Commit 96fdf90aacd6
tarball32 files changed+17892-0
@@ -0,0 +1,2 @@
1
+/target2
+@@ -0,0 +1,4728 @@
1
+# This file is automatically @generated by Cargo.2
+# It is not intended for manual editing.3
+version = 44
+5
+[[package]]6
+name = "accessory"7
+version = "2.1.1"8
+source = "registry+https://github.com/rust-lang/crates.io-index"9
+checksum = "cc86a613208ca7d144ca24f6ec49c5cdfa5c05a46f2f7ab5880ff99371f165de"10
+dependencies = [11
+ "macroific 3.0.1",12
+ "proc-macro2",13
+ "quote",14
+ "syn 3.0.5",15
+]16
+17
+[[package]]18
+name = "adler2"19
+version = "2.0.1"20
+source = "registry+https://github.com/rust-lang/crates.io-index"21
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"22
+23
+[[package]]24
+name = "aead"25
+version = "0.5.2"26
+source = "registry+https://github.com/rust-lang/crates.io-index"27
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"28
+dependencies = [29
+ "crypto-common",30
+ "generic-array",31
+]32
+33
+[[package]]34
+name = "aes"35
+version = "0.8.4"36
+source = "registry+https://github.com/rust-lang/crates.io-index"37
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"38
+dependencies = [39
+ "cfg-if",40
+ "cipher",41
+ "cpufeatures 0.2.17",42
+]43
+44
+[[package]]45
+name = "aho-corasick"46
+version = "1.1.5"47
+source = "registry+https://github.com/rust-lang/crates.io-index"48
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"49
+dependencies = [50
+ "memchr",51
+]52
+53
+[[package]]54
+name = "android_system_properties"55
+version = "0.1.6"56
+source = "registry+https://github.com/rust-lang/crates.io-index"57
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"58
+dependencies = [59
+ "libc",60
+]61
+62
+[[package]]63
+name = "anyhow"64
+version = "1.0.104"65
+source = "registry+https://github.com/rust-lang/crates.io-index"66
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"67
+68
+[[package]]69
+name = "anymap2"70
+version = "0.13.0"71
+source = "registry+https://github.com/rust-lang/crates.io-index"72
+checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"73
+74
+[[package]]75
+name = "aquamarine"76
+version = "0.6.0"77
+source = "registry+https://github.com/rust-lang/crates.io-index"78
+checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2"79
+dependencies = [80
+ "include_dir",81
+ "itertools 0.10.5",82
+ "proc-macro-error2",83
+ "proc-macro2",84
+ "quote",85
+ "syn 2.0.119",86
+]87
+88
+[[package]]89
+name = "archery"90
+version = "1.2.3"91
+source = "registry+https://github.com/rust-lang/crates.io-index"92
+checksum = "33ca55ee147b1926dbea904f50fe4902494e97bc742205abbbf10c709e43815f"93
+94
+[[package]]95
+name = "arrayvec"96
+version = "0.7.8"97
+source = "registry+https://github.com/rust-lang/crates.io-index"98
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"99
+dependencies = [100
+ "serde",101
+]102
+103
+[[package]]104
+name = "as_variant"105
+version = "1.3.0"106
+source = "registry+https://github.com/rust-lang/crates.io-index"107
+checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a"108
+109
+[[package]]110
+name = "assert-json-diff"111
+version = "2.0.2"112
+source = "registry+https://github.com/rust-lang/crates.io-index"113
+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"114
+dependencies = [115
+ "serde",116
+ "serde_json",117
+]118
+119
+[[package]]120
+name = "assert_matches"121
+version = "1.5.0"122
+source = "registry+https://github.com/rust-lang/crates.io-index"123
+checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"124
+125
+[[package]]126
+name = "assert_matches2"127
+version = "0.1.2"128
+source = "registry+https://github.com/rust-lang/crates.io-index"129
+checksum = "15832d94c458da98cac0ffa6eca52cc19c2a3c6c951058500a5ae8f01f0fdf56"130
+131
+[[package]]132
+name = "assign"133
+version = "1.1.1"134
+source = "registry+https://github.com/rust-lang/crates.io-index"135
+checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002"136
+137
+[[package]]138
+name = "async-channel"139
+version = "2.5.0"140
+source = "registry+https://github.com/rust-lang/crates.io-index"141
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"142
+dependencies = [143
+ "concurrent-queue",144
+ "event-listener-strategy",145
+ "futures-core",146
+ "pin-project-lite",147
+]148
+149
+[[package]]150
+name = "async-compression"151
+version = "0.4.45"152
+source = "registry+https://github.com/rust-lang/crates.io-index"153
+checksum = "24a8ec73eb862508b7041723c89386365894b4e7d9f6998bf1b8529e5b0ee254"154
+dependencies = [155
+ "compression-codecs",156
+ "compression-core",157
+ "pin-project-lite",158
+ "tokio",159
+]160
+161
+[[package]]162
+name = "async-once-cell"163
+version = "0.5.4"164
+source = "registry+https://github.com/rust-lang/crates.io-index"165
+checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"166
+167
+[[package]]168
+name = "async-rx"169
+version = "0.2.1"170
+source = "registry+https://github.com/rust-lang/crates.io-index"171
+checksum = "a82c1a761cd7b772912fd2500f2c0a850c0c72f5f66543038393ac6cfaa3659a"172
+dependencies = [173
+ "futures-core",174
+ "pin-project-lite",175
+]176
+177
+[[package]]178
+name = "async-stream"179
+version = "0.3.6"180
+source = "registry+https://github.com/rust-lang/crates.io-index"181
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"182
+dependencies = [183
+ "async-stream-impl",184
+ "futures-core",185
+ "pin-project-lite",186
+]187
+188
+[[package]]189
+name = "async-stream-impl"190
+version = "0.3.6"191
+source = "registry+https://github.com/rust-lang/crates.io-index"192
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"193
+dependencies = [194
+ "proc-macro2",195
+ "quote",196
+ "syn 2.0.119",197
+]198
+199
+[[package]]200
+name = "async-trait"201
+version = "0.1.92"202
+source = "registry+https://github.com/rust-lang/crates.io-index"203
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"204
+dependencies = [205
+ "proc-macro2",206
+ "quote",207
+ "syn 3.0.5",208
+]209
+210
+[[package]]211
+name = "async_cell"212
+version = "0.2.3"213
+source = "registry+https://github.com/rust-lang/crates.io-index"214
+checksum = "447ab28afbb345f5408b120702a44e5529ebf90b1796ec76e9528df8e288e6c2"215
+dependencies = [216
+ "loom",217
+]218
+219
+[[package]]220
+name = "atomic-waker"221
+version = "1.1.2"222
+source = "registry+https://github.com/rust-lang/crates.io-index"223
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"224
+225
+[[package]]226
+name = "autocfg"227
+version = "1.5.1"228
+source = "registry+https://github.com/rust-lang/crates.io-index"229
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"230
+231
+[[package]]232
+name = "aws-lc-rs"233
+version = "1.18.1"234
+source = "registry+https://github.com/rust-lang/crates.io-index"235
+checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"236
+dependencies = [237
+ "aws-lc-sys",238
+ "zeroize",239
+]240
+241
+[[package]]242
+name = "aws-lc-sys"243
+version = "0.45.0"244
+source = "registry+https://github.com/rust-lang/crates.io-index"245
+checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"246
+dependencies = [247
+ "cc",248
+ "cmake",249
+ "dunce",250
+ "fs_extra",251
+ "pkg-config",252
+]253
+254
+[[package]]255
+name = "backon"256
+version = "1.6.0"257
+source = "registry+https://github.com/rust-lang/crates.io-index"258
+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"259
+dependencies = [260
+ "fastrand",261
+ "gloo-timers",262
+ "tokio",263
+]264
+265
+[[package]]266
+name = "base64"267
+version = "0.22.1"268
+source = "registry+https://github.com/rust-lang/crates.io-index"269
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"270
+271
+[[package]]272
+name = "base64ct"273
+version = "1.8.3"274
+source = "registry+https://github.com/rust-lang/crates.io-index"275
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"276
+277
+[[package]]278
+name = "bitflags"279
+version = "2.13.1"280
+source = "registry+https://github.com/rust-lang/crates.io-index"281
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"282
+dependencies = [283
+ "serde_core",284
+]285
+286
+[[package]]287
+name = "bitmaps"288
+version = "3.2.1"289
+source = "registry+https://github.com/rust-lang/crates.io-index"290
+checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"291
+292
+[[package]]293
+name = "blake3"294
+version = "1.8.7"295
+source = "registry+https://github.com/rust-lang/crates.io-index"296
+checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"297
+dependencies = [298
+ "arrayvec",299
+ "cc",300
+ "cfg-if",301
+ "constant_time_eq",302
+ "cpufeatures 0.3.1",303
+]304
+305
+[[package]]306
+name = "block-buffer"307
+version = "0.10.4"308
+source = "registry+https://github.com/rust-lang/crates.io-index"309
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"310
+dependencies = [311
+ "generic-array",312
+]313
+314
+[[package]]315
+name = "block-padding"316
+version = "0.3.3"317
+source = "registry+https://github.com/rust-lang/crates.io-index"318
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"319
+dependencies = [320
+ "generic-array",321
+]322
+323
+[[package]]324
+name = "bs58"325
+version = "0.5.1"326
+source = "registry+https://github.com/rust-lang/crates.io-index"327
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"328
+dependencies = [329
+ "tinyvec",330
+]331
+332
+[[package]]333
+name = "bumpalo"334
+version = "3.20.3"335
+source = "registry+https://github.com/rust-lang/crates.io-index"336
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"337
+338
+[[package]]339
+name = "byteorder"340
+version = "1.5.0"341
+source = "registry+https://github.com/rust-lang/crates.io-index"342
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"343
+344
+[[package]]345
+name = "bytes"346
+version = "1.12.1"347
+source = "registry+https://github.com/rust-lang/crates.io-index"348
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"349
+350
+[[package]]351
+name = "bytesize"352
+version = "2.7.0"353
+source = "registry+https://github.com/rust-lang/crates.io-index"354
+checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b"355
+356
+[[package]]357
+name = "cast"358
+version = "0.3.0"359
+source = "registry+https://github.com/rust-lang/crates.io-index"360
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"361
+362
+[[package]]363
+name = "cbc"364
+version = "0.1.2"365
+source = "registry+https://github.com/rust-lang/crates.io-index"366
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"367
+dependencies = [368
+ "cipher",369
+]370
+371
+[[package]]372
+name = "cc"373
+version = "1.4.5"374
+source = "registry+https://github.com/rust-lang/crates.io-index"375
+checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"376
+dependencies = [377
+ "find-msvc-tools",378
+ "jobserver",379
+ "libc",380
+ "shlex",381
+]382
+383
+[[package]]384
+name = "cfg-if"385
+version = "1.0.4"386
+source = "registry+https://github.com/rust-lang/crates.io-index"387
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"388
+389
+[[package]]390
+name = "cfg_aliases"391
+version = "0.2.2"392
+source = "registry+https://github.com/rust-lang/crates.io-index"393
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"394
+395
+[[package]]396
+name = "chacha20"397
+version = "0.9.1"398
+source = "registry+https://github.com/rust-lang/crates.io-index"399
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"400
+dependencies = [401
+ "cfg-if",402
+ "cipher",403
+ "cpufeatures 0.2.17",404
+]405
+406
+[[package]]407
+name = "chacha20"408
+version = "0.10.2"409
+source = "registry+https://github.com/rust-lang/crates.io-index"410
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"411
+dependencies = [412
+ "cfg-if",413
+ "cpufeatures 0.3.1",414
+ "rand_core 0.10.1",415
+]416
+417
+[[package]]418
+name = "chacha20poly1305"419
+version = "0.10.1"420
+source = "registry+https://github.com/rust-lang/crates.io-index"421
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"422
+dependencies = [423
+ "aead",424
+ "chacha20 0.9.1",425
+ "cipher",426
+ "poly1305",427
+ "zeroize",428
+]429
+430
+[[package]]431
+name = "chrono"432
+version = "0.4.45"433
+source = "registry+https://github.com/rust-lang/crates.io-index"434
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"435
+dependencies = [436
+ "iana-time-zone",437
+ "js-sys",438
+ "num-traits",439
+ "serde",440
+ "wasm-bindgen",441
+ "windows-link",442
+]443
+444
+[[package]]445
+name = "cipher"446
+version = "0.4.4"447
+source = "registry+https://github.com/rust-lang/crates.io-index"448
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"449
+dependencies = [450
+ "crypto-common",451
+ "inout",452
+ "zeroize",453
+]454
+455
+[[package]]456
+name = "cmake"457
+version = "0.1.58"458
+source = "registry+https://github.com/rust-lang/crates.io-index"459
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"460
+dependencies = [461
+ "cc",462
+]463
+464
+[[package]]465
+name = "combine"466
+version = "4.6.8"467
+source = "registry+https://github.com/rust-lang/crates.io-index"468
+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"469
+dependencies = [470
+ "bytes",471
+ "memchr",472
+]473
+474
+[[package]]475
+name = "compression-codecs"476
+version = "0.4.40"477
+source = "registry+https://github.com/rust-lang/crates.io-index"478
+checksum = "100590da849306918656ffbb22576bbdfc1382b50ad10d1d50ec177d3b205fb2"479
+dependencies = [480
+ "compression-core",481
+ "flate2",482
+ "memchr",483
+]484
+485
+[[package]]486
+name = "compression-core"487
+version = "0.4.33"488
+source = "registry+https://github.com/rust-lang/crates.io-index"489
+checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"490
+491
+[[package]]492
+name = "concurrent-queue"493
+version = "2.5.0"494
+source = "registry+https://github.com/rust-lang/crates.io-index"495
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"496
+dependencies = [497
+ "crossbeam-utils",498
+]499
+500
+[[package]]501
+name = "console"502
+version = "0.16.4"503
+source = "registry+https://github.com/rust-lang/crates.io-index"504
+checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"505
+dependencies = [506
+ "encode_unicode",507
+ "libc",508
+ "windows-sys 0.61.2",509
+]510
+511
+[[package]]512
+name = "const-oid"513
+version = "0.9.6"514
+source = "registry+https://github.com/rust-lang/crates.io-index"515
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"516
+517
+[[package]]518
+name = "const_panic"519
+version = "0.2.17"520
+source = "registry+https://github.com/rust-lang/crates.io-index"521
+checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3"522
+dependencies = [523
+ "typewit",524
+]525
+526
+[[package]]527
+name = "constant_time_eq"528
+version = "0.4.2"529
+source = "registry+https://github.com/rust-lang/crates.io-index"530
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"531
+532
+[[package]]533
+name = "core-foundation"534
+version = "0.10.1"535
+source = "registry+https://github.com/rust-lang/crates.io-index"536
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"537
+dependencies = [538
+ "core-foundation-sys",539
+ "libc",540
+]541
+542
+[[package]]543
+name = "core-foundation-sys"544
+version = "0.8.7"545
+source = "registry+https://github.com/rust-lang/crates.io-index"546
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"547
+548
+[[package]]549
+name = "cpufeatures"550
+version = "0.2.17"551
+source = "registry+https://github.com/rust-lang/crates.io-index"552
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"553
+dependencies = [554
+ "libc",555
+]556
+557
+[[package]]558
+name = "cpufeatures"559
+version = "0.3.1"560
+source = "registry+https://github.com/rust-lang/crates.io-index"561
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"562
+dependencies = [563
+ "libc",564
+]565
+566
+[[package]]567
+name = "crc32fast"568
+version = "1.5.1"569
+source = "registry+https://github.com/rust-lang/crates.io-index"570
+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"571
+dependencies = [572
+ "cfg-if",573
+]574
+575
+[[package]]576
+name = "crossbeam-utils"577
+version = "0.8.23"578
+source = "registry+https://github.com/rust-lang/crates.io-index"579
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"580
+581
+[[package]]582
+name = "crypto-common"583
+version = "0.1.7"584
+source = "registry+https://github.com/rust-lang/crates.io-index"585
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"586
+dependencies = [587
+ "generic-array",588
+ "rand_core 0.6.4",589
+ "typenum",590
+]591
+592
+[[package]]593
+name = "ctor"594
+version = "0.2.9"595
+source = "registry+https://github.com/rust-lang/crates.io-index"596
+checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"597
+dependencies = [598
+ "quote",599
+ "syn 2.0.119",600
+]601
+602
+[[package]]603
+name = "ctr"604
+version = "0.9.2"605
+source = "registry+https://github.com/rust-lang/crates.io-index"606
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"607
+dependencies = [608
+ "cipher",609
+]610
+611
+[[package]]612
+name = "curve25519-dalek"613
+version = "4.1.3"614
+source = "registry+https://github.com/rust-lang/crates.io-index"615
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"616
+dependencies = [617
+ "cfg-if",618
+ "cpufeatures 0.2.17",619
+ "curve25519-dalek-derive",620
+ "digest",621
+ "fiat-crypto",622
+ "rustc_version",623
+ "serde",624
+ "subtle",625
+ "zeroize",626
+]627
+628
+[[package]]629
+name = "curve25519-dalek-derive"630
+version = "0.1.1"631
+source = "registry+https://github.com/rust-lang/crates.io-index"632
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"633
+dependencies = [634
+ "proc-macro2",635
+ "quote",636
+ "syn 2.0.119",637
+]638
+639
+[[package]]640
+name = "date_header"641
+version = "1.0.5"642
+source = "registry+https://github.com/rust-lang/crates.io-index"643
+checksum = "0c03c416ed1a30fbb027ef484ba6ab6f80e1eada675e1a2b92fd673c045a1f1d"644
+645
+[[package]]646
+name = "deadpool"647
+version = "0.12.3"648
+source = "registry+https://github.com/rust-lang/crates.io-index"649
+checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"650
+dependencies = [651
+ "deadpool-runtime 0.1.4",652
+ "lazy_static",653
+ "num_cpus",654
+ "tokio",655
+]656
+657
+[[package]]658
+name = "deadpool"659
+version = "0.13.1"660
+source = "registry+https://github.com/rust-lang/crates.io-index"661
+checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e"662
+dependencies = [663
+ "deadpool-runtime 0.3.1",664
+ "tokio",665
+]666
+667
+[[package]]668
+name = "deadpool-runtime"669
+version = "0.1.4"670
+source = "registry+https://github.com/rust-lang/crates.io-index"671
+checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"672
+673
+[[package]]674
+name = "deadpool-runtime"675
+version = "0.3.1"676
+source = "registry+https://github.com/rust-lang/crates.io-index"677
+checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae"678
+dependencies = [679
+ "tokio",680
+]681
+682
+[[package]]683
+name = "deadpool-sync"684
+version = "0.2.0"685
+source = "registry+https://github.com/rust-lang/crates.io-index"686
+checksum = "e385cc95d3d582c328b36d1ff90feac061102b001894b555e6b465a2e0eaabbf"687
+dependencies = [688
+ "deadpool-runtime 0.3.1",689
+]690
+691
+[[package]]692
+name = "decancer"693
+version = "3.3.3"694
+source = "registry+https://github.com/rust-lang/crates.io-index"695
+checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"696
+697
+[[package]]698
+name = "delegate-display"699
+version = "3.0.1"700
+source = "registry+https://github.com/rust-lang/crates.io-index"701
+checksum = "54e3a499943fd5180aeffcb708164ffb22125ceeccb05491b5297981d37d6249"702
+dependencies = [703
+ "impartial-ord",704
+ "itoa",705
+ "macroific 3.0.1",706
+ "proc-macro2",707
+ "quote",708
+ "syn 3.0.5",709
+]710
+711
+[[package]]712
+name = "der"713
+version = "0.7.10"714
+source = "registry+https://github.com/rust-lang/crates.io-index"715
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"716
+dependencies = [717
+ "const-oid",718
+ "zeroize",719
+]720
+721
+[[package]]722
+name = "deranged"723
+version = "0.5.8"724
+source = "registry+https://github.com/rust-lang/crates.io-index"725
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"726
+727
+[[package]]728
+name = "derivative"729
+version = "2.2.0"730
+source = "registry+https://github.com/rust-lang/crates.io-index"731
+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"732
+dependencies = [733
+ "proc-macro2",734
+ "quote",735
+ "syn 1.0.109",736
+]737
+738
+[[package]]739
+name = "derive_more"740
+version = "1.0.0"741
+source = "registry+https://github.com/rust-lang/crates.io-index"742
+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"743
+dependencies = [744
+ "derive_more-impl 1.0.0",745
+]746
+747
+[[package]]748
+name = "derive_more"749
+version = "2.1.1"750
+source = "registry+https://github.com/rust-lang/crates.io-index"751
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"752
+dependencies = [753
+ "derive_more-impl 2.1.1",754
+]755
+756
+[[package]]757
+name = "derive_more-impl"758
+version = "1.0.0"759
+source = "registry+https://github.com/rust-lang/crates.io-index"760
+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"761
+dependencies = [762
+ "proc-macro2",763
+ "quote",764
+ "syn 2.0.119",765
+]766
+767
+[[package]]768
+name = "derive_more-impl"769
+version = "2.1.1"770
+source = "registry+https://github.com/rust-lang/crates.io-index"771
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"772
+dependencies = [773
+ "proc-macro2",774
+ "quote",775
+ "rustc_version",776
+ "syn 2.0.119",777
+ "unicode-xid",778
+]779
+780
+[[package]]781
+name = "digest"782
+version = "0.10.7"783
+source = "registry+https://github.com/rust-lang/crates.io-index"784
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"785
+dependencies = [786
+ "block-buffer",787
+ "crypto-common",788
+ "subtle",789
+]790
+791
+[[package]]792
+name = "displaydoc"793
+version = "0.2.7"794
+source = "registry+https://github.com/rust-lang/crates.io-index"795
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"796
+dependencies = [797
+ "proc-macro2",798
+ "quote",799
+ "syn 3.0.5",800
+]801
+802
+[[package]]803
+name = "dunce"804
+version = "1.0.5"805
+source = "registry+https://github.com/rust-lang/crates.io-index"806
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"807
+808
+[[package]]809
+name = "ed25519"810
+version = "2.2.3"811
+source = "registry+https://github.com/rust-lang/crates.io-index"812
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"813
+dependencies = [814
+ "pkcs8",815
+ "serde",816
+ "signature",817
+]818
+819
+[[package]]820
+name = "ed25519-dalek"821
+version = "2.2.0"822
+source = "registry+https://github.com/rust-lang/crates.io-index"823
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"824
+dependencies = [825
+ "curve25519-dalek",826
+ "ed25519",827
+ "rand_core 0.6.4",828
+ "serde",829
+ "sha2",830
+ "subtle",831
+ "zeroize",832
+]833
+834
+[[package]]835
+name = "either"836
+version = "1.18.0"837
+source = "registry+https://github.com/rust-lang/crates.io-index"838
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"839
+840
+[[package]]841
+name = "emojis"842
+version = "0.8.2"843
+source = "registry+https://github.com/rust-lang/crates.io-index"844
+checksum = "0a4d5d50b0b58df5173d8ff1192b4d1422ceae5d981b30d4b6f8ed1d673a2bc4"845
+dependencies = [846
+ "phf",847
+]848
+849
+[[package]]850
+name = "encode_unicode"851
+version = "1.0.0"852
+source = "registry+https://github.com/rust-lang/crates.io-index"853
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"854
+855
+[[package]]856
+name = "equivalent"857
+version = "1.0.2"858
+source = "registry+https://github.com/rust-lang/crates.io-index"859
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"860
+861
+[[package]]862
+name = "errno"863
+version = "0.3.14"864
+source = "registry+https://github.com/rust-lang/crates.io-index"865
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"866
+dependencies = [867
+ "libc",868
+ "windows-sys 0.61.2",869
+]870
+871
+[[package]]872
+name = "event-listener"873
+version = "5.4.2"874
+source = "registry+https://github.com/rust-lang/crates.io-index"875
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"876
+dependencies = [877
+ "parking",878
+ "pin-project-lite",879
+]880
+881
+[[package]]882
+name = "event-listener-strategy"883
+version = "0.5.4"884
+source = "registry+https://github.com/rust-lang/crates.io-index"885
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"886
+dependencies = [887
+ "event-listener",888
+ "pin-project-lite",889
+]890
+891
+[[package]]892
+name = "eyeball"893
+version = "0.8.8"894
+source = "registry+https://github.com/rust-lang/crates.io-index"895
+checksum = "d93bd0ebf93d61d6332d3c09a96e97975968a44e19a64c947bde06e6baff383f"896
+dependencies = [897
+ "futures-core",898
+ "readlock",899
+ "readlock-tokio",900
+ "tokio",901
+ "tokio-util",902
+ "tracing",903
+]904
+905
+[[package]]906
+name = "eyeball-im"907
+version = "0.8.0"908
+source = "registry+https://github.com/rust-lang/crates.io-index"909
+checksum = "4790c03df183c2b46665c1a58118c04fd3e3976ec2fe16a0aa00e00c9eea7754"910
+dependencies = [911
+ "futures-core",912
+ "imbl",913
+ "tokio",914
+ "tracing",915
+]916
+917
+[[package]]918
+name = "eyeball-im-util"919
+version = "0.10.0"920
+source = "registry+https://github.com/rust-lang/crates.io-index"921
+checksum = "809fa2e8e59c25a41f5b2440aa937f1e84df6ae48b707e65f69ce259b1c44bda"922
+dependencies = [923
+ "arrayvec",924
+ "eyeball-im",925
+ "futures-core",926
+ "imbl",927
+ "pin-project-lite",928
+ "smallvec",929
+]930
+931
+[[package]]932
+name = "fallible-iterator"933
+version = "0.3.0"934
+source = "registry+https://github.com/rust-lang/crates.io-index"935
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"936
+937
+[[package]]938
+name = "fallible-streaming-iterator"939
+version = "0.1.9"940
+source = "registry+https://github.com/rust-lang/crates.io-index"941
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"942
+943
+[[package]]944
+name = "fancy_constructor"945
+version = "2.1.1"946
+source = "registry+https://github.com/rust-lang/crates.io-index"947
+checksum = "4a8211ab12c36b63c269e17873331dc1da196a3edb7a5c79015677ef972da34a"948
+dependencies = [949
+ "macroific 3.0.1",950
+ "proc-macro2",951
+ "quote",952
+ "syn 3.0.5",953
+]954
+955
+[[package]]956
+name = "fastrand"957
+version = "2.5.0"958
+source = "registry+https://github.com/rust-lang/crates.io-index"959
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"960
+961
+[[package]]962
+name = "fiat-crypto"963
+version = "0.2.9"964
+source = "registry+https://github.com/rust-lang/crates.io-index"965
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"966
+967
+[[package]]968
+name = "find-msvc-tools"969
+version = "0.1.12"970
+source = "registry+https://github.com/rust-lang/crates.io-index"971
+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"972
+973
+[[package]]974
+name = "flate2"975
+version = "1.1.10"976
+source = "registry+https://github.com/rust-lang/crates.io-index"977
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"978
+dependencies = [979
+ "crc32fast",980
+ "miniz_oxide",981
+ "zlib-rs",982
+]983
+984
+[[package]]985
+name = "fnv"986
+version = "1.0.7"987
+source = "registry+https://github.com/rust-lang/crates.io-index"988
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"989
+990
+[[package]]991
+name = "foldhash"992
+version = "0.1.5"993
+source = "registry+https://github.com/rust-lang/crates.io-index"994
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"995
+996
+[[package]]997
+name = "form_urlencoded"998
+version = "1.2.2"999
+source = "registry+https://github.com/rust-lang/crates.io-index"1000
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"1001
+dependencies = [1002
+ "percent-encoding",1003
+]1004
+1005
+[[package]]1006
+name = "fs_extra"1007
+version = "1.3.0"1008
+source = "registry+https://github.com/rust-lang/crates.io-index"1009
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"1010
+1011
+[[package]]1012
+name = "futures"1013
+version = "0.3.34"1014
+source = "registry+https://github.com/rust-lang/crates.io-index"1015
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"1016
+dependencies = [1017
+ "futures-channel",1018
+ "futures-core",1019
+ "futures-executor",1020
+ "futures-io",1021
+ "futures-sink",1022
+ "futures-task",1023
+ "futures-util",1024
+]1025
+1026
+[[package]]1027
+name = "futures-channel"1028
+version = "0.3.34"1029
+source = "registry+https://github.com/rust-lang/crates.io-index"1030
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"1031
+dependencies = [1032
+ "futures-core",1033
+ "futures-sink",1034
+]1035
+1036
+[[package]]1037
+name = "futures-core"1038
+version = "0.3.34"1039
+source = "registry+https://github.com/rust-lang/crates.io-index"1040
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"1041
+1042
+[[package]]1043
+name = "futures-executor"1044
+version = "0.3.34"1045
+source = "registry+https://github.com/rust-lang/crates.io-index"1046
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"1047
+dependencies = [1048
+ "futures-core",1049
+ "futures-task",1050
+ "futures-util",1051
+]1052
+1053
+[[package]]1054
+name = "futures-io"1055
+version = "0.3.34"1056
+source = "registry+https://github.com/rust-lang/crates.io-index"1057
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"1058
+1059
+[[package]]1060
+name = "futures-macro"1061
+version = "0.3.34"1062
+source = "registry+https://github.com/rust-lang/crates.io-index"1063
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"1064
+dependencies = [1065
+ "proc-macro2",1066
+ "quote",1067
+ "syn 3.0.5",1068
+]1069
+1070
+[[package]]1071
+name = "futures-sink"1072
+version = "0.3.34"1073
+source = "registry+https://github.com/rust-lang/crates.io-index"1074
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"1075
+1076
+[[package]]1077
+name = "futures-task"1078
+version = "0.3.34"1079
+source = "registry+https://github.com/rust-lang/crates.io-index"1080
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"1081
+1082
+[[package]]1083
+name = "futures-util"1084
+version = "0.3.34"1085
+source = "registry+https://github.com/rust-lang/crates.io-index"1086
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"1087
+dependencies = [1088
+ "futures-channel",1089
+ "futures-core",1090
+ "futures-io",1091
+ "futures-macro",1092
+ "futures-sink",1093
+ "futures-task",1094
+ "memchr",1095
+ "pin-project-lite",1096
+ "slab",1097
+]1098
+1099
+[[package]]1100
+name = "fuzzy-matcher"1101
+version = "0.3.7"1102
+source = "registry+https://github.com/rust-lang/crates.io-index"1103
+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"1104
+dependencies = [1105
+ "thread_local",1106
+]1107
+1108
+[[package]]1109
+name = "generator"1110
+version = "0.8.9"1111
+source = "registry+https://github.com/rust-lang/crates.io-index"1112
+checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"1113
+dependencies = [1114
+ "cc",1115
+ "cfg-if",1116
+ "libc",1117
+ "log",1118
+ "rustversion",1119
+ "windows-link",1120
+ "windows-result",1121
+]1122
+1123
+[[package]]1124
+name = "generic-array"1125
+version = "0.14.7"1126
+source = "registry+https://github.com/rust-lang/crates.io-index"1127
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"1128
+dependencies = [1129
+ "typenum",1130
+ "version_check",1131
+]1132
+1133
+[[package]]1134
+name = "getrandom"1135
+version = "0.2.17"1136
+source = "registry+https://github.com/rust-lang/crates.io-index"1137
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"1138
+dependencies = [1139
+ "cfg-if",1140
+ "js-sys",1141
+ "libc",1142
+ "wasi",1143
+ "wasm-bindgen",1144
+]1145
+1146
+[[package]]1147
+name = "getrandom"1148
+version = "0.3.4"1149
+source = "registry+https://github.com/rust-lang/crates.io-index"1150
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"1151
+dependencies = [1152
+ "cfg-if",1153
+ "libc",1154
+ "r-efi 5.3.0",1155
+ "wasip2",1156
+]1157
+1158
+[[package]]1159
+name = "getrandom"1160
+version = "0.4.3"1161
+source = "registry+https://github.com/rust-lang/crates.io-index"1162
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"1163
+dependencies = [1164
+ "cfg-if",1165
+ "js-sys",1166
+ "libc",1167
+ "r-efi 6.0.0",1168
+ "rand_core 0.10.1",1169
+ "wasm-bindgen",1170
+]1171
+1172
+[[package]]1173
+name = "gloo-timers"1174
+version = "0.3.0"1175
+source = "registry+https://github.com/rust-lang/crates.io-index"1176
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"1177
+dependencies = [1178
+ "futures-channel",1179
+ "futures-core",1180
+ "js-sys",1181
+ "wasm-bindgen",1182
+]1183
+1184
+[[package]]1185
+name = "gloo-utils"1186
+version = "0.2.0"1187
+source = "registry+https://github.com/rust-lang/crates.io-index"1188
+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"1189
+dependencies = [1190
+ "js-sys",1191
+ "serde",1192
+ "serde_json",1193
+ "wasm-bindgen",1194
+ "web-sys",1195
+]1196
+1197
+[[package]]1198
+name = "growable-bloom-filter"1199
+version = "2.1.1"1200
+source = "registry+https://github.com/rust-lang/crates.io-index"1201
+checksum = "d174ccb4ba660d431329e7f0797870d0a4281e36353ec4b4a3c5eab6c2cfb6f1"1202
+dependencies = [1203
+ "serde",1204
+ "serde_bytes",1205
+ "serde_derive",1206
+ "xxhash-rust",1207
+]1208
+1209
+[[package]]1210
+name = "h2"1211
+version = "0.4.19"1212
+source = "registry+https://github.com/rust-lang/crates.io-index"1213
+checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"1214
+dependencies = [1215
+ "atomic-waker",1216
+ "bytes",1217
+ "fnv",1218
+ "futures-core",1219
+ "futures-sink",1220
+ "http",1221
+ "indexmap",1222
+ "slab",1223
+ "tokio",1224
+ "tokio-util",1225
+ "tracing",1226
+]1227
+1228
+[[package]]1229
+name = "hashbrown"1230
+version = "0.15.5"1231
+source = "registry+https://github.com/rust-lang/crates.io-index"1232
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"1233
+dependencies = [1234
+ "foldhash",1235
+]1236
+1237
+[[package]]1238
+name = "hashbrown"1239
+version = "0.17.1"1240
+source = "registry+https://github.com/rust-lang/crates.io-index"1241
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"1242
+1243
+[[package]]1244
+name = "hashlink"1245
+version = "0.10.0"1246
+source = "registry+https://github.com/rust-lang/crates.io-index"1247
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"1248
+dependencies = [1249
+ "hashbrown 0.15.5",1250
+]1251
+1252
+[[package]]1253
+name = "hermit-abi"1254
+version = "0.5.3"1255
+source = "registry+https://github.com/rust-lang/crates.io-index"1256
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"1257
+1258
+[[package]]1259
+name = "hkdf"1260
+version = "0.12.4"1261
+source = "registry+https://github.com/rust-lang/crates.io-index"1262
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"1263
+dependencies = [1264
+ "hmac",1265
+]1266
+1267
+[[package]]1268
+name = "hmac"1269
+version = "0.12.1"1270
+source = "registry+https://github.com/rust-lang/crates.io-index"1271
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"1272
+dependencies = [1273
+ "digest",1274
+]1275
+1276
+[[package]]1277
+name = "html5ever"1278
+version = "0.39.0"1279
+source = "registry+https://github.com/rust-lang/crates.io-index"1280
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"1281
+dependencies = [1282
+ "log",1283
+ "markup5ever",1284
+]1285
+1286
+[[package]]1287
+name = "http"1288
+version = "1.5.0"1289
+source = "registry+https://github.com/rust-lang/crates.io-index"1290
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"1291
+dependencies = [1292
+ "bytes",1293
+ "itoa",1294
+]1295
+1296
+[[package]]1297
+name = "http-body"1298
+version = "1.1.0"1299
+source = "registry+https://github.com/rust-lang/crates.io-index"1300
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"1301
+dependencies = [1302
+ "bytes",1303
+ "http",1304
+]1305
+1306
+[[package]]1307
+name = "http-body-util"1308
+version = "0.1.5"1309
+source = "registry+https://github.com/rust-lang/crates.io-index"1310
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"1311
+dependencies = [1312
+ "bytes",1313
+ "futures-core",1314
+ "http",1315
+ "http-body",1316
+ "pin-project-lite",1317
+]1318
+1319
+[[package]]1320
+name = "httparse"1321
+version = "1.10.1"1322
+source = "registry+https://github.com/rust-lang/crates.io-index"1323
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"1324
+1325
+[[package]]1326
+name = "httpdate"1327
+version = "1.0.3"1328
+source = "registry+https://github.com/rust-lang/crates.io-index"1329
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"1330
+1331
+[[package]]1332
+name = "hyper"1333
+version = "1.11.1"1334
+source = "registry+https://github.com/rust-lang/crates.io-index"1335
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"1336
+dependencies = [1337
+ "atomic-waker",1338
+ "bytes",1339
+ "futures-channel",1340
+ "futures-core",1341
+ "h2",1342
+ "http",1343
+ "http-body",1344
+ "httparse",1345
+ "httpdate",1346
+ "itoa",1347
+ "pin-project-lite",1348
+ "smallvec",1349
+ "tokio",1350
+ "want",1351
+]1352
+1353
+[[package]]1354
+name = "hyper-rustls"1355
+version = "0.27.9"1356
+source = "registry+https://github.com/rust-lang/crates.io-index"1357
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"1358
+dependencies = [1359
+ "http",1360
+ "hyper",1361
+ "hyper-util",1362
+ "rustls",1363
+ "tokio",1364
+ "tokio-rustls",1365
+ "tower-service",1366
+]1367
+1368
+[[package]]1369
+name = "hyper-util"1370
+version = "0.1.20"1371
+source = "registry+https://github.com/rust-lang/crates.io-index"1372
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"1373
+dependencies = [1374
+ "base64",1375
+ "bytes",1376
+ "futures-channel",1377
+ "futures-util",1378
+ "http",1379
+ "http-body",1380
+ "hyper",1381
+ "ipnet",1382
+ "libc",1383
+ "percent-encoding",1384
+ "pin-project-lite",1385
+ "socket2",1386
+ "tokio",1387
+ "tower-service",1388
+ "tracing",1389
+]1390
+1391
+[[package]]1392
+name = "iana-time-zone"1393
+version = "0.1.65"1394
+source = "registry+https://github.com/rust-lang/crates.io-index"1395
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"1396
+dependencies = [1397
+ "android_system_properties",1398
+ "core-foundation-sys",1399
+ "iana-time-zone-haiku",1400
+ "js-sys",1401
+ "log",1402
+ "wasm-bindgen",1403
+ "windows-core",1404
+]1405
+1406
+[[package]]1407
+name = "iana-time-zone-haiku"1408
+version = "0.1.2"1409
+source = "registry+https://github.com/rust-lang/crates.io-index"1410
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"1411
+dependencies = [1412
+ "cc",1413
+]1414
+1415
+[[package]]1416
+name = "icu_collections"1417
+version = "2.3.0"1418
+source = "registry+https://github.com/rust-lang/crates.io-index"1419
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"1420
+dependencies = [1421
+ "displaydoc",1422
+ "potential_utf",1423
+ "utf8_iter",1424
+ "yoke",1425
+ "zerofrom",1426
+ "zerovec",1427
+]1428
+1429
+[[package]]1430
+name = "icu_locale_core"1431
+version = "2.3.0"1432
+source = "registry+https://github.com/rust-lang/crates.io-index"1433
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"1434
+dependencies = [1435
+ "displaydoc",1436
+ "litemap",1437
+ "tinystr",1438
+ "writeable",1439
+ "zerovec",1440
+]1441
+1442
+[[package]]1443
+name = "icu_normalizer"1444
+version = "2.3.0"1445
+source = "registry+https://github.com/rust-lang/crates.io-index"1446
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"1447
+dependencies = [1448
+ "icu_collections",1449
+ "icu_normalizer_data",1450
+ "icu_properties",1451
+ "icu_provider",1452
+ "smallvec",1453
+ "zerovec",1454
+]1455
+1456
+[[package]]1457
+name = "icu_normalizer_data"1458
+version = "2.3.0"1459
+source = "registry+https://github.com/rust-lang/crates.io-index"1460
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"1461
+1462
+[[package]]1463
+name = "icu_properties"1464
+version = "2.3.0"1465
+source = "registry+https://github.com/rust-lang/crates.io-index"1466
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"1467
+dependencies = [1468
+ "displaydoc",1469
+ "icu_collections",1470
+ "icu_locale_core",1471
+ "icu_properties_data",1472
+ "icu_provider",1473
+ "zerotrie",1474
+ "zerovec",1475
+]1476
+1477
+[[package]]1478
+name = "icu_properties_data"1479
+version = "2.3.0"1480
+source = "registry+https://github.com/rust-lang/crates.io-index"1481
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"1482
+1483
+[[package]]1484
+name = "icu_provider"1485
+version = "2.3.1"1486
+source = "registry+https://github.com/rust-lang/crates.io-index"1487
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"1488
+dependencies = [1489
+ "displaydoc",1490
+ "icu_locale_core",1491
+ "writeable",1492
+ "yoke",1493
+ "zerofrom",1494
+ "zerotrie",1495
+ "zerovec",1496
+]1497
+1498
+[[package]]1499
+name = "idna"1500
+version = "1.1.0"1501
+source = "registry+https://github.com/rust-lang/crates.io-index"1502
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"1503
+dependencies = [1504
+ "idna_adapter",1505
+ "smallvec",1506
+ "utf8_iter",1507
+]1508
+1509
+[[package]]1510
+name = "idna_adapter"1511
+version = "1.2.2"1512
+source = "registry+https://github.com/rust-lang/crates.io-index"1513
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"1514
+dependencies = [1515
+ "icu_normalizer",1516
+ "icu_properties",1517
+]1518
+1519
+[[package]]1520
+name = "imbl"1521
+version = "6.1.0"1522
+source = "registry+https://github.com/rust-lang/crates.io-index"1523
+checksum = "0fade8ae6828627ad1fa094a891eccfb25150b383047190a3648d66d06186501"1524
+dependencies = [1525
+ "archery",1526
+ "bitmaps",1527
+ "imbl-sized-chunks",1528
+ "rand_core 0.9.5",1529
+ "rand_xoshiro",1530
+ "serde",1531
+ "version_check",1532
+]1533
+1534
+[[package]]1535
+name = "imbl-sized-chunks"1536
+version = "0.1.3"1537
+source = "registry+https://github.com/rust-lang/crates.io-index"1538
+checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d"1539
+dependencies = [1540
+ "bitmaps",1541
+]1542
+1543
+[[package]]1544
+name = "impartial-ord"1545
+version = "2.0.0"1546
+source = "registry+https://github.com/rust-lang/crates.io-index"1547
+checksum = "bc680e03d1a08bdc1c01b60e0c39723430ced63d8cabd93cee0449b6ba916a25"1548
+dependencies = [1549
+ "proc-macro2",1550
+ "quote",1551
+ "syn 3.0.5",1552
+]1553
+1554
+[[package]]1555
+name = "include_dir"1556
+version = "0.7.4"1557
+source = "registry+https://github.com/rust-lang/crates.io-index"1558
+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"1559
+dependencies = [1560
+ "include_dir_macros",1561
+]1562
+1563
+[[package]]1564
+name = "include_dir_macros"1565
+version = "0.7.4"1566
+source = "registry+https://github.com/rust-lang/crates.io-index"1567
+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"1568
+dependencies = [1569
+ "proc-macro2",1570
+ "quote",1571
+]1572
+1573
+[[package]]1574
+name = "indexmap"1575
+version = "2.14.2"1576
+source = "registry+https://github.com/rust-lang/crates.io-index"1577
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"1578
+dependencies = [1579
+ "equivalent",1580
+ "hashbrown 0.17.1",1581
+ "serde",1582
+ "serde_core",1583
+]1584
+1585
+[[package]]1586
+name = "inout"1587
+version = "0.1.4"1588
+source = "registry+https://github.com/rust-lang/crates.io-index"1589
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"1590
+dependencies = [1591
+ "block-padding",1592
+ "generic-array",1593
+]1594
+1595
+[[package]]1596
+name = "insta"1597
+version = "1.48.0"1598
+source = "registry+https://github.com/rust-lang/crates.io-index"1599
+checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"1600
+dependencies = [1601
+ "console",1602
+ "once_cell",1603
+ "pest",1604
+ "pest_derive",1605
+ "serde",1606
+ "similar",1607
+ "tempfile",1608
+]1609
+1610
+[[package]]1611
+name = "ipnet"1612
+version = "2.12.2"1613
+source = "registry+https://github.com/rust-lang/crates.io-index"1614
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"1615
+1616
+[[package]]1617
+name = "itertools"1618
+version = "0.10.5"1619
+source = "registry+https://github.com/rust-lang/crates.io-index"1620
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"1621
+dependencies = [1622
+ "either",1623
+]1624
+1625
+[[package]]1626
+name = "itertools"1627
+version = "0.14.0"1628
+source = "registry+https://github.com/rust-lang/crates.io-index"1629
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"1630
+dependencies = [1631
+ "either",1632
+]1633
+1634
+[[package]]1635
+name = "itoa"1636
+version = "1.0.18"1637
+source = "registry+https://github.com/rust-lang/crates.io-index"1638
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"1639
+1640
+[[package]]1641
+name = "jni"1642
+version = "0.22.4"1643
+source = "registry+https://github.com/rust-lang/crates.io-index"1644
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"1645
+dependencies = [1646
+ "cfg-if",1647
+ "combine",1648
+ "jni-macros",1649
+ "jni-sys",1650
+ "log",1651
+ "simd_cesu8",1652
+ "thiserror 2.0.20",1653
+ "walkdir",1654
+ "windows-link",1655
+]1656
+1657
+[[package]]1658
+name = "jni-macros"1659
+version = "0.22.4"1660
+source = "registry+https://github.com/rust-lang/crates.io-index"1661
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"1662
+dependencies = [1663
+ "proc-macro2",1664
+ "quote",1665
+ "rustc_version",1666
+ "simd_cesu8",1667
+ "syn 2.0.119",1668
+]1669
+1670
+[[package]]1671
+name = "jni-sys"1672
+version = "0.4.1"1673
+source = "registry+https://github.com/rust-lang/crates.io-index"1674
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"1675
+dependencies = [1676
+ "jni-sys-macros",1677
+]1678
+1679
+[[package]]1680
+name = "jni-sys-macros"1681
+version = "0.4.1"1682
+source = "registry+https://github.com/rust-lang/crates.io-index"1683
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"1684
+dependencies = [1685
+ "quote",1686
+ "syn 2.0.119",1687
+]1688
+1689
+[[package]]1690
+name = "jobserver"1691
+version = "0.1.35"1692
+source = "registry+https://github.com/rust-lang/crates.io-index"1693
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"1694
+dependencies = [1695
+ "getrandom 0.4.3",1696
+ "libc",1697
+]1698
+1699
+[[package]]1700
+name = "js-sys"1701
+version = "0.3.105"1702
+source = "registry+https://github.com/rust-lang/crates.io-index"1703
+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"1704
+dependencies = [1705
+ "cfg-if",1706
+ "futures-util",1707
+ "wasm-bindgen",1708
+]1709
+1710
+[[package]]1711
+name = "js_int"1712
+version = "0.2.2"1713
+source = "registry+https://github.com/rust-lang/crates.io-index"1714
+checksum = "d937f95470b270ce8b8950207715d71aa8e153c0d44c6684d59397ed4949160a"1715
+dependencies = [1716
+ "serde",1717
+]1718
+1719
+[[package]]1720
+name = "js_option"1721
+version = "0.2.0"1722
+source = "registry+https://github.com/rust-lang/crates.io-index"1723
+checksum = "c7dd3e281add16813cf673bf74a32249b0aa0d1c8117519a17b3ada5e8552b3c"1724
+dependencies = [1725
+ "serde_core",1726
+]1727
+1728
+[[package]]1729
+name = "konst"1730
+version = "0.4.3"1731
+source = "registry+https://github.com/rust-lang/crates.io-index"1732
+checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d"1733
+dependencies = [1734
+ "const_panic",1735
+ "typewit",1736
+]1737
+1738
+[[package]]1739
+name = "language-tags"1740
+version = "0.3.2"1741
+source = "registry+https://github.com/rust-lang/crates.io-index"1742
+checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"1743
+1744
+[[package]]1745
+name = "lazy_static"1746
+version = "1.5.0"1747
+source = "registry+https://github.com/rust-lang/crates.io-index"1748
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"1749
+1750
+[[package]]1751
+name = "libc"1752
+version = "0.2.189"1753
+source = "registry+https://github.com/rust-lang/crates.io-index"1754
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"1755
+1756
+[[package]]1757
+name = "libm"1758
+version = "0.2.16"1759
+source = "registry+https://github.com/rust-lang/crates.io-index"1760
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"1761
+1762
+[[package]]1763
+name = "libsqlite3-sys"1764
+version = "0.35.0"1765
+source = "registry+https://github.com/rust-lang/crates.io-index"1766
+checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"1767
+dependencies = [1768
+ "cc",1769
+ "pkg-config",1770
+ "vcpkg",1771
+]1772
+1773
+[[package]]1774
+name = "linux-raw-sys"1775
+version = "0.12.1"1776
+source = "registry+https://github.com/rust-lang/crates.io-index"1777
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"1778
+1779
+[[package]]1780
+name = "litemap"1781
+version = "0.8.3"1782
+source = "registry+https://github.com/rust-lang/crates.io-index"1783
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"1784
+1785
+[[package]]1786
+name = "lock_api"1787
+version = "0.4.14"1788
+source = "registry+https://github.com/rust-lang/crates.io-index"1789
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"1790
+dependencies = [1791
+ "scopeguard",1792
+]1793
+1794
+[[package]]1795
+name = "log"1796
+version = "0.4.34"1797
+source = "registry+https://github.com/rust-lang/crates.io-index"1798
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"1799
+1800
+[[package]]1801
+name = "loom"1802
+version = "0.7.2"1803
+source = "registry+https://github.com/rust-lang/crates.io-index"1804
+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"1805
+dependencies = [1806
+ "cfg-if",1807
+ "generator",1808
+ "scoped-tls",1809
+ "tracing",1810
+ "tracing-subscriber",1811
+]1812
+1813
+[[package]]1814
+name = "lru-slab"1815
+version = "0.1.2"1816
+source = "registry+https://github.com/rust-lang/crates.io-index"1817
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"1818
+1819
+[[package]]1820
+name = "macltok"1821
+version = "0.1.0"1822
+dependencies = [1823
+ "futures-util",1824
+ "http",1825
+ "matrix-sdk",1826
+ "matrix-sdk-crypto",1827
+ "matrix-sdk-test",1828
+ "matrix-sdk-ui",1829
+ "mime",1830
+ "ruma",1831
+ "serde",1832
+ "serde_json",1833
+ "thiserror 2.0.20",1834
+ "tokio",1835
+ "tracing",1836
+ "url",1837
+ "wiremock",1838
+]1839
+1840
+[[package]]1841
+name = "macroific"1842
+version = "2.0.0"1843
+source = "registry+https://github.com/rust-lang/crates.io-index"1844
+checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285"1845
+dependencies = [1846
+ "macroific_attr_parse 2.0.0",1847
+ "macroific_core 2.0.0",1848
+ "macroific_macro 2.0.0",1849
+]1850
+1851
+[[package]]1852
+name = "macroific"1853
+version = "3.0.1"1854
+source = "registry+https://github.com/rust-lang/crates.io-index"1855
+checksum = "c437abb3bf40939b00d1dff858ed2050d948bc230dee39546ed16795fcf10ec2"1856
+dependencies = [1857
+ "macroific_attr_parse 3.0.1",1858
+ "macroific_core 3.0.1",1859
+ "macroific_macro 3.0.1",1860
+]1861
+1862
+[[package]]1863
+name = "macroific_attr_parse"1864
+version = "2.0.0"1865
+source = "registry+https://github.com/rust-lang/crates.io-index"1866
+checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9"1867
+dependencies = [1868
+ "proc-macro2",1869
+ "quote",1870
+ "sealed 0.6.0",1871
+ "syn 2.0.119",1872
+]1873
+1874
+[[package]]1875
+name = "macroific_attr_parse"1876
+version = "3.0.1"1877
+source = "registry+https://github.com/rust-lang/crates.io-index"1878
+checksum = "aeb7a82ec1aa16094da719be18615d79cb2574b037358e4e6c6d96eedcff3a93"1879
+dependencies = [1880
+ "proc-macro2",1881
+ "quote",1882
+ "sealed 0.7.0",1883
+ "syn 3.0.5",1884
+]1885
+1886
+[[package]]1887
+name = "macroific_core"1888
+version = "2.0.0"1889
+source = "registry+https://github.com/rust-lang/crates.io-index"1890
+checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3"1891
+dependencies = [1892
+ "proc-macro2",1893
+ "quote",1894
+ "sealed 0.6.0",1895
+ "syn 2.0.119",1896
+]1897
+1898
+[[package]]1899
+name = "macroific_core"1900
+version = "3.0.1"1901
+source = "registry+https://github.com/rust-lang/crates.io-index"1902
+checksum = "26c8e789484d2fa216eee9d5e0565ba80ccda389bcf0e111143a7b8f4792336e"1903
+dependencies = [1904
+ "proc-macro2",1905
+ "quote",1906
+ "sealed 0.7.0",1907
+ "syn 3.0.5",1908
+]1909
+1910
+[[package]]1911
+name = "macroific_macro"1912
+version = "2.0.0"1913
+source = "registry+https://github.com/rust-lang/crates.io-index"1914
+checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b"1915
+dependencies = [1916
+ "macroific_attr_parse 2.0.0",1917
+ "macroific_core 2.0.0",1918
+ "proc-macro2",1919
+ "quote",1920
+ "syn 2.0.119",1921
+]1922
+1923
+[[package]]1924
+name = "macroific_macro"1925
+version = "3.0.1"1926
+source = "registry+https://github.com/rust-lang/crates.io-index"1927
+checksum = "e30d63cb102648965753f0d47a6ab171b1cbb1fb5c363f4784ea986782fc7b77"1928
+dependencies = [1929
+ "macroific_attr_parse 3.0.1",1930
+ "macroific_core 3.0.1",1931
+ "proc-macro2",1932
+ "quote",1933
+ "syn 3.0.5",1934
+]1935
+1936
+[[package]]1937
+name = "maplit"1938
+version = "1.0.2"1939
+source = "registry+https://github.com/rust-lang/crates.io-index"1940
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"1941
+1942
+[[package]]1943
+name = "markup5ever"1944
+version = "0.39.0"1945
+source = "registry+https://github.com/rust-lang/crates.io-index"1946
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"1947
+dependencies = [1948
+ "log",1949
+ "tendril",1950
+ "web_atoms",1951
+]1952
+1953
+[[package]]1954
+name = "matchers"1955
+version = "0.2.0"1956
+source = "registry+https://github.com/rust-lang/crates.io-index"1957
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"1958
+dependencies = [1959
+ "regex-automata",1960
+]1961
+1962
+[[package]]1963
+name = "matrix-pickle"1964
+version = "0.2.3"1965
+source = "registry+https://github.com/rust-lang/crates.io-index"1966
+checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0"1967
+dependencies = [1968
+ "matrix-pickle-derive",1969
+ "thiserror 2.0.20",1970
+]1971
+1972
+[[package]]1973
+name = "matrix-pickle-derive"1974
+version = "0.2.3"1975
+source = "registry+https://github.com/rust-lang/crates.io-index"1976
+checksum = "414b5e4c34009f2bc3fe35dd018f25755ca38858096574841c7332f99e2c7e77"1977
+dependencies = [1978
+ "proc-macro-crate",1979
+ "proc-macro2",1980
+ "quote",1981
+ "syn 2.0.119",1982
+]1983
+1984
+[[package]]1985
+name = "matrix-sdk"1986
+version = "0.18.0"1987
+source = "registry+https://github.com/rust-lang/crates.io-index"1988
+checksum = "7083d580527511ac5d9369e03b9f2b20902e76949f1b3964051f978e4d3756ae"1989
+dependencies = [1990
+ "anymap2",1991
+ "aquamarine",1992
+ "as_variant",1993
+ "assert_matches2",1994
+ "async-channel",1995
+ "async-once-cell",1996
+ "async-stream",1997
+ "async-trait",1998
+ "backon",1999
+ "bytes",2000
+ "bytesize",2001
+ "cfg-if",2002
+ "event-listener",2003
+ "eyeball",2004
+ "eyeball-im",2005
+ "futures-core",2006
+ "futures-util",2007
+ "gloo-timers",2008
+ "http",2009
+ "imbl",2010
+ "indexmap",2011
+ "itertools 0.14.0",2012
+ "js_int",2013
+ "language-tags",2014
+ "matrix-sdk-base",2015
+ "matrix-sdk-common",2016
+ "matrix-sdk-indexeddb",2017
+ "matrix-sdk-sqlite",2018
+ "matrix-sdk-test",2019
+ "mime",2020
+ "mime2ext",2021
+ "oauth2",2022
+ "oauth2-reqwest",2023
+ "percent-encoding",2024
+ "pin-project-lite",2025
+ "reqwest",2026
+ "ruma",2027
+ "rustls",2028
+ "rustls-native-certs",2029
+ "rustls-pki-types",2030
+ "serde",2031
+ "serde_html_form",2032
+ "serde_json",2033
+ "sha2",2034
+ "tempfile",2035
+ "thiserror 2.0.20",2036
+ "tokio",2037
+ "tokio-stream",2038
+ "tokio-util",2039
+ "tracing",2040
+ "url",2041
+ "urlencoding",2042
+ "vodozemac",2043
+ "webpki-roots",2044
+ "wiremock",2045
+ "zeroize",2046
+]2047
+2048
+[[package]]2049
+name = "matrix-sdk-base"2050
+version = "0.18.0"2051
+source = "registry+https://github.com/rust-lang/crates.io-index"2052
+checksum = "e09a917eb1f7643d9d9a06b2f131e2ceb39df6910a7b830dac18bfd6db37a1f5"2053
+dependencies = [2054
+ "as_variant",2055
+ "assert_matches",2056
+ "assert_matches2",2057
+ "async-trait",2058
+ "bitflags",2059
+ "decancer",2060
+ "eyeball",2061
+ "eyeball-im",2062
+ "futures-util",2063
+ "growable-bloom-filter",2064
+ "http",2065
+ "matrix-sdk-common",2066
+ "matrix-sdk-crypto",2067
+ "matrix-sdk-store-encryption",2068
+ "matrix-sdk-test",2069
+ "regex",2070
+ "ruma",2071
+ "serde",2072
+ "serde_json",2073
+ "thiserror 2.0.20",2074
+ "tokio",2075
+ "tracing",2076
+ "unicode-normalization",2077
+]2078
+2079
+[[package]]2080
+name = "matrix-sdk-common"2081
+version = "0.18.0"2082
+source = "registry+https://github.com/rust-lang/crates.io-index"2083
+checksum = "2b9d1e0fee0f090180ef9457034adc547e80de9e6e3eb2e63aaac68a8476f836"2084
+dependencies = [2085
+ "eyeball-im",2086
+ "futures-core",2087
+ "futures-executor",2088
+ "futures-util",2089
+ "gloo-timers",2090
+ "imbl",2091
+ "ruma",2092
+ "serde",2093
+ "serde_json",2094
+ "thiserror 2.0.20",2095
+ "tokio",2096
+ "tracing",2097
+ "tracing-subscriber",2098
+ "wasm-bindgen",2099
+ "wasm-bindgen-futures",2100
+ "web-sys",2101
+]2102
+2103
+[[package]]2104
+name = "matrix-sdk-crypto"2105
+version = "0.18.0"2106
+source = "registry+https://github.com/rust-lang/crates.io-index"2107
+checksum = "c54afd2a326f51c13a6ad44ec86315a688fffeb3f1e287fb343e0e1836a3bdaf"2108
+dependencies = [2109
+ "aes",2110
+ "aquamarine",2111
+ "as_variant",2112
+ "async-trait",2113
+ "bs58",2114
+ "byteorder",2115
+ "cfg-if",2116
+ "ctr",2117
+ "eyeball",2118
+ "futures-core",2119
+ "futures-util",2120
+ "hkdf",2121
+ "hmac",2122
+ "itertools 0.14.0",2123
+ "js_option",2124
+ "matrix-sdk-common",2125
+ "matrix-sdk-test",2126
+ "pbkdf2",2127
+ "rand 0.10.2",2128
+ "rmp-serde",2129
+ "ruma",2130
+ "serde",2131
+ "serde_json",2132
+ "sha2",2133
+ "subtle",2134
+ "thiserror 2.0.20",2135
+ "time",2136
+ "tokio",2137
+ "tokio-stream",2138
+ "tracing",2139
+ "ulid",2140
+ "url",2141
+ "vodozemac",2142
+ "zeroize",2143
+]2144
+2145
+[[package]]2146
+name = "matrix-sdk-indexeddb"2147
+version = "0.18.0"2148
+source = "registry+https://github.com/rust-lang/crates.io-index"2149
+checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47"2150
+dependencies = [2151
+ "async-trait",2152
+ "base64",2153
+ "futures-util",2154
+ "getrandom 0.4.3",2155
+ "gloo-utils",2156
+ "hkdf",2157
+ "js-sys",2158
+ "matrix-sdk-base",2159
+ "matrix-sdk-crypto",2160
+ "matrix-sdk-store-encryption",2161
+ "matrix_indexed_db_futures",2162
+ "rmp-serde",2163
+ "ruma",2164
+ "serde",2165
+ "serde-wasm-bindgen",2166
+ "serde_json",2167
+ "sha2",2168
+ "thiserror 2.0.20",2169
+ "tokio",2170
+ "tracing",2171
+ "uuid",2172
+ "wasm-bindgen",2173
+ "web-sys",2174
+ "zeroize",2175
+]2176
+2177
+[[package]]2178
+name = "matrix-sdk-sqlite"2179
+version = "0.18.0"2180
+source = "registry+https://github.com/rust-lang/crates.io-index"2181
+checksum = "a49133429271005745f8d5a05362d2ba6567274777250236799927ca30dc0670"2182
+dependencies = [2183
+ "as_variant",2184
+ "async-trait",2185
+ "deadpool 0.13.1",2186
+ "deadpool-sync",2187
+ "itertools 0.14.0",2188
+ "matrix-sdk-base",2189
+ "matrix-sdk-crypto",2190
+ "matrix-sdk-store-encryption",2191
+ "num_cpus",2192
+ "rmp-serde",2193
+ "ruma",2194
+ "rusqlite",2195
+ "serde",2196
+ "serde_json",2197
+ "serde_path_to_error",2198
+ "thiserror 2.0.20",2199
+ "tokio",2200
+ "tracing",2201
+ "vodozemac",2202
+ "zeroize",2203
+]2204
+2205
+[[package]]2206
+name = "matrix-sdk-store-encryption"2207
+version = "0.18.0"2208
+source = "registry+https://github.com/rust-lang/crates.io-index"2209
+checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e"2210
+dependencies = [2211
+ "base64",2212
+ "blake3",2213
+ "chacha20poly1305",2214
+ "getrandom 0.2.17",2215
+ "getrandom 0.4.3",2216
+ "hmac",2217
+ "pbkdf2",2218
+ "rand 0.10.2",2219
+ "rmp-serde",2220
+ "serde",2221
+ "serde_json",2222
+ "sha2",2223
+ "thiserror 2.0.20",2224
+ "zeroize",2225
+]2226
+2227
+[[package]]2228
+name = "matrix-sdk-test"2229
+version = "0.18.0"2230
+source = "registry+https://github.com/rust-lang/crates.io-index"2231
+checksum = "8023d4315fff35598c2d4a955920c1273c6aaab4dcd9ed405238a882581649bf"2232
+dependencies = [2233
+ "as_variant",2234
+ "ctor",2235
+ "getrandom 0.4.3",2236
+ "http",2237
+ "insta",2238
+ "matrix-sdk-common",2239
+ "matrix-sdk-test-macros",2240
+ "ruma",2241
+ "serde",2242
+ "serde_json",2243
+ "sha2",2244
+ "tokio",2245
+ "tracing-subscriber",2246
+ "vodozemac",2247
+ "wasm-bindgen-test",2248
+ "wiremock",2249
+]2250
+2251
+[[package]]2252
+name = "matrix-sdk-test-macros"2253
+version = "0.18.0"2254
+source = "registry+https://github.com/rust-lang/crates.io-index"2255
+checksum = "d97d6a0e5b1401c530b3c5d18cb98257d255d396bacf3903beda6718a2f4c4c5"2256
+dependencies = [2257
+ "quote",2258
+ "syn 2.0.119",2259
+]2260
+2261
+[[package]]2262
+name = "matrix-sdk-ui"2263
+version = "0.18.0"2264
+source = "registry+https://github.com/rust-lang/crates.io-index"2265
+checksum = "6d1f2b8a8cf57164d7cee4eb61c0b9f074b2e11d766a9391b7db0b9f2cbd83d8"2266
+dependencies = [2267
+ "as_variant",2268
+ "async-rx",2269
+ "async-stream",2270
+ "async_cell",2271
+ "bitflags",2272
+ "chrono",2273
+ "emojis",2274
+ "eyeball",2275
+ "eyeball-im",2276
+ "eyeball-im-util",2277
+ "futures-core",2278
+ "futures-util",2279
+ "fuzzy-matcher",2280
+ "growable-bloom-filter",2281
+ "imbl",2282
+ "indexmap",2283
+ "itertools 0.14.0",2284
+ "matrix-sdk",2285
+ "matrix-sdk-base",2286
+ "matrix-sdk-common",2287
+ "mime",2288
+ "pin-project-lite",2289
+ "ruma",2290
+ "serde",2291
+ "serde_json",2292
+ "thiserror 2.0.20",2293
+ "tokio",2294
+ "tokio-stream",2295
+ "tracing",2296
+ "unicode-normalization",2297
+ "unicode-segmentation",2298
+]2299
+2300
+[[package]]2301
+name = "matrix_indexed_db_futures"2302
+version = "0.7.0"2303
+source = "registry+https://github.com/rust-lang/crates.io-index"2304
+checksum = "245ff6a224b4df7b0c90dda2dd5a6eb46112708d49e8bdd8b007fccb09fea8e4"2305
+dependencies = [2306
+ "accessory",2307
+ "cfg-if",2308
+ "delegate-display",2309
+ "derive_more 2.1.1",2310
+ "fancy_constructor",2311
+ "futures-core",2312
+ "js-sys",2313
+ "matrix_indexed_db_futures_macros_internal",2314
+ "sealed 0.6.0",2315
+ "serde",2316
+ "serde-wasm-bindgen",2317
+ "smallvec",2318
+ "thiserror 2.0.20",2319
+ "tokio",2320
+ "wasm-bindgen",2321
+ "wasm-bindgen-futures",2322
+ "wasm_evt_listener",2323
+ "web-sys",2324
+ "web-time",2325
+]2326
+2327
+[[package]]2328
+name = "matrix_indexed_db_futures_macros_internal"2329
+version = "1.0.0"2330
+source = "registry+https://github.com/rust-lang/crates.io-index"2331
+checksum = "b428aee5c0fe9e5babd29e99d289b7f64718c444989aac0442d1fd6d3e3f66d1"2332
+dependencies = [2333
+ "macroific 2.0.0",2334
+ "proc-macro2",2335
+ "quote",2336
+ "syn 2.0.119",2337
+]2338
+2339
+[[package]]2340
+name = "memchr"2341
+version = "2.8.3"2342
+source = "registry+https://github.com/rust-lang/crates.io-index"2343
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"2344
+2345
+[[package]]2346
+name = "mime"2347
+version = "0.3.17"2348
+source = "registry+https://github.com/rust-lang/crates.io-index"2349
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"2350
+2351
+[[package]]2352
+name = "mime2ext"2353
+version = "0.1.54"2354
+source = "registry+https://github.com/rust-lang/crates.io-index"2355
+checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc"2356
+2357
+[[package]]2358
+name = "minicov"2359
+version = "0.3.8"2360
+source = "registry+https://github.com/rust-lang/crates.io-index"2361
+checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"2362
+dependencies = [2363
+ "cc",2364
+ "walkdir",2365
+]2366
+2367
+[[package]]2368
+name = "miniz_oxide"2369
+version = "0.9.1"2370
+source = "registry+https://github.com/rust-lang/crates.io-index"2371
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"2372
+dependencies = [2373
+ "adler2",2374
+ "simd-adler32",2375
+]2376
+2377
+[[package]]2378
+name = "mio"2379
+version = "1.2.3"2380
+source = "registry+https://github.com/rust-lang/crates.io-index"2381
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"2382
+dependencies = [2383
+ "libc",2384
+ "wasi",2385
+ "windows-sys 0.61.2",2386
+]2387
+2388
+[[package]]2389
+name = "new_debug_unreachable"2390
+version = "1.0.6"2391
+source = "registry+https://github.com/rust-lang/crates.io-index"2392
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"2393
+2394
+[[package]]2395
+name = "nu-ansi-term"2396
+version = "0.50.3"2397
+source = "registry+https://github.com/rust-lang/crates.io-index"2398
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"2399
+dependencies = [2400
+ "windows-sys 0.61.2",2401
+]2402
+2403
+[[package]]2404
+name = "num-conv"2405
+version = "0.2.2"2406
+source = "registry+https://github.com/rust-lang/crates.io-index"2407
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"2408
+2409
+[[package]]2410
+name = "num-traits"2411
+version = "0.2.19"2412
+source = "registry+https://github.com/rust-lang/crates.io-index"2413
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"2414
+dependencies = [2415
+ "autocfg",2416
+ "libm",2417
+]2418
+2419
+[[package]]2420
+name = "num_cpus"2421
+version = "1.17.0"2422
+source = "registry+https://github.com/rust-lang/crates.io-index"2423
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"2424
+dependencies = [2425
+ "hermit-abi",2426
+ "libc",2427
+]2428
+2429
+[[package]]2430
+name = "oauth2"2431
+version = "5.0.0"2432
+source = "registry+https://github.com/rust-lang/crates.io-index"2433
+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"2434
+dependencies = [2435
+ "base64",2436
+ "chrono",2437
+ "getrandom 0.2.17",2438
+ "http",2439
+ "rand 0.8.8",2440
+ "serde",2441
+ "serde_json",2442
+ "serde_path_to_error",2443
+ "sha2",2444
+ "thiserror 1.0.69",2445
+ "url",2446
+]2447
+2448
+[[package]]2449
+name = "oauth2-reqwest"2450
+version = "0.1.0-alpha.3"2451
+source = "registry+https://github.com/rust-lang/crates.io-index"2452
+checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2"2453
+dependencies = [2454
+ "oauth2",2455
+ "reqwest",2456
+]2457
+2458
+[[package]]2459
+name = "once_cell"2460
+version = "1.21.4"2461
+source = "registry+https://github.com/rust-lang/crates.io-index"2462
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"2463
+2464
+[[package]]2465
+name = "oorandom"2466
+version = "11.1.5"2467
+source = "registry+https://github.com/rust-lang/crates.io-index"2468
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"2469
+2470
+[[package]]2471
+name = "opaque-debug"2472
+version = "0.3.1"2473
+source = "registry+https://github.com/rust-lang/crates.io-index"2474
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"2475
+2476
+[[package]]2477
+name = "openssl-probe"2478
+version = "0.2.1"2479
+source = "registry+https://github.com/rust-lang/crates.io-index"2480
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"2481
+2482
+[[package]]2483
+name = "parking"2484
+version = "2.2.1"2485
+source = "registry+https://github.com/rust-lang/crates.io-index"2486
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"2487
+2488
+[[package]]2489
+name = "parking_lot"2490
+version = "0.12.5"2491
+source = "registry+https://github.com/rust-lang/crates.io-index"2492
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"2493
+dependencies = [2494
+ "lock_api",2495
+ "parking_lot_core",2496
+]2497
+2498
+[[package]]2499
+name = "parking_lot_core"2500
+version = "0.9.12"2501
+source = "registry+https://github.com/rust-lang/crates.io-index"2502
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"2503
+dependencies = [2504
+ "cfg-if",2505
+ "libc",2506
+ "redox_syscall",2507
+ "smallvec",2508
+ "windows-link",2509
+]2510
+2511
+[[package]]2512
+name = "pbkdf2"2513
+version = "0.12.2"2514
+source = "registry+https://github.com/rust-lang/crates.io-index"2515
+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"2516
+dependencies = [2517
+ "digest",2518
+]2519
+2520
+[[package]]2521
+name = "percent-encoding"2522
+version = "2.3.2"2523
+source = "registry+https://github.com/rust-lang/crates.io-index"2524
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"2525
+2526
+[[package]]2527
+name = "pest"2528
+version = "2.9.1"2529
+source = "registry+https://github.com/rust-lang/crates.io-index"2530
+checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"2531
+dependencies = [2532
+ "memchr",2533
+ "ucd-trie",2534
+]2535
+2536
+[[package]]2537
+name = "pest_derive"2538
+version = "2.9.1"2539
+source = "registry+https://github.com/rust-lang/crates.io-index"2540
+checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"2541
+dependencies = [2542
+ "pest",2543
+ "pest_generator",2544
+]2545
+2546
+[[package]]2547
+name = "pest_generator"2548
+version = "2.9.1"2549
+source = "registry+https://github.com/rust-lang/crates.io-index"2550
+checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"2551
+dependencies = [2552
+ "pest",2553
+ "pest_meta",2554
+ "proc-macro2",2555
+ "quote",2556
+ "syn 2.0.119",2557
+]2558
+2559
+[[package]]2560
+name = "pest_meta"2561
+version = "2.9.1"2562
+source = "registry+https://github.com/rust-lang/crates.io-index"2563
+checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"2564
+dependencies = [2565
+ "pest",2566
+]2567
+2568
+[[package]]2569
+name = "phf"2570
+version = "0.13.1"2571
+source = "registry+https://github.com/rust-lang/crates.io-index"2572
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"2573
+dependencies = [2574
+ "phf_shared",2575
+ "serde",2576
+]2577
+2578
+[[package]]2579
+name = "phf_codegen"2580
+version = "0.13.1"2581
+source = "registry+https://github.com/rust-lang/crates.io-index"2582
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"2583
+dependencies = [2584
+ "phf_generator",2585
+ "phf_shared",2586
+]2587
+2588
+[[package]]2589
+name = "phf_generator"2590
+version = "0.13.1"2591
+source = "registry+https://github.com/rust-lang/crates.io-index"2592
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"2593
+dependencies = [2594
+ "fastrand",2595
+ "phf_shared",2596
+]2597
+2598
+[[package]]2599
+name = "phf_shared"2600
+version = "0.13.1"2601
+source = "registry+https://github.com/rust-lang/crates.io-index"2602
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"2603
+dependencies = [2604
+ "siphasher",2605
+]2606
+2607
+[[package]]2608
+name = "pin-project-lite"2609
+version = "0.2.17"2610
+source = "registry+https://github.com/rust-lang/crates.io-index"2611
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"2612
+2613
+[[package]]2614
+name = "pkcs8"2615
+version = "0.10.2"2616
+source = "registry+https://github.com/rust-lang/crates.io-index"2617
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"2618
+dependencies = [2619
+ "der",2620
+ "spki",2621
+]2622
+2623
+[[package]]2624
+name = "pkg-config"2625
+version = "0.3.34"2626
+source = "registry+https://github.com/rust-lang/crates.io-index"2627
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"2628
+2629
+[[package]]2630
+name = "poly1305"2631
+version = "0.8.0"2632
+source = "registry+https://github.com/rust-lang/crates.io-index"2633
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"2634
+dependencies = [2635
+ "cpufeatures 0.2.17",2636
+ "opaque-debug",2637
+ "universal-hash",2638
+]2639
+2640
+[[package]]2641
+name = "potential_utf"2642
+version = "0.1.6"2643
+source = "registry+https://github.com/rust-lang/crates.io-index"2644
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"2645
+dependencies = [2646
+ "zerovec",2647
+]2648
+2649
+[[package]]2650
+name = "powerfmt"2651
+version = "0.2.0"2652
+source = "registry+https://github.com/rust-lang/crates.io-index"2653
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"2654
+2655
+[[package]]2656
+name = "ppv-lite86"2657
+version = "0.2.21"2658
+source = "registry+https://github.com/rust-lang/crates.io-index"2659
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"2660
+dependencies = [2661
+ "zerocopy",2662
+]2663
+2664
+[[package]]2665
+name = "precomputed-hash"2666
+version = "0.1.1"2667
+source = "registry+https://github.com/rust-lang/crates.io-index"2668
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"2669
+2670
+[[package]]2671
+name = "proc-macro-crate"2672
+version = "3.5.0"2673
+source = "registry+https://github.com/rust-lang/crates.io-index"2674
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"2675
+dependencies = [2676
+ "toml_edit",2677
+]2678
+2679
+[[package]]2680
+name = "proc-macro-error-attr2"2681
+version = "2.0.0"2682
+source = "registry+https://github.com/rust-lang/crates.io-index"2683
+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"2684
+dependencies = [2685
+ "proc-macro2",2686
+ "quote",2687
+]2688
+2689
+[[package]]2690
+name = "proc-macro-error2"2691
+version = "2.0.1"2692
+source = "registry+https://github.com/rust-lang/crates.io-index"2693
+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"2694
+dependencies = [2695
+ "proc-macro-error-attr2",2696
+ "proc-macro2",2697
+ "quote",2698
+]2699
+2700
+[[package]]2701
+name = "proc-macro2"2702
+version = "1.0.107"2703
+source = "registry+https://github.com/rust-lang/crates.io-index"2704
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"2705
+dependencies = [2706
+ "unicode-ident",2707
+]2708
+2709
+[[package]]2710
+name = "prost"2711
+version = "0.14.4"2712
+source = "registry+https://github.com/rust-lang/crates.io-index"2713
+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"2714
+dependencies = [2715
+ "bytes",2716
+ "prost-derive",2717
+]2718
+2719
+[[package]]2720
+name = "prost-derive"2721
+version = "0.14.4"2722
+source = "registry+https://github.com/rust-lang/crates.io-index"2723
+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"2724
+dependencies = [2725
+ "anyhow",2726
+ "itertools 0.14.0",2727
+ "proc-macro2",2728
+ "quote",2729
+ "syn 2.0.119",2730
+]2731
+2732
+[[package]]2733
+name = "quinn"2734
+version = "0.11.11"2735
+source = "registry+https://github.com/rust-lang/crates.io-index"2736
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"2737
+dependencies = [2738
+ "bytes",2739
+ "cfg_aliases",2740
+ "pin-project-lite",2741
+ "quinn-proto",2742
+ "quinn-udp",2743
+ "rustc-hash",2744
+ "rustls",2745
+ "socket2",2746
+ "thiserror 2.0.20",2747
+ "tokio",2748
+ "tracing",2749
+ "web-time",2750
+]2751
+2752
+[[package]]2753
+name = "quinn-proto"2754
+version = "0.11.17"2755
+source = "registry+https://github.com/rust-lang/crates.io-index"2756
+checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"2757
+dependencies = [2758
+ "aws-lc-rs",2759
+ "bytes",2760
+ "getrandom 0.4.3",2761
+ "lru-slab",2762
+ "rand 0.10.2",2763
+ "rand_pcg",2764
+ "ring",2765
+ "rustc-hash",2766
+ "rustls",2767
+ "rustls-pki-types",2768
+ "slab",2769
+ "thiserror 2.0.20",2770
+ "tinyvec",2771
+ "tracing",2772
+ "web-time",2773
+]2774
+2775
+[[package]]2776
+name = "quinn-udp"2777
+version = "0.5.15"2778
+source = "registry+https://github.com/rust-lang/crates.io-index"2779
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"2780
+dependencies = [2781
+ "cfg_aliases",2782
+ "libc",2783
+ "once_cell",2784
+ "socket2",2785
+ "tracing",2786
+ "windows-sys 0.61.2",2787
+]2788
+2789
+[[package]]2790
+name = "quote"2791
+version = "1.0.47"2792
+source = "registry+https://github.com/rust-lang/crates.io-index"2793
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"2794
+dependencies = [2795
+ "proc-macro2",2796
+]2797
+2798
+[[package]]2799
+name = "r-efi"2800
+version = "5.3.0"2801
+source = "registry+https://github.com/rust-lang/crates.io-index"2802
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"2803
+2804
+[[package]]2805
+name = "r-efi"2806
+version = "6.0.0"2807
+source = "registry+https://github.com/rust-lang/crates.io-index"2808
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"2809
+2810
+[[package]]2811
+name = "rand"2812
+version = "0.8.8"2813
+source = "registry+https://github.com/rust-lang/crates.io-index"2814
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"2815
+dependencies = [2816
+ "libc",2817
+ "rand_chacha 0.3.1",2818
+ "rand_core 0.6.4",2819
+]2820
+2821
+[[package]]2822
+name = "rand"2823
+version = "0.9.5"2824
+source = "registry+https://github.com/rust-lang/crates.io-index"2825
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"2826
+dependencies = [2827
+ "rand_chacha 0.9.0",2828
+ "rand_core 0.9.5",2829
+]2830
+2831
+[[package]]2832
+name = "rand"2833
+version = "0.10.2"2834
+source = "registry+https://github.com/rust-lang/crates.io-index"2835
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"2836
+dependencies = [2837
+ "chacha20 0.10.2",2838
+ "getrandom 0.4.3",2839
+ "rand_core 0.10.1",2840
+]2841
+2842
+[[package]]2843
+name = "rand_chacha"2844
+version = "0.3.1"2845
+source = "registry+https://github.com/rust-lang/crates.io-index"2846
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"2847
+dependencies = [2848
+ "ppv-lite86",2849
+ "rand_core 0.6.4",2850
+]2851
+2852
+[[package]]2853
+name = "rand_chacha"2854
+version = "0.9.0"2855
+source = "registry+https://github.com/rust-lang/crates.io-index"2856
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"2857
+dependencies = [2858
+ "ppv-lite86",2859
+ "rand_core 0.9.5",2860
+]2861
+2862
+[[package]]2863
+name = "rand_core"2864
+version = "0.6.4"2865
+source = "registry+https://github.com/rust-lang/crates.io-index"2866
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"2867
+dependencies = [2868
+ "getrandom 0.2.17",2869
+]2870
+2871
+[[package]]2872
+name = "rand_core"2873
+version = "0.9.5"2874
+source = "registry+https://github.com/rust-lang/crates.io-index"2875
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"2876
+dependencies = [2877
+ "getrandom 0.3.4",2878
+]2879
+2880
+[[package]]2881
+name = "rand_core"2882
+version = "0.10.1"2883
+source = "registry+https://github.com/rust-lang/crates.io-index"2884
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"2885
+2886
+[[package]]2887
+name = "rand_pcg"2888
+version = "0.10.2"2889
+source = "registry+https://github.com/rust-lang/crates.io-index"2890
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"2891
+dependencies = [2892
+ "rand_core 0.10.1",2893
+]2894
+2895
+[[package]]2896
+name = "rand_xoshiro"2897
+version = "0.7.0"2898
+source = "registry+https://github.com/rust-lang/crates.io-index"2899
+checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"2900
+dependencies = [2901
+ "rand_core 0.9.5",2902
+]2903
+2904
+[[package]]2905
+name = "readlock"2906
+version = "0.1.11"2907
+source = "registry+https://github.com/rust-lang/crates.io-index"2908
+checksum = "6da6f291b23556edd9edaf655a0be2ad8ef8002ff5f1bca62b264f3f58b53f34"2909
+2910
+[[package]]2911
+name = "readlock-tokio"2912
+version = "0.1.6"2913
+source = "registry+https://github.com/rust-lang/crates.io-index"2914
+checksum = "fc7e264f9ec4f3d112e8e2f214e8e7cb5cf3b83278f3570b7e00bfe13d3bd8ff"2915
+dependencies = [2916
+ "tokio",2917
+]2918
+2919
+[[package]]2920
+name = "redox_syscall"2921
+version = "0.5.18"2922
+source = "registry+https://github.com/rust-lang/crates.io-index"2923
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"2924
+dependencies = [2925
+ "bitflags",2926
+]2927
+2928
+[[package]]2929
+name = "regex"2930
+version = "1.13.1"2931
+source = "registry+https://github.com/rust-lang/crates.io-index"2932
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"2933
+dependencies = [2934
+ "aho-corasick",2935
+ "memchr",2936
+ "regex-automata",2937
+ "regex-syntax",2938
+]2939
+2940
+[[package]]2941
+name = "regex-automata"2942
+version = "0.4.18"2943
+source = "registry+https://github.com/rust-lang/crates.io-index"2944
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"2945
+dependencies = [2946
+ "aho-corasick",2947
+ "memchr",2948
+ "regex-syntax",2949
+]2950
+2951
+[[package]]2952
+name = "regex-syntax"2953
+version = "0.8.11"2954
+source = "registry+https://github.com/rust-lang/crates.io-index"2955
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"2956
+2957
+[[package]]2958
+name = "reqwest"2959
+version = "0.13.4"2960
+source = "registry+https://github.com/rust-lang/crates.io-index"2961
+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"2962
+dependencies = [2963
+ "base64",2964
+ "bytes",2965
+ "futures-core",2966
+ "futures-util",2967
+ "h2",2968
+ "http",2969
+ "http-body",2970
+ "http-body-util",2971
+ "hyper",2972
+ "hyper-rustls",2973
+ "hyper-util",2974
+ "js-sys",2975
+ "log",2976
+ "percent-encoding",2977
+ "pin-project-lite",2978
+ "quinn",2979
+ "rustls",2980
+ "rustls-pki-types",2981
+ "rustls-platform-verifier",2982
+ "sync_wrapper",2983
+ "tokio",2984
+ "tokio-rustls",2985
+ "tokio-util",2986
+ "tower",2987
+ "tower-http",2988
+ "tower-service",2989
+ "url",2990
+ "wasm-bindgen",2991
+ "wasm-bindgen-futures",2992
+ "wasm-streams",2993
+ "web-sys",2994
+]2995
+2996
+[[package]]2997
+name = "ring"2998
+version = "0.17.14"2999
+source = "registry+https://github.com/rust-lang/crates.io-index"3000
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"3001
+dependencies = [3002
+ "cc",3003
+ "cfg-if",3004
+ "getrandom 0.2.17",3005
+ "libc",3006
+ "untrusted",3007
+ "windows-sys 0.52.0",3008
+]3009
+3010
+[[package]]3011
+name = "rmp"3012
+version = "0.8.15"3013
+source = "registry+https://github.com/rust-lang/crates.io-index"3014
+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"3015
+dependencies = [3016
+ "num-traits",3017
+]3018
+3019
+[[package]]3020
+name = "rmp-serde"3021
+version = "1.3.1"3022
+source = "registry+https://github.com/rust-lang/crates.io-index"3023
+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"3024
+dependencies = [3025
+ "rmp",3026
+ "serde",3027
+]3028
+3029
+[[package]]3030
+name = "ruma"3031
+version = "0.16.0"3032
+source = "registry+https://github.com/rust-lang/crates.io-index"3033
+checksum = "ee4fe5bfacdb0e95e733da3b6c37d98edf46447a4a8e8dea824e0da266d8ad59"3034
+dependencies = [3035
+ "assign",3036
+ "js_int",3037
+ "js_option",3038
+ "ruma-client-api",3039
+ "ruma-common",3040
+ "ruma-events",3041
+ "ruma-html",3042
+ "web-time",3043
+]3044
+3045
+[[package]]3046
+name = "ruma-client-api"3047
+version = "0.24.0"3048
+source = "registry+https://github.com/rust-lang/crates.io-index"3049
+checksum = "bf7ca43a888ca569168d7e3901f4dd14a777b860bb19f4c08e35414162eb261c"3050
+dependencies = [3051
+ "as_variant",3052
+ "assign",3053
+ "bytes",3054
+ "http",3055
+ "js_int",3056
+ "js_option",3057
+ "maplit",3058
+ "ruma-common",3059
+ "ruma-events",3060
+ "serde",3061
+ "serde_html_form",3062
+ "serde_json",3063
+ "thiserror 2.0.20",3064
+ "url",3065
+ "web-time",3066
+]3067
+3068
+[[package]]3069
+name = "ruma-common"3070
+version = "0.19.0"3071
+source = "registry+https://github.com/rust-lang/crates.io-index"3072
+checksum = "2c3b4f00112791b490acce57df1ce3eb3f88899b045bebcff8a29f75369640cc"3073
+dependencies = [3074
+ "as_variant",3075
+ "base64",3076
+ "bytes",3077
+ "date_header",3078
+ "form_urlencoded",3079
+ "getrandom 0.4.3",3080
+ "http",3081
+ "indexmap",3082
+ "js_int",3083
+ "konst",3084
+ "percent-encoding",3085
+ "rand 0.10.2",3086
+ "regex",3087
+ "ruma-identifiers-validation",3088
+ "ruma-macros",3089
+ "serde",3090
+ "serde_html_form",3091
+ "serde_json",3092
+ "thiserror 2.0.20",3093
+ "time",3094
+ "tracing",3095
+ "url",3096
+ "uuid",3097
+ "web-time",3098
+ "wildmatch",3099
+ "zeroize",3100
+]3101
+3102
+[[package]]3103
+name = "ruma-events"3104
+version = "0.34.0"3105
+source = "registry+https://github.com/rust-lang/crates.io-index"3106
+checksum = "85d2f90830fc131691349b96a69ff53444eb6c3e8dc7869c77961b43cfaf3344"3107
+dependencies = [3108
+ "as_variant",3109
+ "indexmap",3110
+ "js_int",3111
+ "js_option",3112
+ "ruma-common",3113
+ "ruma-html",3114
+ "ruma-macros",3115
+ "serde",3116
+ "serde_json",3117
+ "thiserror 2.0.20",3118
+ "tracing",3119
+ "web-time",3120
+ "wildmatch",3121
+ "zeroize",3122
+]3123
+3124
+[[package]]3125
+name = "ruma-html"3126
+version = "0.8.0"3127
+source = "registry+https://github.com/rust-lang/crates.io-index"3128
+checksum = "48d33a944650f4bbd2188dd204d39dd87a9a1498f14b3a13252910c90ed7cd43"3129
+dependencies = [3130
+ "as_variant",3131
+ "html5ever",3132
+ "tracing",3133
+ "wildmatch",3134
+]3135
+3136
+[[package]]3137
+name = "ruma-identifiers-validation"3138
+version = "0.12.1"3139
+source = "registry+https://github.com/rust-lang/crates.io-index"3140
+checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432"3141
+dependencies = [3142
+ "js_int",3143
+ "thiserror 2.0.20",3144
+]3145
+3146
+[[package]]3147
+name = "ruma-macros"3148
+version = "0.19.0"3149
+source = "registry+https://github.com/rust-lang/crates.io-index"3150
+checksum = "c8cfb39eaa9b9fd389126ff941e060b496add5cbbfef559d80c46e571dda459c"3151
+dependencies = [3152
+ "as_variant",3153
+ "cfg-if",3154
+ "proc-macro-crate",3155
+ "proc-macro2",3156
+ "quote",3157
+ "ruma-identifiers-validation",3158
+ "serde",3159
+ "syn 2.0.119",3160
+ "toml",3161
+]3162
+3163
+[[package]]3164
+name = "rusqlite"3165
+version = "0.37.0"3166
+source = "registry+https://github.com/rust-lang/crates.io-index"3167
+checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"3168
+dependencies = [3169
+ "bitflags",3170
+ "fallible-iterator",3171
+ "fallible-streaming-iterator",3172
+ "hashlink",3173
+ "libsqlite3-sys",3174
+ "smallvec",3175
+]3176
+3177
+[[package]]3178
+name = "rustc-hash"3179
+version = "2.1.3"3180
+source = "registry+https://github.com/rust-lang/crates.io-index"3181
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"3182
+3183
+[[package]]3184
+name = "rustc_version"3185
+version = "0.4.1"3186
+source = "registry+https://github.com/rust-lang/crates.io-index"3187
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"3188
+dependencies = [3189
+ "semver",3190
+]3191
+3192
+[[package]]3193
+name = "rustix"3194
+version = "1.1.4"3195
+source = "registry+https://github.com/rust-lang/crates.io-index"3196
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"3197
+dependencies = [3198
+ "bitflags",3199
+ "errno",3200
+ "libc",3201
+ "linux-raw-sys",3202
+ "windows-sys 0.61.2",3203
+]3204
+3205
+[[package]]3206
+name = "rustls"3207
+version = "0.23.44"3208
+source = "registry+https://github.com/rust-lang/crates.io-index"3209
+checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"3210
+dependencies = [3211
+ "aws-lc-rs",3212
+ "log",3213
+ "once_cell",3214
+ "rustls-pki-types",3215
+ "rustls-webpki",3216
+ "subtle",3217
+ "zeroize",3218
+]3219
+3220
+[[package]]3221
+name = "rustls-native-certs"3222
+version = "0.8.4"3223
+source = "registry+https://github.com/rust-lang/crates.io-index"3224
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"3225
+dependencies = [3226
+ "openssl-probe",3227
+ "rustls-pki-types",3228
+ "schannel",3229
+ "security-framework",3230
+]3231
+3232
+[[package]]3233
+name = "rustls-pki-types"3234
+version = "1.15.1"3235
+source = "registry+https://github.com/rust-lang/crates.io-index"3236
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"3237
+dependencies = [3238
+ "web-time",3239
+ "zeroize",3240
+]3241
+3242
+[[package]]3243
+name = "rustls-platform-verifier"3244
+version = "0.7.0"3245
+source = "registry+https://github.com/rust-lang/crates.io-index"3246
+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"3247
+dependencies = [3248
+ "core-foundation",3249
+ "core-foundation-sys",3250
+ "jni",3251
+ "log",3252
+ "once_cell",3253
+ "rustls",3254
+ "rustls-native-certs",3255
+ "rustls-platform-verifier-android",3256
+ "rustls-webpki",3257
+ "security-framework",3258
+ "security-framework-sys",3259
+ "webpki-root-certs",3260
+ "windows-sys 0.61.2",3261
+]3262
+3263
+[[package]]3264
+name = "rustls-platform-verifier-android"3265
+version = "0.1.1"3266
+source = "registry+https://github.com/rust-lang/crates.io-index"3267
+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"3268
+3269
+[[package]]3270
+name = "rustls-webpki"3271
+version = "0.103.15"3272
+source = "registry+https://github.com/rust-lang/crates.io-index"3273
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"3274
+dependencies = [3275
+ "aws-lc-rs",3276
+ "ring",3277
+ "rustls-pki-types",3278
+ "untrusted",3279
+]3280
+3281
+[[package]]3282
+name = "rustversion"3283
+version = "1.0.23"3284
+source = "registry+https://github.com/rust-lang/crates.io-index"3285
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"3286
+3287
+[[package]]3288
+name = "same-file"3289
+version = "1.0.6"3290
+source = "registry+https://github.com/rust-lang/crates.io-index"3291
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"3292
+dependencies = [3293
+ "winapi-util",3294
+]3295
+3296
+[[package]]3297
+name = "schannel"3298
+version = "0.1.29"3299
+source = "registry+https://github.com/rust-lang/crates.io-index"3300
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"3301
+dependencies = [3302
+ "windows-sys 0.61.2",3303
+]3304
+3305
+[[package]]3306
+name = "scoped-tls"3307
+version = "1.0.1"3308
+source = "registry+https://github.com/rust-lang/crates.io-index"3309
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"3310
+3311
+[[package]]3312
+name = "scopeguard"3313
+version = "1.2.0"3314
+source = "registry+https://github.com/rust-lang/crates.io-index"3315
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"3316
+3317
+[[package]]3318
+name = "sealed"3319
+version = "0.6.0"3320
+source = "registry+https://github.com/rust-lang/crates.io-index"3321
+checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"3322
+dependencies = [3323
+ "proc-macro2",3324
+ "quote",3325
+ "syn 2.0.119",3326
+]3327
+3328
+[[package]]3329
+name = "sealed"3330
+version = "0.7.0"3331
+source = "registry+https://github.com/rust-lang/crates.io-index"3332
+checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"3333
+dependencies = [3334
+ "proc-macro2",3335
+ "quote",3336
+ "syn 3.0.5",3337
+]3338
+3339
+[[package]]3340
+name = "security-framework"3341
+version = "3.7.0"3342
+source = "registry+https://github.com/rust-lang/crates.io-index"3343
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"3344
+dependencies = [3345
+ "bitflags",3346
+ "core-foundation",3347
+ "core-foundation-sys",3348
+ "libc",3349
+ "security-framework-sys",3350
+]3351
+3352
+[[package]]3353
+name = "security-framework-sys"3354
+version = "2.17.0"3355
+source = "registry+https://github.com/rust-lang/crates.io-index"3356
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"3357
+dependencies = [3358
+ "core-foundation-sys",3359
+ "libc",3360
+]3361
+3362
+[[package]]3363
+name = "semver"3364
+version = "1.0.28"3365
+source = "registry+https://github.com/rust-lang/crates.io-index"3366
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"3367
+3368
+[[package]]3369
+name = "serde"3370
+version = "1.0.229"3371
+source = "registry+https://github.com/rust-lang/crates.io-index"3372
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"3373
+dependencies = [3374
+ "serde_core",3375
+ "serde_derive",3376
+]3377
+3378
+[[package]]3379
+name = "serde-wasm-bindgen"3380
+version = "0.6.5"3381
+source = "registry+https://github.com/rust-lang/crates.io-index"3382
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"3383
+dependencies = [3384
+ "js-sys",3385
+ "serde",3386
+ "wasm-bindgen",3387
+]3388
+3389
+[[package]]3390
+name = "serde_bytes"3391
+version = "0.11.19"3392
+source = "registry+https://github.com/rust-lang/crates.io-index"3393
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"3394
+dependencies = [3395
+ "serde",3396
+ "serde_core",3397
+]3398
+3399
+[[package]]3400
+name = "serde_core"3401
+version = "1.0.229"3402
+source = "registry+https://github.com/rust-lang/crates.io-index"3403
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"3404
+dependencies = [3405
+ "serde_derive",3406
+]3407
+3408
+[[package]]3409
+name = "serde_derive"3410
+version = "1.0.229"3411
+source = "registry+https://github.com/rust-lang/crates.io-index"3412
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"3413
+dependencies = [3414
+ "proc-macro2",3415
+ "quote",3416
+ "syn 3.0.5",3417
+]3418
+3419
+[[package]]3420
+name = "serde_html_form"3421
+version = "0.4.1"3422
+source = "registry+https://github.com/rust-lang/crates.io-index"3423
+checksum = "8f0346d7a342ab90f405cfc08f25d15075f944f42fcabbc5eac923829fa6d228"3424
+dependencies = [3425
+ "form_urlencoded",3426
+ "indexmap",3427
+ "itoa",3428
+ "serde_core",3429
+ "zmij",3430
+]3431
+3432
+[[package]]3433
+name = "serde_json"3434
+version = "1.0.151"3435
+source = "registry+https://github.com/rust-lang/crates.io-index"3436
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"3437
+dependencies = [3438
+ "itoa",3439
+ "memchr",3440
+ "serde",3441
+ "serde_core",3442
+ "zmij",3443
+]3444
+3445
+[[package]]3446
+name = "serde_path_to_error"3447
+version = "0.1.20"3448
+source = "registry+https://github.com/rust-lang/crates.io-index"3449
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"3450
+dependencies = [3451
+ "itoa",3452
+ "serde",3453
+ "serde_core",3454
+]3455
+3456
+[[package]]3457
+name = "serde_spanned"3458
+version = "1.1.1"3459
+source = "registry+https://github.com/rust-lang/crates.io-index"3460
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"3461
+dependencies = [3462
+ "serde_core",3463
+]3464
+3465
+[[package]]3466
+name = "sha2"3467
+version = "0.10.9"3468
+source = "registry+https://github.com/rust-lang/crates.io-index"3469
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"3470
+dependencies = [3471
+ "cfg-if",3472
+ "cpufeatures 0.2.17",3473
+ "digest",3474
+]3475
+3476
+[[package]]3477
+name = "sharded-slab"3478
+version = "0.1.7"3479
+source = "registry+https://github.com/rust-lang/crates.io-index"3480
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"3481
+dependencies = [3482
+ "lazy_static",3483
+]3484
+3485
+[[package]]3486
+name = "shlex"3487
+version = "2.0.1"3488
+source = "registry+https://github.com/rust-lang/crates.io-index"3489
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"3490
+3491
+[[package]]3492
+name = "signal-hook-registry"3493
+version = "1.4.8"3494
+source = "registry+https://github.com/rust-lang/crates.io-index"3495
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"3496
+dependencies = [3497
+ "errno",3498
+ "libc",3499
+]3500
+3501
+[[package]]3502
+name = "signature"3503
+version = "2.2.0"3504
+source = "registry+https://github.com/rust-lang/crates.io-index"3505
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"3506
+dependencies = [3507
+ "rand_core 0.6.4",3508
+]3509
+3510
+[[package]]3511
+name = "simd-adler32"3512
+version = "0.3.10"3513
+source = "registry+https://github.com/rust-lang/crates.io-index"3514
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"3515
+3516
+[[package]]3517
+name = "simd_cesu8"3518
+version = "1.2.0"3519
+source = "registry+https://github.com/rust-lang/crates.io-index"3520
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"3521
+dependencies = [3522
+ "rustc_version",3523
+ "simdutf8",3524
+]3525
+3526
+[[package]]3527
+name = "simdutf8"3528
+version = "0.1.5"3529
+source = "registry+https://github.com/rust-lang/crates.io-index"3530
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"3531
+3532
+[[package]]3533
+name = "similar"3534
+version = "2.7.0"3535
+source = "registry+https://github.com/rust-lang/crates.io-index"3536
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"3537
+3538
+[[package]]3539
+name = "siphasher"3540
+version = "1.0.3"3541
+source = "registry+https://github.com/rust-lang/crates.io-index"3542
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"3543
+3544
+[[package]]3545
+name = "slab"3546
+version = "0.4.12"3547
+source = "registry+https://github.com/rust-lang/crates.io-index"3548
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"3549
+3550
+[[package]]3551
+name = "smallvec"3552
+version = "1.16.0"3553
+source = "registry+https://github.com/rust-lang/crates.io-index"3554
+checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"3555
+dependencies = [3556
+ "serde",3557
+]3558
+3559
+[[package]]3560
+name = "socket2"3561
+version = "0.6.5"3562
+source = "registry+https://github.com/rust-lang/crates.io-index"3563
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"3564
+dependencies = [3565
+ "libc",3566
+ "windows-sys 0.61.2",3567
+]3568
+3569
+[[package]]3570
+name = "spki"3571
+version = "0.7.3"3572
+source = "registry+https://github.com/rust-lang/crates.io-index"3573
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"3574
+dependencies = [3575
+ "base64ct",3576
+ "der",3577
+]3578
+3579
+[[package]]3580
+name = "stable_deref_trait"3581
+version = "1.2.1"3582
+source = "registry+https://github.com/rust-lang/crates.io-index"3583
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"3584
+3585
+[[package]]3586
+name = "string_cache"3587
+version = "0.9.0"3588
+source = "registry+https://github.com/rust-lang/crates.io-index"3589
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"3590
+dependencies = [3591
+ "new_debug_unreachable",3592
+ "parking_lot",3593
+ "phf_shared",3594
+ "precomputed-hash",3595
+]3596
+3597
+[[package]]3598
+name = "string_cache_codegen"3599
+version = "0.6.1"3600
+source = "registry+https://github.com/rust-lang/crates.io-index"3601
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"3602
+dependencies = [3603
+ "phf_generator",3604
+ "phf_shared",3605
+ "proc-macro2",3606
+ "quote",3607
+]3608
+3609
+[[package]]3610
+name = "subtle"3611
+version = "2.6.1"3612
+source = "registry+https://github.com/rust-lang/crates.io-index"3613
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"3614
+3615
+[[package]]3616
+name = "syn"3617
+version = "1.0.109"3618
+source = "registry+https://github.com/rust-lang/crates.io-index"3619
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"3620
+dependencies = [3621
+ "proc-macro2",3622
+ "quote",3623
+ "unicode-ident",3624
+]3625
+3626
+[[package]]3627
+name = "syn"3628
+version = "2.0.119"3629
+source = "registry+https://github.com/rust-lang/crates.io-index"3630
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"3631
+dependencies = [3632
+ "proc-macro2",3633
+ "quote",3634
+ "unicode-ident",3635
+]3636
+3637
+[[package]]3638
+name = "syn"3639
+version = "3.0.5"3640
+source = "registry+https://github.com/rust-lang/crates.io-index"3641
+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"3642
+dependencies = [3643
+ "proc-macro2",3644
+ "quote",3645
+ "unicode-ident",3646
+]3647
+3648
+[[package]]3649
+name = "sync_wrapper"3650
+version = "1.0.2"3651
+source = "registry+https://github.com/rust-lang/crates.io-index"3652
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"3653
+dependencies = [3654
+ "futures-core",3655
+]3656
+3657
+[[package]]3658
+name = "synstructure"3659
+version = "0.13.2"3660
+source = "registry+https://github.com/rust-lang/crates.io-index"3661
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"3662
+dependencies = [3663
+ "proc-macro2",3664
+ "quote",3665
+ "syn 2.0.119",3666
+]3667
+3668
+[[package]]3669
+name = "tempfile"3670
+version = "3.27.0"3671
+source = "registry+https://github.com/rust-lang/crates.io-index"3672
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"3673
+dependencies = [3674
+ "fastrand",3675
+ "getrandom 0.4.3",3676
+ "once_cell",3677
+ "rustix",3678
+ "windows-sys 0.61.2",3679
+]3680
+3681
+[[package]]3682
+name = "tendril"3683
+version = "0.5.1"3684
+source = "registry+https://github.com/rust-lang/crates.io-index"3685
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"3686
+dependencies = [3687
+ "new_debug_unreachable",3688
+]3689
+3690
+[[package]]3691
+name = "thiserror"3692
+version = "1.0.69"3693
+source = "registry+https://github.com/rust-lang/crates.io-index"3694
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"3695
+dependencies = [3696
+ "thiserror-impl 1.0.69",3697
+]3698
+3699
+[[package]]3700
+name = "thiserror"3701
+version = "2.0.20"3702
+source = "registry+https://github.com/rust-lang/crates.io-index"3703
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"3704
+dependencies = [3705
+ "thiserror-impl 2.0.20",3706
+]3707
+3708
+[[package]]3709
+name = "thiserror-impl"3710
+version = "1.0.69"3711
+source = "registry+https://github.com/rust-lang/crates.io-index"3712
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"3713
+dependencies = [3714
+ "proc-macro2",3715
+ "quote",3716
+ "syn 2.0.119",3717
+]3718
+3719
+[[package]]3720
+name = "thiserror-impl"3721
+version = "2.0.20"3722
+source = "registry+https://github.com/rust-lang/crates.io-index"3723
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"3724
+dependencies = [3725
+ "proc-macro2",3726
+ "quote",3727
+ "syn 3.0.5",3728
+]3729
+3730
+[[package]]3731
+name = "thread_local"3732
+version = "1.1.10"3733
+source = "registry+https://github.com/rust-lang/crates.io-index"3734
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"3735
+dependencies = [3736
+ "cfg-if",3737
+]3738
+3739
+[[package]]3740
+name = "time"3741
+version = "0.3.55"3742
+source = "registry+https://github.com/rust-lang/crates.io-index"3743
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"3744
+dependencies = [3745
+ "deranged",3746
+ "num-conv",3747
+ "powerfmt",3748
+ "serde_core",3749
+ "time-core",3750
+ "time-macros",3751
+]3752
+3753
+[[package]]3754
+name = "time-core"3755
+version = "0.1.9"3756
+source = "registry+https://github.com/rust-lang/crates.io-index"3757
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"3758
+3759
+[[package]]3760
+name = "time-macros"3761
+version = "0.2.32"3762
+source = "registry+https://github.com/rust-lang/crates.io-index"3763
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"3764
+dependencies = [3765
+ "num-conv",3766
+ "time-core",3767
+]3768
+3769
+[[package]]3770
+name = "tinystr"3771
+version = "0.8.4"3772
+source = "registry+https://github.com/rust-lang/crates.io-index"3773
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"3774
+dependencies = [3775
+ "displaydoc",3776
+ "zerovec",3777
+]3778
+3779
+[[package]]3780
+name = "tinyvec"3781
+version = "1.13.2"3782
+source = "registry+https://github.com/rust-lang/crates.io-index"3783
+checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"3784
+dependencies = [3785
+ "tinyvec_macros",3786
+]3787
+3788
+[[package]]3789
+name = "tinyvec_macros"3790
+version = "0.1.1"3791
+source = "registry+https://github.com/rust-lang/crates.io-index"3792
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"3793
+3794
+[[package]]3795
+name = "tokio"3796
+version = "1.53.1"3797
+source = "registry+https://github.com/rust-lang/crates.io-index"3798
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"3799
+dependencies = [3800
+ "bytes",3801
+ "libc",3802
+ "mio",3803
+ "pin-project-lite",3804
+ "signal-hook-registry",3805
+ "socket2",3806
+ "tokio-macros",3807
+ "windows-sys 0.61.2",3808
+]3809
+3810
+[[package]]3811
+name = "tokio-macros"3812
+version = "2.7.2"3813
+source = "registry+https://github.com/rust-lang/crates.io-index"3814
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"3815
+dependencies = [3816
+ "proc-macro2",3817
+ "quote",3818
+ "syn 3.0.5",3819
+]3820
+3821
+[[package]]3822
+name = "tokio-rustls"3823
+version = "0.26.5"3824
+source = "registry+https://github.com/rust-lang/crates.io-index"3825
+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"3826
+dependencies = [3827
+ "rustls",3828
+ "tokio",3829
+]3830
+3831
+[[package]]3832
+name = "tokio-stream"3833
+version = "0.1.19"3834
+source = "registry+https://github.com/rust-lang/crates.io-index"3835
+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"3836
+dependencies = [3837
+ "futures-core",3838
+ "pin-project-lite",3839
+ "tokio",3840
+ "tokio-util",3841
+]3842
+3843
+[[package]]3844
+name = "tokio-util"3845
+version = "0.7.19"3846
+source = "registry+https://github.com/rust-lang/crates.io-index"3847
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"3848
+dependencies = [3849
+ "bytes",3850
+ "futures-core",3851
+ "futures-sink",3852
+ "libc",3853
+ "pin-project-lite",3854
+ "tokio",3855
+]3856
+3857
+[[package]]3858
+name = "toml"3859
+version = "1.1.5+spec-1.1.0"3860
+source = "registry+https://github.com/rust-lang/crates.io-index"3861
+checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"3862
+dependencies = [3863
+ "serde_core",3864
+ "serde_spanned",3865
+ "toml_datetime",3866
+ "toml_parser",3867
+ "winnow",3868
+]3869
+3870
+[[package]]3871
+name = "toml_datetime"3872
+version = "1.1.1+spec-1.1.0"3873
+source = "registry+https://github.com/rust-lang/crates.io-index"3874
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"3875
+dependencies = [3876
+ "serde_core",3877
+]3878
+3879
+[[package]]3880
+name = "toml_edit"3881
+version = "0.25.13+spec-1.1.0"3882
+source = "registry+https://github.com/rust-lang/crates.io-index"3883
+checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"3884
+dependencies = [3885
+ "indexmap",3886
+ "toml_datetime",3887
+ "toml_parser",3888
+ "winnow",3889
+]3890
+3891
+[[package]]3892
+name = "toml_parser"3893
+version = "1.1.3+spec-1.1.0"3894
+source = "registry+https://github.com/rust-lang/crates.io-index"3895
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"3896
+dependencies = [3897
+ "winnow",3898
+]3899
+3900
+[[package]]3901
+name = "tower"3902
+version = "0.5.3"3903
+source = "registry+https://github.com/rust-lang/crates.io-index"3904
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"3905
+dependencies = [3906
+ "futures-core",3907
+ "futures-util",3908
+ "pin-project-lite",3909
+ "sync_wrapper",3910
+ "tokio",3911
+ "tower-layer",3912
+ "tower-service",3913
+]3914
+3915
+[[package]]3916
+name = "tower-http"3917
+version = "0.6.11"3918
+source = "registry+https://github.com/rust-lang/crates.io-index"3919
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"3920
+dependencies = [3921
+ "async-compression",3922
+ "bitflags",3923
+ "bytes",3924
+ "futures-core",3925
+ "futures-util",3926
+ "http",3927
+ "http-body",3928
+ "http-body-util",3929
+ "pin-project-lite",3930
+ "tokio",3931
+ "tokio-util",3932
+ "tower",3933
+ "tower-layer",3934
+ "tower-service",3935
+ "url",3936
+]3937
+3938
+[[package]]3939
+name = "tower-layer"3940
+version = "0.3.3"3941
+source = "registry+https://github.com/rust-lang/crates.io-index"3942
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"3943
+3944
+[[package]]3945
+name = "tower-service"3946
+version = "0.3.3"3947
+source = "registry+https://github.com/rust-lang/crates.io-index"3948
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"3949
+3950
+[[package]]3951
+name = "tracing"3952
+version = "0.1.44"3953
+source = "registry+https://github.com/rust-lang/crates.io-index"3954
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"3955
+dependencies = [3956
+ "pin-project-lite",3957
+ "tracing-attributes",3958
+ "tracing-core",3959
+]3960
+3961
+[[package]]3962
+name = "tracing-attributes"3963
+version = "0.1.31"3964
+source = "registry+https://github.com/rust-lang/crates.io-index"3965
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"3966
+dependencies = [3967
+ "proc-macro2",3968
+ "quote",3969
+ "syn 2.0.119",3970
+]3971
+3972
+[[package]]3973
+name = "tracing-core"3974
+version = "0.1.36"3975
+source = "registry+https://github.com/rust-lang/crates.io-index"3976
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"3977
+dependencies = [3978
+ "once_cell",3979
+ "valuable",3980
+]3981
+3982
+[[package]]3983
+name = "tracing-log"3984
+version = "0.2.0"3985
+source = "registry+https://github.com/rust-lang/crates.io-index"3986
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"3987
+dependencies = [3988
+ "log",3989
+ "once_cell",3990
+ "tracing-core",3991
+]3992
+3993
+[[package]]3994
+name = "tracing-subscriber"3995
+version = "0.3.23"3996
+source = "registry+https://github.com/rust-lang/crates.io-index"3997
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"3998
+dependencies = [3999
+ "matchers",4000
+ "nu-ansi-term",4001
+ "once_cell",4002
+ "regex-automata",4003
+ "sharded-slab",4004
+ "smallvec",4005
+ "thread_local",4006
+ "tracing",4007
+ "tracing-core",4008
+ "tracing-log",4009
+]4010
+4011
+[[package]]4012
+name = "try-lock"4013
+version = "0.2.5"4014
+source = "registry+https://github.com/rust-lang/crates.io-index"4015
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"4016
+4017
+[[package]]4018
+name = "typenum"4019
+version = "1.20.1"4020
+source = "registry+https://github.com/rust-lang/crates.io-index"4021
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"4022
+4023
+[[package]]4024
+name = "typewit"4025
+version = "1.15.2"4026
+source = "registry+https://github.com/rust-lang/crates.io-index"4027
+checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6"4028
+4029
+[[package]]4030
+name = "ucd-trie"4031
+version = "0.1.7"4032
+source = "registry+https://github.com/rust-lang/crates.io-index"4033
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"4034
+4035
+[[package]]4036
+name = "ulid"4037
+version = "1.2.1"4038
+source = "registry+https://github.com/rust-lang/crates.io-index"4039
+checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"4040
+dependencies = [4041
+ "rand 0.9.5",4042
+ "web-time",4043
+]4044
+4045
+[[package]]4046
+name = "unicode-ident"4047
+version = "1.0.24"4048
+source = "registry+https://github.com/rust-lang/crates.io-index"4049
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"4050
+4051
+[[package]]4052
+name = "unicode-normalization"4053
+version = "0.1.25"4054
+source = "registry+https://github.com/rust-lang/crates.io-index"4055
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"4056
+dependencies = [4057
+ "tinyvec",4058
+]4059
+4060
+[[package]]4061
+name = "unicode-segmentation"4062
+version = "1.13.3"4063
+source = "registry+https://github.com/rust-lang/crates.io-index"4064
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"4065
+4066
+[[package]]4067
+name = "unicode-xid"4068
+version = "0.2.6"4069
+source = "registry+https://github.com/rust-lang/crates.io-index"4070
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"4071
+4072
+[[package]]4073
+name = "universal-hash"4074
+version = "0.5.1"4075
+source = "registry+https://github.com/rust-lang/crates.io-index"4076
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"4077
+dependencies = [4078
+ "crypto-common",4079
+ "subtle",4080
+]4081
+4082
+[[package]]4083
+name = "untrusted"4084
+version = "0.9.0"4085
+source = "registry+https://github.com/rust-lang/crates.io-index"4086
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"4087
+4088
+[[package]]4089
+name = "url"4090
+version = "2.5.8"4091
+source = "registry+https://github.com/rust-lang/crates.io-index"4092
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"4093
+dependencies = [4094
+ "form_urlencoded",4095
+ "idna",4096
+ "percent-encoding",4097
+ "serde",4098
+ "serde_derive",4099
+]4100
+4101
+[[package]]4102
+name = "urlencoding"4103
+version = "2.1.3"4104
+source = "registry+https://github.com/rust-lang/crates.io-index"4105
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"4106
+4107
+[[package]]4108
+name = "utf8_iter"4109
+version = "1.0.4"4110
+source = "registry+https://github.com/rust-lang/crates.io-index"4111
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"4112
+4113
+[[package]]4114
+name = "uuid"4115
+version = "1.26.0"4116
+source = "registry+https://github.com/rust-lang/crates.io-index"4117
+checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"4118
+dependencies = [4119
+ "getrandom 0.4.3",4120
+ "js-sys",4121
+ "serde_core",4122
+ "wasm-bindgen",4123
+]4124
+4125
+[[package]]4126
+name = "valuable"4127
+version = "0.1.1"4128
+source = "registry+https://github.com/rust-lang/crates.io-index"4129
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"4130
+4131
+[[package]]4132
+name = "vcpkg"4133
+version = "0.2.15"4134
+source = "registry+https://github.com/rust-lang/crates.io-index"4135
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"4136
+4137
+[[package]]4138
+name = "version_check"4139
+version = "0.9.5"4140
+source = "registry+https://github.com/rust-lang/crates.io-index"4141
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"4142
+4143
+[[package]]4144
+name = "vodozemac"4145
+version = "0.10.0"4146
+source = "registry+https://github.com/rust-lang/crates.io-index"4147
+checksum = "b98bf83c0992966775b8012f194b07b44928996163e5a05b741b43891571ae5b"4148
+dependencies = [4149
+ "aes",4150
+ "arrayvec",4151
+ "base64",4152
+ "base64ct",4153
+ "cbc",4154
+ "chacha20poly1305",4155
+ "curve25519-dalek",4156
+ "ed25519-dalek",4157
+ "getrandom 0.2.17",4158
+ "hkdf",4159
+ "hmac",4160
+ "matrix-pickle",4161
+ "prost",4162
+ "rand 0.8.8",4163
+ "serde",4164
+ "serde_bytes",4165
+ "serde_json",4166
+ "sha2",4167
+ "subtle",4168
+ "thiserror 2.0.20",4169
+ "x25519-dalek",4170
+ "zeroize",4171
+]4172
+4173
+[[package]]4174
+name = "walkdir"4175
+version = "2.5.0"4176
+source = "registry+https://github.com/rust-lang/crates.io-index"4177
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"4178
+dependencies = [4179
+ "same-file",4180
+ "winapi-util",4181
+]4182
+4183
+[[package]]4184
+name = "want"4185
+version = "0.3.1"4186
+source = "registry+https://github.com/rust-lang/crates.io-index"4187
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"4188
+dependencies = [4189
+ "try-lock",4190
+]4191
+4192
+[[package]]4193
+name = "wasi"4194
+version = "0.11.1+wasi-snapshot-preview1"4195
+source = "registry+https://github.com/rust-lang/crates.io-index"4196
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"4197
+4198
+[[package]]4199
+name = "wasip2"4200
+version = "1.0.4+wasi-0.2.12"4201
+source = "registry+https://github.com/rust-lang/crates.io-index"4202
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"4203
+dependencies = [4204
+ "wit-bindgen",4205
+]4206
+4207
+[[package]]4208
+name = "wasm-bindgen"4209
+version = "0.2.128"4210
+source = "registry+https://github.com/rust-lang/crates.io-index"4211
+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"4212
+dependencies = [4213
+ "cfg-if",4214
+ "once_cell",4215
+ "rustversion",4216
+ "wasm-bindgen-macro",4217
+ "wasm-bindgen-shared",4218
+]4219
+4220
+[[package]]4221
+name = "wasm-bindgen-futures"4222
+version = "0.4.78"4223
+source = "registry+https://github.com/rust-lang/crates.io-index"4224
+checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"4225
+dependencies = [4226
+ "js-sys",4227
+ "wasm-bindgen",4228
+]4229
+4230
+[[package]]4231
+name = "wasm-bindgen-macro"4232
+version = "0.2.128"4233
+source = "registry+https://github.com/rust-lang/crates.io-index"4234
+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"4235
+dependencies = [4236
+ "quote",4237
+ "wasm-bindgen-macro-support",4238
+]4239
+4240
+[[package]]4241
+name = "wasm-bindgen-macro-support"4242
+version = "0.2.128"4243
+source = "registry+https://github.com/rust-lang/crates.io-index"4244
+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"4245
+dependencies = [4246
+ "bumpalo",4247
+ "proc-macro2",4248
+ "quote",4249
+ "syn 3.0.5",4250
+ "wasm-bindgen-shared",4251
+]4252
+4253
+[[package]]4254
+name = "wasm-bindgen-shared"4255
+version = "0.2.128"4256
+source = "registry+https://github.com/rust-lang/crates.io-index"4257
+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"4258
+dependencies = [4259
+ "unicode-ident",4260
+]4261
+4262
+[[package]]4263
+name = "wasm-bindgen-test"4264
+version = "0.3.78"4265
+source = "registry+https://github.com/rust-lang/crates.io-index"4266
+checksum = "45863ef0bef521c12124eb39d9a38513c47db75f22e503c06beacd40afeb35db"4267
+dependencies = [4268
+ "async-trait",4269
+ "cast",4270
+ "js-sys",4271
+ "libm",4272
+ "minicov",4273
+ "nu-ansi-term",4274
+ "num-traits",4275
+ "oorandom",4276
+ "serde",4277
+ "serde_json",4278
+ "wasm-bindgen",4279
+ "wasm-bindgen-futures",4280
+ "wasm-bindgen-test-macro",4281
+ "wasm-bindgen-test-shared",4282
+]4283
+4284
+[[package]]4285
+name = "wasm-bindgen-test-macro"4286
+version = "0.3.78"4287
+source = "registry+https://github.com/rust-lang/crates.io-index"4288
+checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12"4289
+dependencies = [4290
+ "proc-macro2",4291
+ "quote",4292
+ "syn 3.0.5",4293
+]4294
+4295
+[[package]]4296
+name = "wasm-bindgen-test-shared"4297
+version = "0.2.128"4298
+source = "registry+https://github.com/rust-lang/crates.io-index"4299
+checksum = "f37b4f992cebe528ef34964ae69681ac0fe7080071e7298e46008f9d380302af"4300
+4301
+[[package]]4302
+name = "wasm-streams"4303
+version = "0.5.0"4304
+source = "registry+https://github.com/rust-lang/crates.io-index"4305
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"4306
+dependencies = [4307
+ "futures-util",4308
+ "js-sys",4309
+ "wasm-bindgen",4310
+ "wasm-bindgen-futures",4311
+ "web-sys",4312
+]4313
+4314
+[[package]]4315
+name = "wasm_evt_listener"4316
+version = "0.1.0"4317
+source = "registry+https://github.com/rust-lang/crates.io-index"4318
+checksum = "dc92d6378b411ed94839112a36d9dbc77143451d85b05dfb0cce93a78dab1963"4319
+dependencies = [4320
+ "accessory",4321
+ "derivative",4322
+ "derive_more 1.0.0",4323
+ "fancy_constructor",4324
+ "futures-core",4325
+ "js-sys",4326
+ "smallvec",4327
+ "tokio",4328
+ "wasm-bindgen",4329
+ "web-sys",4330
+]4331
+4332
+[[package]]4333
+name = "web-sys"4334
+version = "0.3.105"4335
+source = "registry+https://github.com/rust-lang/crates.io-index"4336
+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"4337
+dependencies = [4338
+ "js-sys",4339
+ "wasm-bindgen",4340
+]4341
+4342
+[[package]]4343
+name = "web-time"4344
+version = "1.1.0"4345
+source = "registry+https://github.com/rust-lang/crates.io-index"4346
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"4347
+dependencies = [4348
+ "js-sys",4349
+ "serde",4350
+ "wasm-bindgen",4351
+]4352
+4353
+[[package]]4354
+name = "web_atoms"4355
+version = "0.2.6"4356
+source = "registry+https://github.com/rust-lang/crates.io-index"4357
+checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"4358
+dependencies = [4359
+ "phf",4360
+ "phf_codegen",4361
+ "string_cache",4362
+ "string_cache_codegen",4363
+]4364
+4365
+[[package]]4366
+name = "webpki-root-certs"4367
+version = "1.0.9"4368
+source = "registry+https://github.com/rust-lang/crates.io-index"4369
+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"4370
+dependencies = [4371
+ "rustls-pki-types",4372
+]4373
+4374
+[[package]]4375
+name = "webpki-roots"4376
+version = "1.0.9"4377
+source = "registry+https://github.com/rust-lang/crates.io-index"4378
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"4379
+dependencies = [4380
+ "rustls-pki-types",4381
+]4382
+4383
+[[package]]4384
+name = "wildmatch"4385
+version = "2.6.1"4386
+source = "registry+https://github.com/rust-lang/crates.io-index"4387
+checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68"4388
+4389
+[[package]]4390
+name = "winapi-util"4391
+version = "0.1.11"4392
+source = "registry+https://github.com/rust-lang/crates.io-index"4393
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"4394
+dependencies = [4395
+ "windows-sys 0.61.2",4396
+]4397
+4398
+[[package]]4399
+name = "windows-core"4400
+version = "0.62.2"4401
+source = "registry+https://github.com/rust-lang/crates.io-index"4402
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"4403
+dependencies = [4404
+ "windows-implement",4405
+ "windows-interface",4406
+ "windows-link",4407
+ "windows-result",4408
+ "windows-strings",4409
+]4410
+4411
+[[package]]4412
+name = "windows-implement"4413
+version = "0.60.2"4414
+source = "registry+https://github.com/rust-lang/crates.io-index"4415
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"4416
+dependencies = [4417
+ "proc-macro2",4418
+ "quote",4419
+ "syn 2.0.119",4420
+]4421
+4422
+[[package]]4423
+name = "windows-interface"4424
+version = "0.59.3"4425
+source = "registry+https://github.com/rust-lang/crates.io-index"4426
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"4427
+dependencies = [4428
+ "proc-macro2",4429
+ "quote",4430
+ "syn 2.0.119",4431
+]4432
+4433
+[[package]]4434
+name = "windows-link"4435
+version = "0.2.1"4436
+source = "registry+https://github.com/rust-lang/crates.io-index"4437
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"4438
+4439
+[[package]]4440
+name = "windows-result"4441
+version = "0.4.1"4442
+source = "registry+https://github.com/rust-lang/crates.io-index"4443
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"4444
+dependencies = [4445
+ "windows-link",4446
+]4447
+4448
+[[package]]4449
+name = "windows-strings"4450
+version = "0.5.1"4451
+source = "registry+https://github.com/rust-lang/crates.io-index"4452
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"4453
+dependencies = [4454
+ "windows-link",4455
+]4456
+4457
+[[package]]4458
+name = "windows-sys"4459
+version = "0.52.0"4460
+source = "registry+https://github.com/rust-lang/crates.io-index"4461
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"4462
+dependencies = [4463
+ "windows-targets",4464
+]4465
+4466
+[[package]]4467
+name = "windows-sys"4468
+version = "0.61.2"4469
+source = "registry+https://github.com/rust-lang/crates.io-index"4470
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"4471
+dependencies = [4472
+ "windows-link",4473
+]4474
+4475
+[[package]]4476
+name = "windows-targets"4477
+version = "0.52.6"4478
+source = "registry+https://github.com/rust-lang/crates.io-index"4479
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"4480
+dependencies = [4481
+ "windows_aarch64_gnullvm",4482
+ "windows_aarch64_msvc",4483
+ "windows_i686_gnu",4484
+ "windows_i686_gnullvm",4485
+ "windows_i686_msvc",4486
+ "windows_x86_64_gnu",4487
+ "windows_x86_64_gnullvm",4488
+ "windows_x86_64_msvc",4489
+]4490
+4491
+[[package]]4492
+name = "windows_aarch64_gnullvm"4493
+version = "0.52.6"4494
+source = "registry+https://github.com/rust-lang/crates.io-index"4495
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"4496
+4497
+[[package]]4498
+name = "windows_aarch64_msvc"4499
+version = "0.52.6"4500
+source = "registry+https://github.com/rust-lang/crates.io-index"4501
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"4502
+4503
+[[package]]4504
+name = "windows_i686_gnu"4505
+version = "0.52.6"4506
+source = "registry+https://github.com/rust-lang/crates.io-index"4507
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"4508
+4509
+[[package]]4510
+name = "windows_i686_gnullvm"4511
+version = "0.52.6"4512
+source = "registry+https://github.com/rust-lang/crates.io-index"4513
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"4514
+4515
+[[package]]4516
+name = "windows_i686_msvc"4517
+version = "0.52.6"4518
+source = "registry+https://github.com/rust-lang/crates.io-index"4519
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"4520
+4521
+[[package]]4522
+name = "windows_x86_64_gnu"4523
+version = "0.52.6"4524
+source = "registry+https://github.com/rust-lang/crates.io-index"4525
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"4526
+4527
+[[package]]4528
+name = "windows_x86_64_gnullvm"4529
+version = "0.52.6"4530
+source = "registry+https://github.com/rust-lang/crates.io-index"4531
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"4532
+4533
+[[package]]4534
+name = "windows_x86_64_msvc"4535
+version = "0.52.6"4536
+source = "registry+https://github.com/rust-lang/crates.io-index"4537
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"4538
+4539
+[[package]]4540
+name = "winnow"4541
+version = "1.0.4"4542
+source = "registry+https://github.com/rust-lang/crates.io-index"4543
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"4544
+dependencies = [4545
+ "memchr",4546
+]4547
+4548
+[[package]]4549
+name = "wiremock"4550
+version = "0.6.5"4551
+source = "registry+https://github.com/rust-lang/crates.io-index"4552
+checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"4553
+dependencies = [4554
+ "assert-json-diff",4555
+ "base64",4556
+ "deadpool 0.12.3",4557
+ "futures",4558
+ "http",4559
+ "http-body-util",4560
+ "hyper",4561
+ "hyper-util",4562
+ "log",4563
+ "once_cell",4564
+ "regex",4565
+ "serde",4566
+ "serde_json",4567
+ "tokio",4568
+ "url",4569
+]4570
+4571
+[[package]]4572
+name = "wit-bindgen"4573
+version = "0.57.1"4574
+source = "registry+https://github.com/rust-lang/crates.io-index"4575
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"4576
+4577
+[[package]]4578
+name = "writeable"4579
+version = "0.6.4"4580
+source = "registry+https://github.com/rust-lang/crates.io-index"4581
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"4582
+4583
+[[package]]4584
+name = "x25519-dalek"4585
+version = "2.0.1"4586
+source = "registry+https://github.com/rust-lang/crates.io-index"4587
+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"4588
+dependencies = [4589
+ "curve25519-dalek",4590
+ "rand_core 0.6.4",4591
+ "serde",4592
+ "zeroize",4593
+]4594
+4595
+[[package]]4596
+name = "xxhash-rust"4597
+version = "0.8.18"4598
+source = "registry+https://github.com/rust-lang/crates.io-index"4599
+checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"4600
+4601
+[[package]]4602
+name = "yoke"4603
+version = "0.8.3"4604
+source = "registry+https://github.com/rust-lang/crates.io-index"4605
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"4606
+dependencies = [4607
+ "stable_deref_trait",4608
+ "yoke-derive",4609
+ "zerofrom",4610
+]4611
+4612
+[[package]]4613
+name = "yoke-derive"4614
+version = "0.8.2"4615
+source = "registry+https://github.com/rust-lang/crates.io-index"4616
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"4617
+dependencies = [4618
+ "proc-macro2",4619
+ "quote",4620
+ "syn 2.0.119",4621
+ "synstructure",4622
+]4623
+4624
+[[package]]4625
+name = "zerocopy"4626
+version = "0.8.56"4627
+source = "registry+https://github.com/rust-lang/crates.io-index"4628
+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"4629
+dependencies = [4630
+ "zerocopy-derive",4631
+]4632
+4633
+[[package]]4634
+name = "zerocopy-derive"4635
+version = "0.8.56"4636
+source = "registry+https://github.com/rust-lang/crates.io-index"4637
+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"4638
+dependencies = [4639
+ "proc-macro2",4640
+ "quote",4641
+ "syn 2.0.119",4642
+]4643
+4644
+[[package]]4645
+name = "zerofrom"4646
+version = "0.1.8"4647
+source = "registry+https://github.com/rust-lang/crates.io-index"4648
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"4649
+dependencies = [4650
+ "zerofrom-derive",4651
+]4652
+4653
+[[package]]4654
+name = "zerofrom-derive"4655
+version = "0.1.7"4656
+source = "registry+https://github.com/rust-lang/crates.io-index"4657
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"4658
+dependencies = [4659
+ "proc-macro2",4660
+ "quote",4661
+ "syn 2.0.119",4662
+ "synstructure",4663
+]4664
+4665
+[[package]]4666
+name = "zeroize"4667
+version = "1.9.0"4668
+source = "registry+https://github.com/rust-lang/crates.io-index"4669
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"4670
+dependencies = [4671
+ "zeroize_derive",4672
+]4673
+4674
+[[package]]4675
+name = "zeroize_derive"4676
+version = "1.5.0"4677
+source = "registry+https://github.com/rust-lang/crates.io-index"4678
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"4679
+dependencies = [4680
+ "proc-macro2",4681
+ "quote",4682
+ "syn 2.0.119",4683
+]4684
+4685
+[[package]]4686
+name = "zerotrie"4687
+version = "0.2.5"4688
+source = "registry+https://github.com/rust-lang/crates.io-index"4689
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"4690
+dependencies = [4691
+ "displaydoc",4692
+ "yoke",4693
+ "zerofrom",4694
+]4695
+4696
+[[package]]4697
+name = "zerovec"4698
+version = "0.11.8"4699
+source = "registry+https://github.com/rust-lang/crates.io-index"4700
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"4701
+dependencies = [4702
+ "yoke",4703
+ "zerofrom",4704
+ "zerovec-derive",4705
+]4706
+4707
+[[package]]4708
+name = "zerovec-derive"4709
+version = "0.11.6"4710
+source = "registry+https://github.com/rust-lang/crates.io-index"4711
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"4712
+dependencies = [4713
+ "proc-macro2",4714
+ "quote",4715
+ "syn 3.0.5",4716
+]4717
+4718
+[[package]]4719
+name = "zlib-rs"4720
+version = "0.6.7"4721
+source = "registry+https://github.com/rust-lang/crates.io-index"4722
+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"4723
+4724
+[[package]]4725
+name = "zmij"4726
+version = "1.0.23"4727
+source = "registry+https://github.com/rust-lang/crates.io-index"4728
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"@@ -0,0 +1,42 @@
1
+[package]2
+name = "macltok"3
+version = "0.1.0"4
+edition = "2024"5
+description = "A compact application backend on top of the Matrix Rust SDK"6
+7
+[features]8
+default = []9
+serde = ["dep:serde"]10
+sqlite = ["matrix-sdk/sqlite", "serde", "dep:url"]11
+bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"]12
+13
+[dependencies]14
+futures-util = { version = "0.3.31", default-features = false }15
+http = "1"16
+matrix-sdk = { version = "0.18.0", default-features = false, features = ["e2e-encryption"] }17
+matrix-sdk-crypto = { version = "0.18.0", default-features = false }18
+matrix-sdk-ui = { version = "0.18.0", default-features = false }19
+ruma = { version = "0.16.0", default-features = false }20
+serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }21
+thiserror = "2.0.16"22
+mime = "0.3.17"23
+tokio = { version = "1.47.1", features = ["rt", "sync"] }24
+tracing = "0.1.41"25
+url = { version = "2.5.7", optional = true }26
+27
+[dev-dependencies]28
+matrix-sdk = { version = "0.18.0", default-features = false, features = ["testing"] }29
+matrix-sdk-test = "0.18.0"30
+serde_json = "1.0.143"31
+tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal", "time"] }32
+wiremock = "0.6.5"33
+34
+[[example]]35
+name = "headless"36
+required-features = ["sqlite"]37
+38
+[lints.rust]39
+unsafe_code = "forbid"40
+41
+[lints.clippy]42
+all = "warn"@@ -0,0 +1,58 @@
1
+# macltok2
+3
+`macltok` is a compact Rust application backend built above `matrix-sdk` and4
+`matrix-sdk-ui`. It aims to provide the stateful behavior needed by a useful5
+Matrix client without depending on a GUI toolkit or inventing another protocol6
+SDK.7
+8
+The crate currently provides9
+10
+- typed commands, owned UI-safe events and bounded per-client transport11
+- Sliding Sync with normalized connection and room-list state12
+- independently retained live room timelines and backwards pagination13
+- text, emote, reply, edit, redaction, reaction, receipt and typing operations14
+- raw encrypted or unencrypted media acquisition with configurable byte limits15
+- encryption state, recovery-key import and SAS verification16
+- password login and session restoration with caller-owned encrypted SQLite stores17
+18
+Matrix room, event, user, device and transaction identifiers remain strongly19
+typed. A toolkit timeline identity covers local echoes without pretending they20
+already have remote event IDs.21
+22
+## Consuming the engine23
+24
+Run `Engine::run` and drain its single `EventStream` independently of command25
+submission. `ClientHandle::send` returns an enqueue ID; match that ID against26
+`CommandSucceeded`, `CommandSucceededWithRoom`, or `CommandFailed` before27
+reporting completion. A successful message command only enters the SDK send28
+queue; the timeline item's send state reports subsequent network delivery.29
+30
+Start a timeline from `TimelineReset`, then apply `TimelineChanged` diffs with31
+`TimelineDiff::try_apply`. If a diff does not match the current snapshot, stop32
+applying diffs for that timeline and send `TimelineCommand::Resync`. Resume only33
+after its fresh `TimelineReset`; the reset precedes the command completion.34
+35
+With `sqlite`, persist the initial `Session::record()` separately from its36
+encrypted SQLite store. On `SessionTokensChanged`, obtain a new record from37
+`ClientHandle::session_record()` and replace the saved record atomically in38
+protected storage. Restore with the same store path and passphrase. Delete the39
+saved credentials after `LoggedOut` or `SessionAccessRejected`, but keep them40
+after `Stopped` so a restart can restore the session. The headless example is41
+login-only; it does not persist sessions across runs.42
+43
+## Dependency policy44
+45
+UI frameworks, image decoders, application-directory discovery and logging46
+subscribers do not belong in this crate. SQLite remains optional through the47
+`sqlite` and `bundled-sqlite` features. Media decoding belongs to the consumer.48
+49
+## Verification50
+51
+```sh52
+nix develop --command cargo fmt -- --check53
+nix develop --command cargo test --all-targets54
+nix develop --command cargo test --all-targets --features sqlite55
+nix develop --command cargo clippy --all-targets -- -D warnings56
+nix develop --command cargo clippy --all-targets --features sqlite -- -D warnings57
+nix flake check58
+```@@ -0,0 +1,171 @@
1
+# macltok design decisions2
+3
+This document describes the contract implemented in the working tree.4
+5
+## Engine execution6
+7
+The engine keeps a contextual task registry. Every spawned task is registered8
+with either its command id and kind, a watcher name, or a room id and9
+generation. The run loop continuously joins completed tasks and removes their10
+context. A command-task panic produces its correlated `CommandFailed` event.11
+A watcher or current room-actor panic is terminal to the engine. Shutdown aborts12
+and joins the remaining registered tasks.13
+14
+The engine emits `Authenticated`, then `ConnectionChanged(Connecting)`, before15
+starting the sync service. `ConnectionChanged` also represents the later SDK16
+states and the final `Stopped` transition. `Engine::run` returns an17
+`EngineError` for terminal failure and emits `Failed` before `Stopped` when the18
+event stream remains available.19
+20
+The driver command channel remains lossless and clients must drain21
+`EventStream` independently of command submission. `ClientHandle` also exposes22
+a `watch` receiver for the current connection state. `RoomsChanged` is an23
+invalidation event. Call `ClientHandle::rooms` for current, normalised room24
+summaries.25
+26
+`ClientHandle` sends `Command` values and receives a `CommandId`. Completion is27
+reported as `CommandSucceeded`, `CommandSucceededWithRoom`, or28
+`CommandFailed`. `CommandFailure` contains the id, outer `CommandKind`, typed29
+`CommandErrorKind`, and display message. Direct request and response methods30
+return `CommandResultError`, which is either a delivered `CommandFailure` or a31
+closed reply channel.32
+33
+For timeline sends, success means the SDK send queue accepted the event;34
+network delivery is reported later through timeline send state.35
+36
+`send` reports enqueueing, not completion. Consumers retain the returned id37
+until a terminal result with that id arrives; they match `CommandFailure::kind`38
+rather than parsing its display message. The event stream must be drained while39
+commands are submitted, even when several frontend clients share one engine.40
+41
+## Room lifetime and ordering42
+43
+An open room has an explicit generation and exactly one of three states.44
+45
+- `Opening` owns the generation and actor request sender46
+- `Open` owns the generation and actor request sender47
+- `Closing` owns the generation and a bounded FIFO of deferred open and close commands48
+49
+An `Open` command starts an actor immediately and returns its terminal result50
+only after the actor reports readiness. There is no separate readiness waiter or51
+readiness task. Repeated opens while opening are retained by the actor and each52
+gets one terminal result. A close changes the room to `Closing`, rejects work53
+that needs an open actor, and waits for that generation to exit. Opens and54
+closes during closing are queued in order; after exit, each deferred command is55
+replayed, with a fresh generation started when needed. Events from earlier56
+generations are ignored.57
+58
+Each room actor serialises its timeline work in FIFO order. Different rooms run59
+concurrently. Room mutations which do not require an open timeline use a60
+separate keyed FIFO scheduler. Jobs with the same room, target, or direct-chat61
+key run in submission order while unrelated keys run concurrently. Notification62
+commands and encryption commands each have one dedicated serial worker. The63
+encryption worker owns the verification controller and cancels it before64
+shutdown completes.65
+66
+Pagination has a per-timeline mutex gate and a session-owned completion path.67
+Pagination tasks are reaped as they finish. Closing a timeline aborts its68
+pagination work and emits one terminal result for every still-pending command.69
+There is no public pagination handle. The internal result channel is only the70
+session completion mechanism and is not an engine-wide routing design.71
+72
+`TimelineDiff::try_apply` rejects indices that do not match the consumer's73
+snapshot without modifying that snapshot. A consumer then stops applying diffs74
+for that timeline and sends `TimelineCommand::Resync`. The timeline session75
+aborts and joins its old watcher, atomically subscribes to a fresh SDK snapshot76
+and diff stream, and emits `TimelineReset` before the resync command succeeds.77
+This works for rooms and threads without closing their timelines or pagination.78
+79
+Timeline diffs and resets carry `TimelineId`, which distinguishes a room from a80
+thread by its root event. Thread actors and pagination state belong to the room81
+actor. Stateful timeline commands require the room actor. Standalone room82
+commands such as typing, receipts, send-queue control, membership actions, and83
+moderation use the SDK `Room` directly.84
+85
+## Account construction and secrets86
+87
+`Session::login_password`, `Session::begin_oauth`, and `Session::restore` use88
+one shared account client builder. It configures encrypted SQLite storage,89
+encryption settings, refresh-token handling, and enabled threading without90
+thread subscriptions. `Session::build` passes the authenticated client to the91
+engine.92
+93
+Secret-bearing input types do not derive `Clone`. The store passphrase is94
+borrowed by the shared builder, password login passes its password by reference,95
+and authentication records are cloned only where SDK ownership requires it.96
+97
+The caller owns durable authentication records. Save `Session::record()` after98
+login, then consume `SessionTokensChanged` and atomically replace that saved99
+record with `ClientHandle::session_record()`. A previously captured record may100
+hold an exhausted refresh token; failing to persist a rotation must be surfaced101
+rather than silently treating the stale record as safe to restore. Restore with102
+the same `StoreConfig` path and passphrase. Explicit logout and103
+`SessionAccessRejected` invalidate the saved record and store; `Stopped` alone104
+does not. Neither cookies nor application-specific secret storage belong in the105
+toolkit.106
+107
+## Notifications and room data108
+109
+The notification client is constructed lazily in an `OnceCell` and shares the110
+engine sync service. It is created only when notification resolution is first111
+requested. `PushNotification` exposes typed room and sender metadata and a112
+typed `PushNotificationEvent`. The event is `Raw<AnySyncTimelineEvent>` for a113
+timeline notification or `Raw<StrippedRoomMemberEvent>` for an invite. SDK UI114
+types do not cross the public boundary.115
+116
+Room summaries are normalised once per `rooms` query. `ClientHandle::rooms_filtered`117
+normalises its name search term once before filtering. Summary generation runs118
+display-name requests with bounded concurrency. Sorting is descending latest119
+event timestamp, then case-insensitive name, then room id as a deterministic tie120
+break.121
+122
+Global and room account data retain typed Matrix event-type parameters and raw123
+event content. The handle offers direct raw reads and engine-backed raw writes.124
+125
+## Commands and errors126
+127
+`Command` is grouped into `Account`, `Room`, `Timeline`, `Media`,128
+`Encryption`, `Notification`, and `Lifecycle` domains. `CommandKind` identifies129
+the outer domain in result events.130
+131
+`RoomCommand::InviteUser` contains only room and user ids. It deliberately does132
+not expose a reason because the SDK invite helper accepts none. Kick, ban, and133
+unban retain optional reasons where the protocol supports them.134
+135
+`TimelineCommand::Edit` accepts `MessageEditContent`. Its `Text` and `Emote`136
+variants carry `RichText`, so edited text and emotes preserve plain text,137
+optional Matrix HTML, and mentions. Empty edited bodies fail with `EmptyBody`.138
+139
+`VerificationAction` contains `Confirm`, `Reject`, and `Cancel`. Starting a140
+verification is a target-bearing encryption command rather than an action.141
+Verification progress uses `Waiting`, `Comparison`, `Confirmed`, `Done`,142
+`Cancelled`, and typed `Failed(VerificationFailure)`. Request acceptance, SAS143
+startup and acceptance failures retain their message, while stream termination144
+has dedicated variants. A cancelled verification can be started again.145
+The watcher and action paths claim terminal emission so a racing cancellation146
+cannot replace `Done` with a local `Cancelled` event.147
+148
+`CommandErrorKind` is the stable typed failure category. It includes room and149
+timeline availability, queue pressure, validation, media, verification,150
+authentication, network, server, store, timeline, encryption, and fallback151
+categories. The rendered message preserves operation context while internal152
+errors retain their source chains.153
+154
+## Power levels155
+156
+Power-level reads return the complete local `RoomPowerLevels` mapping.157
+`PowerLevelChanges` applies selected common and well-known event thresholds,158
+and `SetUserPowerLevel` changes one user mapping.159
+160
+The SDK trait that applies a power-level event is crate-private. macltok keeps161
+an atomic local mapping, applies all requested changes to it, and sends one162
+state event. It does not patch upstream and does not send separate events for163
+individual fields.164
+165
+## Public model boundaries166
+167
+The public model uses owned ruma identifiers and typed data wherever it has a168
+stable representation. Raw Matrix event payloads remain `Raw` where arbitrary169
+event content must be preserved. Public output models are non-exhaustive where170
+they are expected to grow. SDK clients remain available through the handle for171
+direct state queries that do not need engine coordination.@@ -0,0 +1,393 @@
1
+# macltok review, 2026-09-242
+3
+Second full review, taken after the restructure and Tier 0 landed. It covers4
+correctness, races, robustness, duplication, antipatterns and how easy the crate5
+is to build a client on, then folds in the API gaps reported by Malice, the6
+first real client built on macltok.7
+8
+Every finding carries an id so fixes can reference it. Status starts as OPEN.9
+SDK claims cite matrix-sdk 0.18 source under `~/.cargo/registry` and were10
+checked against the code rather than recalled.11
+12
+Baseline when written. `cargo fmt --check` clean, all default-feature tests13
+pass. The sqlite and clippy runs are recorded at the end.14
+15
+## C. Correctness16
+17
+### C1. `Verify(Reject)` never rejects. OPEN18
+19
+`VerificationController::reject` (`src/encryption.rs:339-389`) validates the20
+SAS state twice and returns `Ok(())` without ever calling `sas.mismatch()`. The21
+peer is never told the emoji did not match, the local flow stays in22
+comparison, and the command reports success. A user who presses "they don't23
+match" gets no protection from the one action meant to provide it. No test24
+exercises `Reject`.25
+26
+### C2. Sync dies permanently after `SyncState::Error`. OPEN27
+28
+The connection watcher maps `SyncState::Error` to `ConnectionState::Offline`29
+(`src/engine.rs:1338-1341`) and nothing ever calls `SyncService::start` again.30
+In the SDK the supervisor breaks out of its loop on error, and even offline31
+mode ends in `Error` when its reachability check fails32
+(`matrix-sdk-ui sync_service.rs:313-339`). `start()` is the only way back33
+(`sync_service.rs:629-649`). The client sees "Offline" forever while the engine34
+still reports itself healthy. `SyncState::Terminated` has the same problem, it35
+becomes `ConnectionState::Stopped` while the engine keeps running, so36
+`Stopped` now means two different things.37
+38
+Fix direction. Restart with backoff on `Error` and `Terminated`, and report39
+`Stopped` only from engine shutdown.40
+41
+### C3. A room's send queue stays disabled after any send failure. OPEN42
+43
+The SDK disables a room's queue after every send error, recoverable or not44
+(`matrix-sdk send_queue/mod.rs:1011-1019`), and nothing in matrix-sdk-ui turns45
+it back on. macltok re-enables only inside `RetrySend`46
+(`src/engine.rs:2178`), and does so globally. One network blip therefore47
+pauses every later message in that room until the user explicitly retries a48
+specific transaction. Element X re-enables queues when sync returns to49
+running.50
+51
+Fix direction. Re-enable on each transition to `Online`, and keep52
+`RetrySend` for unwedging a single item.53
+54
+### C4. Own encrypted uploads cannot be fetched, and the media cache is split between two formats. OPEN55
+56
+macltok writes ciphertext into the media store under the `Encrypted` source key57
+and decrypts on every read (`src/engine.rs:2799-2821`, `2864-2874`). The SDK58
+stores plaintext under that same key, both in `Media::get_media_content`59
+(`matrix-sdk media.rs:451-480`, `522-528`) and in the send queue. After an60
+upload the send queue renames its plaintext cache entry to the remote61
+`Encrypted` source (`send_queue/upload.rs:928-953`). So fetching your own sent62
+image in an encrypted room reads plaintext, runs it through63
+`AttachmentDecryptor`, fails the hash check and returns `MediaUnavailable`. The64
+reverse also holds. Any SDK path that reads a macltok-written entry gets65
+ciphertext as if it were the image. The media tests force plaintext rooms66
+(`tests/media.rs:766`), which is why this passed.67
+68
+Fix direction. Cache plaintext, matching the SDK, and keep the byte bound by69
+decrypting in a streaming fashion before the write.70
+71
+### C5. Media single-flight is poisoned by a leader panic. OPEN72
+73
+In `spawn_media_fetch` (`src/engine.rs:1870-1911`) followers push their id and74
+return at once, trusting the leader to answer for them. If `fetch_media`75
+panics, the leader's own id gets "command task panicked", the followers get76
+nothing, and the `(MediaId, MediaSize)` entry is never removed. Every later77
+fetch of that media then becomes a follower of a dead leader and never78
+completes. Largely superseded if media fetch becomes a direct query (see79
+M2), but the fix is the same either way. Use a drop guard or a shared future80
+so the entry always clears.81
+82
+### C6. Back pagination can stick at "start reached" forever. OPEN83
+84
+`reached_start` is a cached `AtomicBool` that is only ever set from a85
+pagination result (`src/engine.rs:4060-4071`, `4097`). A limited sync with a86
+gap makes the event cache shrink to the last chunk87
+(`event_cache/caches/room/state.rs:917-924`), which the timeline applies as88
+`VectorDiff::Clear`. History is unloaded again but the flag stays true, so89
+every later `Paginate` emits `StartReached` without fetching anything. The90
+room scrolls to a stub and stops.91
+92
+Fix direction. Drop the flag and follow `Timeline::live_back_pagination_status`93
+(`matrix-sdk-ui timeline/pagination.rs:130-138`), which is authoritative.94
+95
+### C7. Soft logout destroys the store. OPEN96
+97
+`SessionChange::UnknownToken` carries `soft_logout`98
+(`matrix-sdk client/mod.rs:166`), but the watcher collapses it into a99
+payload-free `SessionAccessRejected` (`src/engine.rs:1314`). The README and the100
+event's doc comment both tell the client to delete the saved record and the101
+store. On a soft logout the correct response is to re-authenticate and keep102
+the store, so following the documentation throws away every E2EE key for103
+nothing.104
+105
+### C8. Logout and rejected access leave the engine running. OPEN106
+107
+`spawn_logout` emits `LoggedOut` and returns (`src/engine.rs:825-852`). The108
+sync service, watchers and room actors keep running against a dead session,109
+and every later command fails with an authentication error. Neither logout nor110
+a hard `UnknownToken` shuts the engine down. The engine should stop itself,111
+emitting `LoggedOut` or the rejection before `Stopped`.112
+113
+### C9. `MarkRead` can move the read marker backwards and costs two requests. OPEN114
+115
+`mark_read` (`src/engine.rs:2072-2095`) calls `Room::send_single_receipt`116
+twice. That call does no ordering check117
+(`matrix-sdk room/mod.rs:2167-2201`), so a client that marks an older event118
+drags both the public receipt and the fully-read marker back. The timeline API119
+refuses stale receipts (`timeline/controller/mod.rs:1621-1690`), and120
+`Room::send_multiple_receipts` sets fully read and read in one121
+`/read_markers` call (`room/mod.rs:2213-2230`). Private read receipts are not122
+offered at all.123
+124
+Fix direction. When the room is open, route through the actor and use125
+`Timeline::send_multiple_receipts`. Otherwise use a single126
+`Room::send_multiple_receipts`.127
+128
+### C10. `create_direct` always creates a new room. OPEN129
+130
+`Client::create_dm` never reuses an existing DM131
+(`matrix-sdk client/mod.rs:1830-1849`). Starting a chat with someone you132
+already talk to creates a duplicate every time. `Client::get_dm_room` exists133
+for the lookup (`client/mod.rs:1852`).134
+135
+### C11. Unregistered tasks swallow panics, contradicting the design doc. OPEN136
+137
+The design doc says a watcher panic is terminal. But the timeline watcher138
+(`src/engine.rs:589`), pagination worker (`446`), pagination result task139
+(`472`) and verification watcher (`src/encryption.rs:276`) are bare140
+`tokio::spawn`s. A panic in a timeline watcher freezes that timeline in141
+silence while commands keep succeeding. The reverse mistake also exists. The142
+account-data writers (`src/engine.rs:1111`, `1141`) and the per-room media143
+upload watcher (`1945`) are registered as `TaskContext::Watcher`, so a panic144
+in a single write kills the whole engine.145
+146
+### C12. The room actor blocks on network requests. OPEN147
+148
+Each room actor runs its commands serially and awaits each one. Most sends149
+only enqueue, but `Timeline::redact` on a remote event is a direct HTTP call150
+(`timeline/mod.rs:630`), and so is removing a reaction151
+(`controller/mod.rs:595`). `Room::make_reply_event` can fetch the replied-to152
+event (`room/reply.rs:108`). A slow homeserver therefore stalls every later153
+send, paginate and thread command in that room behind one redaction.154
+155
+### C13. Media registry evicts media that is still on screen. OPEN156
+157
+`MediaRegistry::insert_all` (`src/engine.rs:358-370`) evicts in first-insertion158
+order and does not refresh position on re-registration. One registry of 2048159
+entries is shared by every room. In a long-lived session, the first messages160
+loaded in a room that is still open are the first evicted, and fetching them161
+then fails with `MediaUnknown`. Closed timelines never release their entries,162
+so they compete with open ones.163
+164
+### C14. Smaller correctness issues. OPEN165
+166
+- `CommandResultError::Failed` renders its `Display` through `Debug`167
+ (`src/transport.rs:840`), so users see a struct dump.168
+- `download_bounded` builds a fresh `reqwest::Client` per fetch just to build a169
+ request (`src/engine.rs:3181`). That reloads the TLS roots every time.170
+ `http_client.get(...)` is enough. On a 401 it refreshes the token and then171
+ fails rather than retrying once.172
+- On `Lagged`, the session watcher assumes the dropped message was a token173
+ refresh (`src/engine.rs:1315`). A dropped `UnknownToken` is lost.174
+- Leaving a room does not close its open actor, so its timeline, typing175
+ subscription and room subscription outlive the membership.176
+- `open_room` on a room that is already open succeeds with no `TimelineReset`177
+ (`src/engine.rs:1608`). A second frontend sharing the engine gets success and178
+ no snapshot.179
+- On shutdown, in-flight commands never get a terminal result. That is180
+ acceptable, but the docs should say that `Stopped` fails everything still181
+ outstanding.182
+- A store path reused after a login whose record was never persisted holds a183
+ crypto account for another device, and the SDK refuses it. Document one184
+ store directory per login.185
+186
+## A. API shape and client-builder friendliness187
+188
+### A1. Three completion mechanisms, and the useful one is rationed. OPEN189
+190
+A client must know which of three patterns each operation uses.191
+192
+- `send` returns a `CommandId`. The client correlates it by hand against193
+ `CommandSucceeded`, `CommandSucceededWithRoom` or `CommandFailed` on a194
+ shared stream.195
+- Five direct methods (`join_room`, `create_room`, `create_direct`,196
+ `delete_devices`, and the internal room-result path) await a `oneshot` and197
+ return `CommandResultError`. These emit nothing on the stream.198
+- The account-data writes await a `oneshot` but return `ClientQueryError`,199
+ with no `CommandId` at all.200
+201
+The headless example is mostly bookkeeping of ids. Malice gap 4 comes straight202
+from this. The one pattern a web backend can use, an awaited result per203
+request, is not offered for most operations.204
+205
+### A2. Pure reads modelled as commands, against the doc's own rule. OPEN206
+207
+The design doc says operations are split by whether they change state. Media208
+fetch changes no state, yet it is a `MediaCommand` whose bytes arrive as a209
+`MediaReady` broadcast on the shared stream. Every frontend then receives every210
+other frontend's image bytes, up to 20 MiB each, and the requester has to211
+filter by `MediaId`.212
+213
+### A3. Every timeline item is a `Message`, with presentation strings in `body`. OPEN214
+215
+`normalise_timeline_item` (`src/normalise/timeline.rs`) flattens every event216
+into `TimelineMessage`. The consequences for a client are these.217
+218
+- Membership changes, profile changes and room state changes arrive as219
+ messages whose body reads `[m.room.member]` or similar.220
+- Redacted and undecryptable events carry hard-coded English bodies,221
+ `<message redacted>` and `<unable to decrypt message>`. Only UTD has a flag.222
+ A redaction cannot be told apart from a user who typed that text.223
+- `msgtype` is dropped, so emotes and notices cannot be rendered differently224
+ from text.225
+- No sender avatar, though the SDK profile has one. `display_name_ambiguous`226
+ is ignored, so two "Alice"s look identical.227
+- Reply references hold no preview content, and reply details are never228
+ fetched. The SDK leaves them `Unavailable` until the app calls229
+ `Timeline::fetch_details_for_event` (`timeline/event_item/content/reply.rs:38`),230
+ so replies to anything outside the loaded window never get a sender.231
+- `TimelineItem` is not `#[non_exhaustive]`, so adding a state-event variant232
+ later is a break.233
+234
+This needs a content enum, for example `Message { msgtype, ... }`, `Redacted`,235
+`UnableToDecrypt`, `Membership`, `ProfileChange`, `RoomState`, `Poll` and236
+`Other { event_type }`. It is the largest single rendering gap.237
+238
+### A4. Timeline commands cannot target local echoes. OPEN239
+240
+`Edit`, `Redact` and `ToggleReaction` take an `OwnedEventId` and wrap it in241
+`TimelineEventItemId::EventId`. The SDK accepts transaction ids for all three,242
+but a client cannot edit, redact or react to a message it sent until the243
+server echo arrives. The commands should take the identifier that the model244
+already exposes.245
+246
+### A5. `RoomSummary` is too thin for a room list. OPEN247
+248
+Missing fields, all cheap SDK reads. Topic, canonical alias, joined and invited249
+member counts, heroes, tombstone and successor, inviter for invites, a latest250
+event preview, and space parents and children (Malice gap 3).251
+252
+### A6. No incoming verification. OPEN253
+254
+Only outgoing `StartVerification` exists. A request started from another255
+device, which is the normal "verify this new login" flow, is never observed.256
+macltok registers no handler for it, and the SDK has no stream, so the app257
+pattern is an event handler plus `Encryption::get_verification_request`258
+(`encryption/mod.rs:1060`).259
+260
+### A7. Missing `#[non_exhaustive]`. OPEN261
+262
+Adding the attribute later is itself a breaking change, so it belongs now. Output types that will263
+grow are `TimelineItem`, `CommandKind`, `RoomMembership`, `VerificationState`,264
+`HistoryState`, `KeyBackupState` and `RecoveryState`. Input structs that will265
+grow need a constructor plus `#[non_exhaustive]`. These are266
+`CreateRoomOptions`, `PasswordLogin`, `OAuthLogin` and `UploadThumbnail`.267
+268
+### A8. Smaller API issues. OPEN269
+270
+- `MediaCommand::Upload` duplicates `UploadAttachment`, and the handle exposes271
+ both `upload_media` and `upload_attachment`.272
+- Convenience methods cover room and account commands but not timeline send,273
+ edit, paginate, mark read, typing, recovery or verification, so a client uses274
+ two styles side by side.275
+- `HistoryState::Failed(String)` and `RoomPowerLevels::events: Vec<(String, i64)>`276
+ are stringly typed where typed values exist.277
+- `rooms_filtered` re-implements the search half of `RoomListFilter::matches`,278
+ which itself lowercases the needle once per room.279
+- `RoomsChanged` is sent once per notable room-info update with no coalescing280
+ at source. A sync touching fifty rooms queues fifty invalidations, each of281
+ which prompts a full `rooms()` rebuild in a naive client.282
+- The encryption defaults (`auto_enable_backups`, `OneShot` backup download)283
+ are hard-coded with no override.284
+285
+## D. Duplication and antipatterns286
+287
+Line references are from the mechanical inventory taken during this review.288
+289
+- `RoomActor::run` is about 700 lines. The "queue while the thread is opening,290
+ else QueueFull" block appears four times (`src/engine.rs:3367-3402`,291
+ `3518-3549`, `3625-3644`), and emit-then-`break 'actor` about twenty times.292
+ A thread registry type with one `route(id, root, command)` method removes293
+ most of it.294
+- Six `spawn_*` helpers share one shape of clone, spawn, run, emit295
+ (`src/engine.rs:825-960`). `spawn_rename_device` hand-rolls the same thing296
+ without `emit_command_result`. `CommandFailure` is built by hand in nine297
+ places. The reply-or-event branch appears nine times. One `spawn_command(id,298
+ kind, future)` plus one completion sink covers all of them, and A1 removes299
+ the branch entirely.300
+- `media_http_request` (`src/engine.rs:2991-3175`) is six near-identical301
+ branches that differ by endpoint type and token mode.302
+- Twenty-one `client.get_room(...)` lookups with hand-written `RoomUnknown`303
+ errors, ten of them consecutive match arms in `execute_room_command`.304
+- `media_from_message` and `media_registration` walk `MessageType` twice for305
+ the same item. The descriptor can be derived from the registration.306
+- The verification controller repeats its "done, then cancelled, then error"307
+ terminal check eight times. It also carries an unused `_client` parameter,308
+ and the encryption worker builds a throwaway `VerificationController` to run309
+ `Recover`. `EncryptionWork` is a single-variant enum.310
+- `catch_unwind` with a "... panicked" string appears six times.311
+- Test helpers `receive_until`, `event_messages`, `diff_items`, `start_engine`312
+ and `stop` are copied across up to five test files. A shared313
+ `tests/support/mod.rs` removes them.314
+- `model.rs` has its test module in the middle of the file, with types315
+ defined after it.316
+317
+## T. Test coverage318
+319
+No test touches the whole encryption domain (recovery, backups, verification),320
+all notification commands, invite, kick, ban and unban, power levels,321
+`MarkRead`, `SetTyping`, `Redact`, `ToggleReaction`, `CloseThread`, `Logout`,322
+`RenameDevice`, `DeleteDevices`, `resolve_push_notification` or323
+`rooms_filtered`. No test uses an encrypted room, which is how C4 got through.324
+325
+## M. Malice gaps, synthesised326
+327
+Malice reported four gaps. Each one traces back to a finding above, and fixing328
+the underlying finding closes the gap without special-casing it.329
+330
+| Malice gap | Root cause | Proposed shape |331
+| --- | --- | --- |332
+| Own profile | Not exposed at all | `ClientHandle::own_profile()`, a direct read via `Account::fetch_user_profile` (`account.rs:353`), returning a `Profile { user_id, display_name, avatar }`. Add `user_profile(user_id)` alongside. Setting the name and avatar become commands. |333
+| MXC avatar download | Media is reachable only through registered `MediaId`s (A2) | `ClientHandle::mxc_media(uri, MediaRequest)` as a bounded direct query, sharing the download, bound and cache code with timeline media. |334
+| Space children | `RoomSummary` gap (A5) | Wrap `matrix_sdk_ui::spaces::SpaceService` (`spaces/mod.rs`), which offers top-level joined spaces, `joined_parents_of_child` and a paginated `/hierarchy` `SpaceRoomList`. |335
+| Timeline media bytes per request | A1 and A2 | `ClientHandle::media(media_id, size)` as an awaited direct query. Remove `MediaCommand::Fetch` and `Event::MediaReady`. |336
+337
+Malice's rule, that no `matrix-sdk` use remains in the app, holds once these338
+land. None of the four needs an escape hatch.339
+340
+## Discussion items341
+342
+These are genuine trade-offs rather than fixes, each with a recommendation.343
+344
+### Q1. How far to take awaited results345
+346
+Option 1. Keep `send` and add `handle.execute(command).await -> Result<CommandOutput, CommandFailure>`347
+by carrying an optional `oneshot` in `CommandEnvelope`. Stream result events348
+continue for `send`. This is additive, but it leaves two mechanisms and every349
+engine path still branches on reply versus event.350
+351
+Option 2. Make awaited results the only completion path. Remove `CommandId`,352
+`CommandSucceeded*`, `CommandFailed` and `InteractiveAuthRequired` from the353
+stream, which then carries only state changes. Fire-and-forget callers spawn a354
+task. Every engine path produces one `Result` and the nine reply-or-event355
+branches disappear.356
+357
+The cost of option 2 is ordering. Today a `TimelineReset` is guaranteed to358
+precede the `CommandSucceeded` for its `Open` or `Resync` on the same stream.359
+Across a stream and a `oneshot` the guarantee weakens to "enqueued before", so360
+a client draining the stream in another task can see the future resolve first.361
+Either `Open` and `Resync` return the snapshot in their result and stop362
+emitting `TimelineReset`, which then needs an epoch on `TimelineChanged` so363
+stale diffs from the old watcher can be discarded, or the contract states that364
+the snapshot arrives on the stream at some point after success. A second365
+frontend sharing the engine also stops seeing the other's outcomes, though it366
+still sees every resulting state change.367
+368
+Recommendation. Option 2 with snapshot-in-result and an epoch. It is the369
+larger break, but it is the shape every client ends up wrapping the current API370
+into, and breakage is free now.371
+372
+### Q2. Space children source373
+374
+`SpaceService` gives the full graph, including unjoined children, over the375
+network via `/hierarchy`. Local `m.space.child` state is instant but lists only376
+what sync delivered and cannot show rooms the user has not joined.377
+Recommendation. Own a `SpaceService` in the engine, as with `SyncService`. Put378
+`parent_spaces` and `child_rooms` on `RoomSummary` from local state for the379
+sidebar, and add a paginated hierarchy query for browsing.380
+381
+### Q3. Timeline content model depth382
+383
+A3 can be closed with a coarse enum (message, redacted, UTD, state, other) or384
+with typed variants for membership and profile changes, room name and topic,385
+polls and calls. Coarse is quick but pushes raw event parsing onto every386
+client. Recommendation. Typed variants for membership, profile, name, topic,387
+avatar and encryption changes, which cover what Element shows inline, plus388
+`Other { event_type }` as the escape.389
+390
+## Verification results391
+392
+- `cargo fmt -- --check` clean.393
+- `cargo test --all-targets` 49 passed, none failed.@@ -0,0 +1,92 @@
1
+use macltok::{2
+ Command, CommandKind, EngineConfig, Event, LifecycleCommand, PasswordLogin, RoomCommand,3
+ RoomMembership, Session, StoreConfig, TimelineCommand,4
+};5
+6
+#[tokio::main]7
+async fn main() -> Result<(), Box<dyn std::error::Error>> {8
+ let homeserver = required("MATRIX_HOMESERVER")?;9
+ let username = required("MATRIX_USERNAME")?;10
+ let password = required("MATRIX_PASSWORD")?;11
+ let store_path = required("MATRIX_STORE_PATH")?;12
+ let store_passphrase = required("MATRIX_STORE_PASSPHRASE")?;13
+ let session = Session::login_password(14
+ PasswordLogin {15
+ homeserver,16
+ username,17
+ password,18
+ device_display_name: Some("macltok headless example".into()),19
+ },20
+ StoreConfig::new(store_path, store_passphrase),21
+ )22
+ .await?;23
+ let (engine, handle, mut events) = session.build(EngineConfig::default()).await?;24
+ let running = tokio::spawn(engine.run());25
+ let mut room_open_id = None;26
+ let mut resync_id = None;27
+ let mut timeline_items = Vec::new();28
+29
+ loop {30
+ tokio::select! {31
+ event = events.recv() => {32
+ let Some(event) = event else { break; };33
+ match event {34
+ Event::RoomsChanged if room_open_id.is_none() => {35
+ if let Some(room) = handle36
+ .rooms()37
+ .await38
+ .into_iter()39
+ .find(|room| room.membership == RoomMembership::Joined)40
+ {41
+ room_open_id = Some(handle42
+ .send(Command::Room(RoomCommand::Open(room.room_id)))43
+ .await?);44
+ }45
+ }46
+ Event::TimelineReset { items, .. } => {47
+ timeline_items = items;48
+ resync_id = None;49
+ }50
+ Event::TimelineChanged { timeline_id, diffs } if resync_id.is_none() => {51
+ for diff in diffs {52
+ if diff.try_apply(&mut timeline_items).is_err() {53
+ resync_id = Some(handle.send(Command::Timeline(54
+ TimelineCommand::Resync { timeline_id },55
+ )).await?);56
+ break;57
+ }58
+ }59
+ if resync_id.is_none() {60
+ for item in &timeline_items {61
+ if let macltok::TimelineItem::Message(message) = item {62
+ println!("{} {}", message.sender_name, message.body);63
+ }64
+ }65
+ }66
+ }67
+ Event::CommandSucceeded { id, command: CommandKind::Room }68
+ if room_open_id == Some(id) => println!("room ready"),69
+ Event::CommandFailed(error) if room_open_id == Some(error.id)70
+ || resync_id == Some(error.id) => return Err(error.message.into()),71
+ Event::CommandFailed(error) => {72
+ eprintln!("{:?} {}", error.command, error.message);73
+ }74
+ Event::Stopped => break,75
+ _ => {}76
+ }77
+ }78
+ result = tokio::signal::ctrl_c() => {79
+ result?;80
+ handle81
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))82
+ .await?;83
+ }84
+ }85
+ }86
+ running.await??;87
+ Ok(())88
+}89
+90
+fn required(name: &str) -> Result<String, Box<dyn std::error::Error>> {91
+ std::env::var(name).map_err(|_| format!("{name} is required").into())92
+}@@ -0,0 +1,27 @@
1
+{2
+ "nodes": {3
+ "nixpkgs": {4
+ "locked": {5
+ "lastModified": 1788746152,6
+ "narHash": "sha256-RaKngusl5I8pNi8RfrVvsHq3NZe7eFWzDlb01+hEVqY=",7
+ "owner": "NixOS",8
+ "repo": "nixpkgs",9
+ "rev": "42f17a57f4f6e33b3de3dca0a2a5ea5233169d02",10
+ "type": "github"11
+ },12
+ "original": {13
+ "owner": "NixOS",14
+ "ref": "nixpkgs-unstable",15
+ "repo": "nixpkgs",16
+ "type": "github"17
+ }18
+ },19
+ "root": {20
+ "inputs": {21
+ "nixpkgs": "nixpkgs"22
+ }23
+ }24
+ },25
+ "root": "root",26
+ "version": 727
+}@@ -0,0 +1,62 @@
1
+{2
+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";3
+4
+ outputs =5
+ { self, nixpkgs }:6
+ let7
+ forAllSystems = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;8
+ in9
+ {10
+ packages = forAllSystems (11
+ system:12
+ let13
+ pkgs = nixpkgs.legacyPackages.${system};14
+ in15
+ {16
+ default = pkgs.rustPlatform.buildRustPackage {17
+ pname = "macltok";18
+ version = "0.1.0";19
+ src = self;20
+ cargoLock.lockFile = ./Cargo.lock;21
+ preBuild = "export NIX_BUILD_CORES=2";22
+ SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";23
+ };24
+ }25
+ );26
+27
+ checks = forAllSystems (system: {28
+ default = self.packages.${system}.default;29
+ sqlite = nixpkgs.legacyPackages.${system}.rustPlatform.buildRustPackage {30
+ pname = "macltok-sqlite-check";31
+ version = "0.1.0";32
+ src = self;33
+ cargoLock.lockFile = ./Cargo.lock;34
+ preBuild = "export NIX_BUILD_CORES=2";35
+ buildFeatures = [ "sqlite" ];36
+ nativeBuildInputs = [ nixpkgs.legacyPackages.${system}.pkg-config ];37
+ buildInputs = [ nixpkgs.legacyPackages.${system}.sqlite ];38
+ SSL_CERT_FILE = "${nixpkgs.legacyPackages.${system}.cacert}/etc/ssl/certs/ca-bundle.crt";39
+ };40
+ });41
+42
+ devShells = forAllSystems (43
+ system:44
+ let45
+ pkgs = nixpkgs.legacyPackages.${system};46
+ in47
+ {48
+ default = pkgs.mkShell {49
+ packages = with pkgs; [50
+ cargo51
+ clippy52
+ pkg-config53
+ rust-analyzer54
+ rustc55
+ rustfmt56
+ sqlite57
+ ];58
+ };59
+ }60
+ );61
+ };62
+}@@ -0,0 +1,321 @@
1
+use std::{2
+ collections::HashMap,3
+ path::{Path, PathBuf},4
+};5
+6
+use matrix_sdk::{7
+ Client, ClientBuilder, ThreadingSupport,8
+ authentication::{9
+ AuthSession,10
+ matrix::MatrixSession,11
+ oauth::{12
+ ClientId, ClientRegistrationData, CsrfToken, OAuthSession, UserSession,13
+ registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},14
+ },15
+ },16
+ encryption::{BackupDownloadStrategy, EncryptionSettings},17
+ ruma::{OwnedDeviceId, serde::Raw},18
+};19
+use serde::{Deserialize, Serialize};20
+use thiserror::Error;21
+use url::Url;22
+23
+use crate::{ClientHandle, Engine, EngineConfig, EngineError, EventStream};24
+25
+pub struct Session {26
+ client: Client,27
+ record: SessionRecord,28
+}29
+30
+pub struct PasswordLogin {31
+ pub homeserver: String,32
+ pub username: String,33
+ pub password: String,34
+ pub device_display_name: Option<String>,35
+}36
+37
+pub struct OAuthLogin {38
+ pub homeserver: String,39
+ pub redirect_uri: String,40
+ pub client_uri: String,41
+ pub client_name: Option<String>,42
+ pub client_id: Option<String>,43
+ pub device_id: Option<OwnedDeviceId>,44
+ pub application_type: OAuthApplicationType,45
+}46
+47
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]48
+pub enum OAuthApplicationType {49
+ Native,50
+ Web,51
+}52
+53
+pub struct PendingOAuthLogin {54
+ client: Client,55
+ authorization_url: String,56
+ state: Option<CsrfToken>,57
+}58
+59
+impl PendingOAuthLogin {60
+ #[must_use]61
+ pub fn authorization_url(&self) -> &str {62
+ &self.authorization_url63
+ }64
+65
+ #[must_use]66
+ pub fn registered_client_id(&self) -> String {67
+ self.client68
+ .oauth()69
+ .client_id()70
+ .expect("OAuth client was registered before authorization")71
+ .as_str()72
+ .to_owned()73
+ }74
+75
+ #[must_use]76
+ pub const fn is_pending(&self) -> bool {77
+ self.state.is_some()78
+ }79
+80
+ pub async fn finish(&mut self, callback_url: &str) -> Result<Session, AccountError> {81
+ let callback_url = Url::parse(callback_url)82
+ .map_err(|error| account_error("parsing the OAuth callback URL", error))?;83
+ let state = self84
+ .state85
+ .take()86
+ .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;87
+ let oauth = self.client.oauth();88
+ if let Err(error) = oauth.finish_login(callback_url.into()).await {89
+ oauth.abort_login(&state).await;90
+ return Err(account_error("OAuth login", error));91
+ }92
+ Session::from_client(self.client.clone())93
+ }94
+95
+ pub async fn abort(mut self) {96
+ if let Some(state) = self.state.take() {97
+ self.client.oauth().abort_login(&state).await;98
+ }99
+ }100
+}101
+102
+pub struct StoreConfig {103
+ path: PathBuf,104
+ passphrase: String,105
+}106
+107
+impl StoreConfig {108
+ #[must_use]109
+ pub fn new(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {110
+ Self {111
+ path: path.into(),112
+ passphrase: passphrase.into(),113
+ }114
+ }115
+116
+ #[must_use]117
+ pub fn path(&self) -> &Path {118
+ &self.path119
+ }120
+}121
+122
+/// Serializable authentication secrets. Store this separately from the123
+/// encrypted SQLite store in protected, preferably encrypted storage.124
+/// A saved record must be replaced when `Event::SessionTokensChanged` arrives.125
+#[derive(Clone, Deserialize, Serialize)]126
+pub struct SessionRecord {127
+ homeserver: String,128
+ authentication: AuthenticationRecord,129
+}130
+131
+#[derive(Clone, Deserialize, Serialize)]132
+#[serde(tag = "type", content = "session", rename_all = "snake_case")]133
+enum AuthenticationRecord {134
+ Matrix(MatrixSession),135
+ OAuth {136
+ client_id: ClientId,137
+ user: UserSession,138
+ },139
+}140
+141
+impl SessionRecord {142
+ #[must_use]143
+ pub fn homeserver(&self) -> &str {144
+ &self.homeserver145
+ }146
+}147
+148
+#[derive(Debug, Error)]149
+pub enum AccountError {150
+ #[error("{context} failed")]151
+ Operation {152
+ context: &'static str,153
+ #[source]154
+ source: Box<dyn std::error::Error + Send + Sync>,155
+ },156
+ #[error("the homeserver returned no authenticated session")]157
+ MissingSession,158
+ #[error("the SDK returned an unsupported authentication session")]159
+ UnsupportedAuthentication,160
+ #[error("the OAuth callback was already submitted")]161
+ OAuthCallbackAlreadySubmitted,162
+ #[error(transparent)]163
+ Engine(#[from] EngineError),164
+}165
+166
+impl Session {167
+ pub async fn login_password(168
+ login: PasswordLogin,169
+ store: StoreConfig,170
+ ) -> Result<Self, AccountError> {171
+ let client = matrix_client_builder(&store)172
+ .server_name_or_homeserver_url(login.homeserver.trim())173
+ .build()174
+ .await175
+ .map_err(|error| account_error("building the Matrix client", error))?;176
+ let mut request = client177
+ .matrix_auth()178
+ .login_username(login.username.trim(), &login.password)179
+ .request_refresh_token();180
+ if let Some(name) = login.device_display_name.as_deref() {181
+ request = request.initial_device_display_name(name);182
+ }183
+ request184
+ .send()185
+ .await186
+ .map_err(|error| account_error("password login", error))?;187
+ Self::from_client(client)188
+ }189
+190
+ pub async fn begin_oauth(191
+ login: OAuthLogin,192
+ store: StoreConfig,193
+ ) -> Result<PendingOAuthLogin, AccountError> {194
+ let redirect_uri = Url::parse(login.redirect_uri.trim())195
+ .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;196
+ let client_uri = Url::parse(login.client_uri.trim())197
+ .map_err(|error| account_error("parsing the OAuth client URI", error))?;198
+ let client = matrix_client_builder(&store)199
+ .server_name_or_homeserver_url(login.homeserver.trim())200
+ .build()201
+ .await202
+ .map_err(|error| account_error("building the Matrix client", error))?;203
+ let application_type = match login.application_type {204
+ OAuthApplicationType::Native => ApplicationType::Native,205
+ OAuthApplicationType::Web => ApplicationType::Web,206
+ };207
+ let mut metadata = ClientMetadata::new(208
+ application_type,209
+ vec![OAuthGrantType::AuthorizationCode {210
+ redirect_uris: vec![redirect_uri.clone()],211
+ }],212
+ Localized::new(client_uri, []),213
+ );214
+ metadata.client_name = login.client_name.map(|name| Localized::new(name, []));215
+ let metadata = Raw::new(&metadata)216
+ .map_err(|error| account_error("serializing OAuth client metadata", error))?;217
+ let mut registration = ClientRegistrationData::new(metadata);218
+ if let Some(client_id) = login.client_id {219
+ registration.static_registrations = Some(HashMap::from([(220
+ client.homeserver(),221
+ ClientId::new(client_id),222
+ )]));223
+ }224
+ let authorization = client225
+ .oauth()226
+ .login(redirect_uri, login.device_id, Some(registration), None)227
+ .build()228
+ .await229
+ .map_err(|error| account_error("starting OAuth login", error))?;230
+ Ok(PendingOAuthLogin {231
+ client,232
+ authorization_url: authorization.url.to_string(),233
+ state: Some(authorization.state),234
+ })235
+ }236
+237
+ /// Restore a saved record with the same store location and passphrase.238
+ /// The caller must also handle refresh, rejection, and explicit logout.239
+ pub async fn restore(record: SessionRecord, store: StoreConfig) -> Result<Self, AccountError> {240
+ let client = matrix_client_builder(&store)241
+ .homeserver_url(&record.homeserver)242
+ .build()243
+ .await244
+ .map_err(|error| account_error("reopening the Matrix store", error))?;245
+ let authentication = match record.authentication.clone() {246
+ AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),247
+ AuthenticationRecord::OAuth { client_id, user } => {248
+ OAuthSession { client_id, user }.into()249
+ }250
+ };251
+ client252
+ .restore_session(authentication)253
+ .await254
+ .map_err(|error| account_error("restoring the authenticated session", error))?;255
+ Ok(Self { client, record })256
+ }257
+258
+ /// Initial login credentials only. This snapshot does not track later259
+ /// token rotations; use `ClientHandle::session_record` after building.260
+ #[must_use]261
+ pub fn record(&self) -> SessionRecord {262
+ self.record.clone()263
+ }264
+265
+ pub async fn build(266
+ self,267
+ config: EngineConfig,268
+ ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {269
+ Engine::from_client(self.client, config)270
+ .await271
+ .map_err(AccountError::from)272
+ }273
+274
+ fn from_client(client: Client) -> Result<Self, AccountError> {275
+ let record = session_record(&client)?;276
+ Ok(Self { client, record })277
+ }278
+}279
+280
+pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {281
+ let authentication = match client.session().ok_or(AccountError::MissingSession)? {282
+ AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),283
+ AuthSession::OAuth(session) => {284
+ let OAuthSession { client_id, user } = *session;285
+ AuthenticationRecord::OAuth { client_id, user }286
+ }287
+ _ => return Err(AccountError::UnsupportedAuthentication),288
+ };289
+ Ok(SessionRecord {290
+ homeserver: client.homeserver().to_string(),291
+ authentication,292
+ })293
+}294
+295
+fn encryption_settings() -> EncryptionSettings {296
+ EncryptionSettings {297
+ auto_enable_cross_signing: true,298
+ backup_download_strategy: BackupDownloadStrategy::OneShot,299
+ auto_enable_backups: true,300
+ }301
+}302
+303
+fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {304
+ Client::builder()305
+ .with_encryption_settings(encryption_settings())306
+ .with_threading_support(ThreadingSupport::Enabled {307
+ with_subscriptions: false,308
+ })309
+ .handle_refresh_tokens()310
+ .sqlite_store(&store.path, Some(&store.passphrase))311
+}312
+313
+fn account_error(314
+ context: &'static str,315
+ source: impl std::error::Error + Send + Sync + 'static,316
+) -> AccountError {317
+ AccountError::Operation {318
+ context,319
+ source: Box::new(source),320
+ }321
+}@@ -0,0 +1,863 @@
1
+use std::sync::Arc;2
+3
+use futures_util::StreamExt;4
+use matrix_sdk::{5
+ Client,6
+ encryption::{7
+ VerificationState,8
+ backups::BackupState,9
+ recovery::RecoveryState as SdkRecoveryState,10
+ verification::{11
+ SasState, SasVerification, Verification, VerificationRequest, VerificationRequestState,12
+ },13
+ },14
+ ruma::events::key::verification::VerificationMethod,15
+};16
+use ruma::{OwnedDeviceId, UserId};17
+use tokio::{sync::Mutex, task::JoinHandle};18
+19
+use crate::{20
+ CommandErrorKind, EncryptionState, Event, KeyBackupState, RecoveryState, VerificationAction,21
+ VerificationState as UiState, VerificationTarget, error::CommandError,22
+ model::VerificationFailure, transport::EventSender,23
+};24
+25
+pub(crate) fn encryption_state(26
+ device_id: OwnedDeviceId,27
+ recovery: SdkRecoveryState,28
+ verification: VerificationState,29
+ backup: BackupState,30
+) -> EncryptionState {31
+ EncryptionState {32
+ device_id,33
+ device_verified: matches!(verification, VerificationState::Verified),34
+ recovery: match recovery {35
+ SdkRecoveryState::Unknown => RecoveryState::Unknown,36
+ SdkRecoveryState::Disabled => RecoveryState::Disabled,37
+ SdkRecoveryState::Incomplete => RecoveryState::Incomplete,38
+ SdkRecoveryState::Enabled => RecoveryState::Enabled,39
+ },40
+ key_backup: match backup {41
+ BackupState::Unknown => KeyBackupState::Unknown,42
+ BackupState::Creating => KeyBackupState::Creating,43
+ BackupState::Enabling => KeyBackupState::Enabling,44
+ BackupState::Resuming => KeyBackupState::Resuming,45
+ BackupState::Enabled => KeyBackupState::Enabled,46
+ BackupState::Downloading => KeyBackupState::Downloading,47
+ BackupState::Disabling => KeyBackupState::Disabling,48
+ },49
+ }50
+}51
+52
+#[derive(Default)]53
+pub(crate) struct VerificationController {54
+ active: Arc<Mutex<VerificationSession>>,55
+ task: Option<JoinHandle<()>>,56
+}57
+58
+#[derive(Default)]59
+struct VerificationSession {60
+ request: Option<VerificationRequest>,61
+ sas: Option<SasVerification>,62
+}63
+64
+impl VerificationController {65
+ pub(crate) async fn apply(66
+ &mut self,67
+ _client: &Client,68
+ sender: EventSender,69
+ action: VerificationAction,70
+ ) -> Result<(), CommandError> {71
+ match action {72
+ VerificationAction::Confirm => {73
+ // Retain a handle across the operation: the watcher clears74
+ // `active` when it publishes a terminal event, but this clone75
+ // still observes the shared SDK state afterwards.76
+ let sas = self.active.lock().await.sas.clone();77
+ match self.confirm().await {78
+ Ok(()) => Ok(()),79
+ Err(error) if error.kind == CommandErrorKind::VerificationInactive => {80
+ Err(error)81
+ }82
+ // The confirm advanced local SDK state before its send failed,83
+ // so the retained session could report success on retry without84
+ // network traffic. Best-effort cancel (no lock held across the85
+ // network), clear for restart, then report the terminal state.86
+ Err(error) => {87
+ // Stop the watcher first so it cannot emit a stale88
+ // Confirmed from the local transition we are unwinding.89
+ self.stop_task().await;90
+ // The peer can complete or cancel the verification91
+ // while our own Done send fails. The stopped watcher may92
+ // already have published that terminal outcome (losing the93
+ // claim below); otherwise this path owns it and must emit94
+ // it instead of regressing to Cancelled.95
+ if sas.as_ref().is_some_and(|sas| sas.is_done()) {96
+ if take_verification(&self.active).await {97
+ let _ =98
+ sender.send(Event::VerificationChanged(UiState::Done)).await;99
+ }100
+ return Err(error);101
+ }102
+ if let Some(reason) = sas103
+ .as_ref()104
+ .and_then(|sas| sas.cancel_info())105
+ .map(|info| info.reason().to_owned())106
+ {107
+ if take_verification(&self.active).await {108
+ let _ = sender109
+ .send(Event::VerificationChanged(UiState::Cancelled(reason)))110
+ .await;111
+ }112
+ return Err(error);113
+ }114
+ if let Some(sas) = sas {115
+ if sas.cancel().await.is_err() {116
+ // The best-effort unwind already advanced117
+ // local state; clearing here keeps the failed118
+ // cancellation from looking retryable. The119
+ // send is owned only if the watcher has not120
+ // already claimed the session for its own121
+ // terminal outcome.122
+ if take_verification(&self.active).await {123
+ let _ = sender124
+ .send(Event::VerificationChanged(UiState::Cancelled(125
+ error.to_string(),126
+ )))127
+ .await;128
+ }129
+ return Err(error);130
+ }131
+ // The peer can complete while the unwind cancel is in132
+ // flight and the SDK call no-ops; the Done then owns the133
+ // terminal outcome, so report it instead of attributing134
+ // it to the send failure.135
+ if sas.is_done() {136
+ if take_verification(&self.active).await {137
+ let _ = sender138
+ .send(Event::VerificationChanged(UiState::Done))139
+ .await;140
+ }141
+ return Err(error);142
+ }143
+ if let Some(info) = sas.cancel_info() {144
+ // Our own unwind cancel just marked the flow; it does145
+ // not own the outcome yet, so fall through and report146
+ // the send failure as Cancelled below. Only a peer147
+ // cancellation racing the unwind owns it instead.148
+ if !info.cancelled_by_us() {149
+ if take_verification(&self.active).await {150
+ let _ = sender151
+ .send(Event::VerificationChanged(UiState::Cancelled(152
+ info.reason().to_owned(),153
+ )))154
+ .await;155
+ }156
+ return Err(CommandError::new(157
+ CommandErrorKind::VerificationInactive,158
+ info.reason().to_owned(),159
+ ));160
+ }161
+ }162
+ }163
+ if take_verification(&self.active).await {164
+ let _ = sender165
+ .send(Event::VerificationChanged(UiState::Cancelled(166
+ error.to_string(),167
+ )))168
+ .await;169
+ }170
+ Err(error)171
+ }172
+ }173
+ }174
+ VerificationAction::Reject => self.reject().await,175
+ VerificationAction::Cancel => {176
+ // The SDK no-ops a cancel of an already-settled flow, so a177
+ // failed claim inside `cancel` already reported the true178
+ // outcome as an error and owns no event here. Only the claim179
+ // winner emits the local Cancelled.180
+ let claimed = self.cancel_claimed(Some(&sender)).await?;181
+ if claimed {182
+ sender183
+ .send(Event::VerificationChanged(UiState::Cancelled(184
+ "cancelled by this client".to_owned(),185
+ )))186
+ .await187
+ .map_err(|source| {188
+ CommandError::with_source(189
+ CommandErrorKind::EventReceiverClosed,190
+ "event receiver has closed",191
+ source,192
+ )193
+ })?;194
+ }195
+ Ok(())196
+ }197
+ }198
+ }199
+200
+ pub(crate) async fn start(201
+ &mut self,202
+ client: &Client,203
+ sender: EventSender,204
+ target: VerificationTarget,205
+ ) -> Result<(), CommandError> {206
+ client207
+ .encryption()208
+ .wait_for_e2ee_initialization_tasks()209
+ .await;210
+ let own_user_id = client.user_id().ok_or_else(|| {211
+ CommandError::new(212
+ CommandErrorKind::Unauthenticated,213
+ "the Matrix client is not authenticated",214
+ )215
+ })?;216
+ if self.has_active_verification().await {217
+ self.cancel().await?;218
+ } else {219
+ self.stop_task().await;220
+ }221
+ let request = match &target {222
+ VerificationTarget::OwnIdentity => {223
+ request_identity_verification(client, own_user_id).await?224
+ }225
+ VerificationTarget::User(user_id) => {226
+ request_identity_verification(client, user_id).await?227
+ }228
+ VerificationTarget::Device { user_id, device_id } => {229
+ let device = client230
+ .encryption()231
+ .get_device(user_id, device_id)232
+ .await233
+ .map_err(|source| {234
+ CommandError::with_source(235
+ CommandErrorKind::Encryption,236
+ "reading the verification device failed",237
+ source,238
+ )239
+ })?240
+ .ok_or_else(|| {241
+ CommandError::new(242
+ CommandErrorKind::VerificationUnsupported,243
+ "the verification device is unavailable",244
+ )245
+ })?;246
+ device247
+ .request_verification_with_methods(vec![VerificationMethod::SasV1])248
+ .await249
+ .map_err(|source| {250
+ CommandError::from_sdk("starting device verification failed", source)251
+ })?252
+ }253
+ };254
+ self.active.lock().await.request = Some(request.clone());255
+ sender256
+ .send(Event::VerificationTargetChanged(target))257
+ .await258
+ .map_err(|source| {259
+ CommandError::with_source(260
+ CommandErrorKind::EventReceiverClosed,261
+ "event receiver has closed",262
+ source,263
+ )264
+ })?;265
+ sender266
+ .send(Event::VerificationChanged(UiState::Waiting))267
+ .await268
+ .map_err(|source| {269
+ CommandError::with_source(270
+ CommandErrorKind::EventReceiverClosed,271
+ "event receiver has closed",272
+ source,273
+ )274
+ })?;275
+ let active = self.active.clone();276
+ self.task = Some(tokio::spawn(async move {277
+ follow_request(request, active, sender).await;278
+ }));279
+ Ok(())280
+ }281
+282
+ async fn confirm(&self) -> Result<(), CommandError> {283
+ let sas = self.active.lock().await.sas.clone().ok_or_else(|| {284
+ CommandError::new(285
+ CommandErrorKind::VerificationInactive,286
+ "verification has not reached comparison",287
+ )288
+ })?;289
+ // Report a settled flow truthfully instead of as "not reached290
+ // comparison": the SDK would no-op the confirm with success.291
+ if sas.is_done() {292
+ return Err(CommandError::new(293
+ CommandErrorKind::VerificationInactive,294
+ "verification is already complete",295
+ ));296
+ }297
+ if let Some(info) = sas.cancel_info() {298
+ return Err(CommandError::new(299
+ CommandErrorKind::VerificationInactive,300
+ info.reason().to_owned(),301
+ ));302
+ }303
+ // Only the short-auth-string stage is confirmable. The SDK reports the304
+ // remote-MAC-arrived state through the same presentable flag, while an305
+ // early confirm would locally no-op and falsely report success.306
+ if !sas.can_be_presented() {307
+ return Err(CommandError::new(308
+ CommandErrorKind::VerificationInactive,309
+ "verification has not reached comparison",310
+ ));311
+ }312
+ // Our own confirm can move the flow to Done, so only Done that predates313
+ // the send means the SDK no-op'd on a stale session; a cancellation314
+ // racing the send must not report success.315
+ let was_done = sas.is_done();316
+ sas.confirm()317
+ .await318
+ .map_err(|source| CommandError::from_sdk("confirming verification failed", source))?;319
+ if sas.is_done() {320
+ if was_done {321
+ return Err(CommandError::new(322
+ CommandErrorKind::VerificationInactive,323
+ "verification is already complete",324
+ ));325
+ }326
+ } else if sas.is_cancelled() {327
+ let reason = sas328
+ .cancel_info()329
+ .map(|info| info.reason().to_owned())330
+ .unwrap_or_else(|| "verification was cancelled".to_owned());331
+ return Err(CommandError::new(332
+ CommandErrorKind::VerificationInactive,333
+ reason,334
+ ));335
+ }336
+ Ok(())337
+ }338
+339
+ async fn reject(&self) -> Result<(), CommandError> {340
+ let sas = self.active.lock().await.sas.clone().ok_or_else(|| {341
+ CommandError::new(342
+ CommandErrorKind::VerificationInactive,343
+ "verification has not reached comparison",344
+ )345
+ })?;346
+ // Report a settled flow truthfully instead of as "not reached347
+ // comparison": the SDK would no-op the mismatch with success.348
+ if sas.is_done() {349
+ return Err(CommandError::new(350
+ CommandErrorKind::VerificationInactive,351
+ "verification is already complete",352
+ ));353
+ }354
+ if let Some(info) = sas.cancel_info() {355
+ return Err(CommandError::new(356
+ CommandErrorKind::VerificationInactive,357
+ info.reason().to_owned(),358
+ ));359
+ }360
+ // Rejecting before the comparison stage would cancel a flow the user361
+ // never compared, so it requires the same presentable state.362
+ if !sas.can_be_presented() {363
+ return Err(CommandError::new(364
+ CommandErrorKind::VerificationInactive,365
+ "verification has not reached comparison",366
+ ));367
+ }368
+ // A peer completing the flow concurrently must not be reported as369
+ // rejected. A remote cancellation racing the send turns the SDK call370
+ // into a no-op success, so report it instead of a false success.371
+ if sas.is_done() {372
+ return Err(CommandError::new(373
+ CommandErrorKind::VerificationInactive,374
+ "verification is already complete",375
+ ));376
+ }377
+ if let Some(info) = sas.cancel_info() {378
+ // Our own mismatch just marked the flow cancelled-by-us; that is the379
+ // expected reject path and stays Ok. Only a peer cancel reports an380
+ // error.381
+ if !info.cancelled_by_us() {382
+ return Err(CommandError::new(383
+ CommandErrorKind::VerificationInactive,384
+ info.reason().to_owned(),385
+ ));386
+ }387
+ }388
+ Ok(())389
+ }390
+ pub(crate) async fn cancel(&mut self) -> Result<(), CommandError> {391
+ self.cancel_claimed(None).await.map(|_| ())392
+ }393
+394
+ /// Return whether this action won the claim for a local cancellation.395
+ /// The optional sender reports a peer terminal outcome when it races after396
+ /// the claim; starting a replacement verification does not publish it.397
+ async fn cancel_claimed(&mut self, sender: Option<&EventSender>) -> Result<bool, CommandError> {398
+ let (sas, request) = {399
+ let active = self.active.lock().await;400
+ (active.sas.clone(), active.request.clone())401
+ };402
+ if sas.is_none() && request.is_none() {403
+ return Err(CommandError::new(404
+ CommandErrorKind::VerificationInactive,405
+ "no verification is active",406
+ ));407
+ }408
+ // The watcher owns terminal emission and claims the session when it409
+ // publishes. If the SDK is already terminal, the watcher either already410
+ // reported it or is about to: leave session and watcher alone so the411
+ // outcome is emitted exactly once, and report it instead of letting the412
+ // SDK no-op success regress Done to "cancelled by this client".413
+ if let Some(sas) = sas.as_ref() {414
+ if sas.is_done() {415
+ return Err(CommandError::new(416
+ CommandErrorKind::VerificationInactive,417
+ "verification is already complete",418
+ ));419
+ }420
+ if let Some(info) = sas.cancel_info() {421
+ return Err(CommandError::new(422
+ CommandErrorKind::VerificationInactive,423
+ info.reason().to_owned(),424
+ ));425
+ }426
+ } else if let Some(request) = request.as_ref() {427
+ if request.is_done() {428
+ return Err(CommandError::new(429
+ CommandErrorKind::VerificationInactive,430
+ "verification is already complete",431
+ ));432
+ }433
+ if let Some(info) = request.cancel_info() {434
+ return Err(CommandError::new(435
+ CommandErrorKind::VerificationInactive,436
+ info.reason().to_owned(),437
+ ));438
+ }439
+ }440
+ // Own the terminal outcome from here: claim the session before441
+ // stopping the watcher so a watcher that already claimed (and will442
+ // emit its own terminal event) turns this into a truthful error443
+ // instead of a duplicate local cancel; no lock is held across sends.444
+ if !take_verification(&self.active).await {445
+ // Re-check the SDK: the claim loss may be a stale session after a446
+ // terminal outcome, or a concurrent start of a new flow. Only the447
+ // terminal case reports here; a live session belongs to the new448
+ // flow and must not be disturbed.449
+ if let Some(sas) = sas.as_ref() {450
+ if sas.is_done() {451
+ return Err(CommandError::new(452
+ CommandErrorKind::VerificationInactive,453
+ "verification is already complete",454
+ ));455
+ }456
+ if let Some(info) = sas.cancel_info() {457
+ return Err(CommandError::new(458
+ CommandErrorKind::VerificationInactive,459
+ info.reason().to_owned(),460
+ ));461
+ }462
+ } else if let Some(request) = request.as_ref() {463
+ if request.is_done() {464
+ return Err(CommandError::new(465
+ CommandErrorKind::VerificationInactive,466
+ "verification is already complete",467
+ ));468
+ }469
+ if let Some(info) = request.cancel_info() {470
+ return Err(CommandError::new(471
+ CommandErrorKind::VerificationInactive,472
+ info.reason().to_owned(),473
+ ));474
+ }475
+ }476
+ return Err(CommandError::new(477
+ CommandErrorKind::VerificationInactive,478
+ "no verification is active",479
+ ));480
+ }481
+ self.stop_task().await;482
+ if let Some(sas) = sas {483
+ let result = sas.cancel().await;484
+ if sas.is_done() {485
+ report_claimed_terminal(sender, UiState::Done).await;486
+ return Err(CommandError::new(487
+ CommandErrorKind::VerificationInactive,488
+ "verification is already complete",489
+ ));490
+ }491
+ if let Some(info) = sas.cancel_info()492
+ && !info.cancelled_by_us()493
+ {494
+ let reason = info.reason().to_owned();495
+ report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;496
+ return Err(CommandError::new(497
+ CommandErrorKind::VerificationInactive,498
+ reason,499
+ ));500
+ }501
+ if let Err(source) = result {502
+ let error = CommandError::from_sdk("cancelling verification failed", source);503
+ report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;504
+ return Err(error);505
+ }506
+ Ok(true)507
+ } else if let Some(request) = request {508
+ let result = request.cancel().await;509
+ if request.is_done() {510
+ report_claimed_terminal(sender, UiState::Done).await;511
+ return Err(CommandError::new(512
+ CommandErrorKind::VerificationInactive,513
+ "verification is already complete",514
+ ));515
+ }516
+ if let Some(info) = request.cancel_info()517
+ && !info.cancelled_by_us()518
+ {519
+ let reason = info.reason().to_owned();520
+ report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;521
+ return Err(CommandError::new(522
+ CommandErrorKind::VerificationInactive,523
+ reason,524
+ ));525
+ }526
+ if let Err(source) = result {527
+ let error = CommandError::from_sdk("cancelling verification failed", source);528
+ report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;529
+ return Err(error);530
+ }531
+ Ok(true)532
+ } else {533
+ Ok(false)534
+ }535
+ }536
+537
+ async fn has_active_verification(&self) -> bool {538
+ let active = self.active.lock().await;539
+ active.request.is_some() || active.sas.is_some()540
+ }541
+542
+ async fn stop_task(&mut self) {543
+ if let Some(task) = self.task.take() {544
+ task.abort();545
+ let _ = task.await;546
+ }547
+ }548
+549
+ pub(crate) async fn stop(&mut self) {550
+ // Shutdown path: terminate the watcher without touching the SDK so a551
+ // shutdown under lock cannot hang on network sends. No terminal event552
+ // is emitted; the session is simply forgotten locally.553
+ self.stop_task().await;554
+ clear_verification(&self.active).await;555
+ }556
+}557
+558
+async fn request_identity_verification(559
+ client: &Client,560
+ user_id: &UserId,561
+) -> Result<VerificationRequest, CommandError> {562
+ let identity = client563
+ .encryption()564
+ .request_user_identity(user_id)565
+ .await566
+ .map_err(|source| CommandError::from_sdk("requesting the user identity failed", source))?567
+ .ok_or_else(|| {568
+ CommandError::new(569
+ CommandErrorKind::NoCrossSigningIdentity,570
+ "the user has no cross-signing identity",571
+ )572
+ })?;573
+ identity574
+ .request_verification_with_methods(vec![VerificationMethod::SasV1])575
+ .await576
+ .map_err(|source| {577
+ CommandError::from_verification_request("starting verification failed", source)578
+ })579
+}580
+581
+async fn follow_request(582
+ request: VerificationRequest,583
+ active: Arc<Mutex<VerificationSession>>,584
+ sender: EventSender,585
+) {586
+ let mut changes = request.changes();587
+ while let Some(state) = changes.next().await {588
+ match state {589
+ VerificationRequestState::Created { .. } => {}590
+ VerificationRequestState::Requested { .. } => {591
+ if let Err(error) = request592
+ .accept_with_methods(vec![VerificationMethod::SasV1])593
+ .await594
+ {595
+ tracing::warn!(error = %error, "accepting verification request failed");596
+ // Claim the session so this terminal outcome is emitted exactly597
+ // once; a lost claim means an action path already owns it.598
+ report_watcher_terminal(599
+ &active,600
+ &sender,601
+ UiState::Failed(VerificationFailure::RequestAcceptance(error.to_string())),602
+ "request acceptance failure",603
+ )604
+ .await;605
+ return;606
+ }607
+ }608
+ VerificationRequestState::Ready { .. } => match request.start_sas().await {609
+ Ok(Some(sas)) => {610
+ follow_sas(sas, active.clone(), sender.clone()).await;611
+ return;612
+ }613
+ Ok(None) => {}614
+ Err(error) => {615
+ tracing::warn!(error = %error, "starting SAS verification failed");616
+ // Claim the session so this terminal outcome is emitted exactly617
+ // once; a lost claim means an action path already owns it.618
+ report_watcher_terminal(619
+ &active,620
+ &sender,621
+ UiState::Failed(VerificationFailure::SasStartup(error.to_string())),622
+ "SAS startup failure",623
+ )624
+ .await;625
+ return;626
+ }627
+ },628
+ VerificationRequestState::Transitioned { verification } => {629
+ if let Verification::SasV1(sas) = verification {630
+ follow_sas(sas, active.clone(), sender.clone()).await;631
+ } else {632
+ report_watcher_terminal(633
+ &active,634
+ &sender,635
+ UiState::Cancelled(636
+ "the other device selected an unsupported method".into(),637
+ ),638
+ "unsupported verification method",639
+ )640
+ .await;641
+ }642
+ return;643
+ }644
+ VerificationRequestState::Done => {645
+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;646
+ return;647
+ }648
+ VerificationRequestState::Cancelled(info) => {649
+ report_watcher_terminal(650
+ &active,651
+ &sender,652
+ UiState::Cancelled(info.reason().to_owned()),653
+ "cancellation",654
+ )655
+ .await;656
+ return;657
+ }658
+ }659
+ }660
+ tracing::debug!("verification request watcher ended unexpectedly");661
+ // The stream may end after the SDK already settled without delivering the662
+ // terminal update; report the settled outcome instead of StreamEnded, and663
+ // stay silent if an action path already claimed the session.664
+ if request.is_done() {665
+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;666
+ } else if let Some(info) = request.cancel_info() {667
+ let reason = info.reason().to_owned();668
+ report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;669
+ } else {670
+ report_watcher_terminal(671
+ &active,672
+ &sender,673
+ UiState::Failed(VerificationFailure::RequestStreamEnded),674
+ "request stream termination",675
+ )676
+ .await;677
+ }678
+}679
+680
+async fn follow_sas(681
+ sas: SasVerification,682
+ active: Arc<Mutex<VerificationSession>>,683
+ sender: EventSender,684
+) {685
+ active.lock().await.sas = Some(sas.clone());686
+ let mut changes = sas.changes();687
+ while let Some(state) = changes.next().await {688
+ let update = match state {689
+ SasState::Created { .. } | SasState::Accepted { .. } => continue,690
+ SasState::Started { .. } => {691
+ if let Err(error) = sas.accept().await {692
+ tracing::warn!(error = %error, "accepting SAS verification failed");693
+ // Claim the session so this terminal outcome is emitted exactly694
+ // once; a lost claim means an action path already owns it.695
+ report_watcher_terminal(696
+ &active,697
+ &sender,698
+ UiState::Failed(VerificationFailure::SasAcceptance(error.to_string())),699
+ "SAS acceptance failure",700
+ )701
+ .await;702
+ return;703
+ }704
+ continue;705
+ }706
+ SasState::KeysExchanged { emojis, decimals } => UiState::Comparison {707
+ emojis: emojis708
+ .map(|short_auth| {709
+ short_auth710
+ .emojis711
+ .into_iter()712
+ .map(|emoji| (emoji.symbol.to_owned(), emoji.description.to_owned()))713
+ .collect()714
+ })715
+ .unwrap_or_default(),716
+ decimals,717
+ },718
+ SasState::Confirmed => UiState::Confirmed,719
+ SasState::Done { .. } => UiState::Done,720
+ SasState::Cancelled(info) => UiState::Cancelled(info.reason().to_owned()),721
+ };722
+ let terminal = matches!(update, UiState::Done | UiState::Cancelled(_));723
+ // Claim the session before the terminal send so a racing Cancel observes724
+ // no active session and the SDK no-op cannot regress Done back to725
+ // "cancelled by this client". A lost claim means an action path already726
+ // owns the terminal outcome, so stay silent instead of duplicating it.727
+ if terminal && !take_verification(&active).await {728
+ return;729
+ }730
+ if sender731
+ .send(Event::VerificationChanged(update))732
+ .await733
+ .is_err()734
+ {735
+ tracing::debug!("SAS watcher stopped because the event receiver closed");736
+ return;737
+ }738
+ if terminal {739
+ return;740
+ }741
+ }742
+ tracing::debug!("SAS watcher ended unexpectedly");743
+ // The stream may end after the SDK already settled without delivering the744
+ // terminal update; report the settled outcome instead of StreamEnded, and745
+ // stay silent if an action path already claimed the session.746
+ if sas.is_done() {747
+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;748
+ } else if let Some(info) = sas.cancel_info() {749
+ let reason = info.reason().to_owned();750
+ report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;751
+ } else {752
+ report_watcher_terminal(753
+ &active,754
+ &sender,755
+ UiState::Failed(VerificationFailure::SasStreamEnded),756
+ "SAS stream termination",757
+ )758
+ .await;759
+ }760
+}761
+762
+async fn report_watcher_terminal(763
+ active: &Arc<Mutex<VerificationSession>>,764
+ sender: &EventSender,765
+ state: UiState,766
+ context: &'static str,767
+) {768
+ if take_verification(active).await769
+ && sender770
+ .send(Event::VerificationChanged(state))771
+ .await772
+ .is_err()773
+ {774
+ tracing::debug!(775
+ context,776
+ "verification watcher could not report terminal outcome"777
+ );778
+ }779
+}780
+781
+async fn report_claimed_terminal(sender: Option<&EventSender>, state: UiState) {782
+ if let Some(sender) = sender {783
+ let _ = sender.send(Event::VerificationChanged(state)).await;784
+ }785
+}786
+787
+async fn clear_verification(active: &Arc<Mutex<VerificationSession>>) {788
+ let mut active = active.lock().await;789
+ active.sas.take();790
+ active.request.take();791
+}792
+793
+/// Claim the active session for terminal emission, if one is still present.794
+///795
+/// Exactly one party (watcher or action path) wins the claim and may emit the796
+/// terminal event; the loser stays silent so Done is never overwritten by a797
+/// later local Cancelled/Failed.798
+async fn take_verification(active: &Arc<Mutex<VerificationSession>>) -> bool {799
+ let mut active = active.lock().await;800
+ let had = active.sas.is_some() || active.request.is_some();801
+ active.sas.take();802
+ active.request.take();803
+ had804
+}805
+806
+#[cfg(test)]807
+mod tests {808
+ use matrix_sdk::encryption::VerificationState;809
+ use matrix_sdk::encryption::backups::BackupState;810
+ use ruma::owned_device_id;811
+812
+ use super::*;813
+814
+ #[test]815
+ fn sdk_security_state_is_normalized() {816
+ assert_eq!(817
+ encryption_state(818
+ owned_device_id!("DEVICE"),819
+ SdkRecoveryState::Incomplete,820
+ VerificationState::Unverified,821
+ BackupState::Unknown,822
+ ),823
+ EncryptionState {824
+ device_id: owned_device_id!("DEVICE"),825
+ device_verified: false,826
+ recovery: RecoveryState::Incomplete,827
+ key_backup: KeyBackupState::Unknown,828
+ }829
+ );830
+ assert_eq!(831
+ encryption_state(832
+ owned_device_id!("DEVICE"),833
+ SdkRecoveryState::Enabled,834
+ VerificationState::Verified,835
+ BackupState::Enabled,836
+ ),837
+ EncryptionState {838
+ device_id: owned_device_id!("DEVICE"),839
+ device_verified: true,840
+ recovery: RecoveryState::Enabled,841
+ key_backup: KeyBackupState::Enabled,842
+ }843
+ );844
+ }845
+846
+ #[tokio::test]847
+ async fn comparison_actions_require_an_active_sas() {848
+ let mut controller = VerificationController::default();849
+850
+ assert_eq!(851
+ controller.confirm().await.unwrap_err().to_string(),852
+ "verification has not reached comparison"853
+ );854
+ assert_eq!(855
+ controller.reject().await.unwrap_err().to_string(),856
+ "verification has not reached comparison"857
+ );858
+ assert_eq!(859
+ controller.cancel().await.unwrap_err().to_string(),860
+ "no verification is active"861
+ );862
+ }863
+}@@ -0,0 +1,4175 @@
1
+use std::{2
+ borrow::Cow,3
+ collections::{HashMap, HashSet, VecDeque},4
+ future::Future,5
+ io::Read as _,6
+ panic::AssertUnwindSafe,7
+ sync::{8
+ Arc,9
+ atomic::{AtomicBool, Ordering},10
+ },11
+};12
+13
+use futures_util::{FutureExt, StreamExt};14
+use matrix_sdk::{15
+ Client, Room, SessionChange,16
+ media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings},17
+ room::edit::EditedContent,18
+ room::reply::{EnforceThread, Reply as SdkReply},19
+ ruma::{20
+ OwnedDeviceId, OwnedEventId, OwnedRoomId, RoomId,21
+ api::client::receipt::create_receipt::v3::ReceiptType,22
+ events::{23
+ AnyGlobalAccountDataEventContent, AnyMessageLikeEventContent,24
+ GlobalAccountDataEventType, Mentions, RoomAccountDataEventType,25
+ receipt::ReceiptThread,26
+ room::message::{27
+ AddMentions, MessageType, Relation, ReplyWithinThread,28
+ RoomMessageEventContentWithoutRelation,29
+ },30
+ },31
+ serde::Raw,32
+ },33
+ send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendHandle},34
+};35
+use matrix_sdk_ui::{36
+ sync_service::{State as SyncState, SyncService},37
+ timeline::{38
+ RoomExt, Timeline, TimelineEventItemId, TimelineFocus, TimelineReadReceiptTracking,39
+ },40
+};41
+use thiserror::Error;42
+use tokio::{43
+ sync::{Mutex, mpsc, oneshot, watch},44
+ task::{AbortHandle, Id as TaskId, JoinHandle, JoinSet},45
+};46
+47
+use crate::{48
+ AccountCommand, AttachmentUpload, ClientHandle, Command, CommandErrorKind, CommandFailure,49
+ CommandId, CommandKind, ConnectionState, CreateRoomOptions, EncryptionCommand, EngineErrorKind,50
+ EngineFailure, Event, EventStream, HistoryState, InteractiveAuth, InteractiveAuthChallenge,51
+ InteractiveAuthOutcome, LifecycleCommand, MediaCommand, MediaId, MediaSize, MessageContent,52
+ MessageEditContent, MessageMentions, NotificationCommand, PowerLevelChanges, RichText,53
+ RoomCommand, RoomNotificationMode, RoomPreset, RoomVisibility, TimelineCommand, TimelineId,54
+ TypingState,55
+ encryption::{VerificationController, encryption_state},56
+ error::{CommandError, classify_sdk_error},57
+ normalise::{MediaRegistration, MediaScope, normalise_timeline, normalise_timeline_diffs},58
+ transport::{59
+ ClientQueryError, CommandEnvelope, CommandResultError, DriverPort, DriverRequest,60
+ EventSender, SendEventError, transport_pair,61
+ },62
+ upload::prepare_upload,63
+};64
+use matrix_sdk::ruma::api::{Metadata, OutgoingRequest, auth_scheme::SendAccessToken};65
+use matrix_sdk_crypto::{AttachmentDecryptor, MediaEncryptionInfo};66
+67
+#[derive(Clone, Debug, Eq, PartialEq)]68
+#[non_exhaustive]69
+pub struct EngineConfig {70
+ pub command_capacity: usize,71
+ pub event_capacity: usize,72
+ pub initial_history_limit: u16,73
+ pub max_media_bytes: usize,74
+ pub max_media_sources: usize,75
+}76
+77
+impl EngineConfig {78
+ #[must_use]79
+ pub const fn with_command_capacity(mut self, capacity: usize) -> Self {80
+ self.command_capacity = capacity;81
+ self82
+ }83
+84
+ #[must_use]85
+ pub const fn with_event_capacity(mut self, capacity: usize) -> Self {86
+ self.event_capacity = capacity;87
+ self88
+ }89
+90
+ #[must_use]91
+ pub const fn with_initial_history_limit(mut self, limit: u16) -> Self {92
+ self.initial_history_limit = limit;93
+ self94
+ }95
+96
+ #[must_use]97
+ pub const fn with_max_media_bytes(mut self, bytes: usize) -> Self {98
+ self.max_media_bytes = bytes;99
+ self100
+ }101
+102
+ #[must_use]103
+ pub const fn with_max_media_sources(mut self, sources: usize) -> Self {104
+ self.max_media_sources = sources;105
+ self106
+ }107
+}108
+109
+impl Default for EngineConfig {110
+ fn default() -> Self {111
+ Self {112
+ command_capacity: 64,113
+ event_capacity: 128,114
+ initial_history_limit: 30,115
+ max_media_bytes: 20 * 1024 * 1024,116
+ max_media_sources: 2_048,117
+ }118
+ }119
+}120
+121
+#[derive(Debug, Error)]122
+pub enum EngineError {123
+ #[error("the Matrix client is not authenticated")]124
+ Unauthenticated,125
+ #[error("command, event, and media source capacities must be nonzero")]126
+ ZeroCapacity,127
+ #[error("could not build the sync service")]128
+ SyncService(#[source] matrix_sdk_ui::sync_service::Error),129
+ #[error("the event receiver has closed")]130
+ EventReceiverClosed,131
+ #[error("engine task {task} panicked")]132
+ TaskPanicked { task: String },133
+}134
+135
+impl EngineError {136
+ fn failure(&self) -> EngineFailure {137
+ EngineFailure {138
+ kind: match self {139
+ Self::EventReceiverClosed => EngineErrorKind::EventReceiverClosed,140
+ Self::Unauthenticated141
+ | Self::ZeroCapacity142
+ | Self::SyncService(_)143
+ | Self::TaskPanicked { .. } => EngineErrorKind::Other,144
+ },145
+ message: self.to_string(),146
+ }147
+ }148
+}149
+150
+type UploadWatchers = HashMap<151
+ OwnedRoomId,152
+ (153
+ AbortHandle,154
+ watch::Receiver<Option<Result<(), CommandErrorKind>>>,155
+ ),156
+>;157
+type MediaFlights = Arc<Mutex<HashMap<(MediaId, MediaSize), Vec<CommandId>>>>;158
+159
+pub struct Engine {160
+ client: Client,161
+ sync: Arc<SyncService>,162
+ port: DriverPort,163
+ rooms: HashMap<OwnedRoomId, RoomActorState>,164
+ next_room_generation: u64,165
+ room_actor_events: mpsc::UnboundedReceiver<RoomActorEvent>,166
+ room_actor_event_sender: mpsc::UnboundedSender<RoomActorEvent>,167
+ upload_watchers: UploadWatchers,168
+ tasks: TaskRegistry,169
+ session_changes: Option<tokio::sync::broadcast::Receiver<SessionChange>>,170
+ initial_history_limit: u16,171
+ room_command_capacity: usize,172
+ max_media_bytes: usize,173
+ media: Arc<Mutex<MediaRegistry>>,174
+ notification_commands: mpsc::Sender<CommandJob<NotificationCommand>>,175
+ notification_command_receiver: Option<mpsc::Receiver<CommandJob<NotificationCommand>>>,176
+ encryption_commands: mpsc::Sender<EncryptionWork>,177
+ encryption_command_receiver: Option<mpsc::Receiver<EncryptionWork>>,178
+ verification: Arc<Mutex<VerificationController>>,179
+ room_commands: mpsc::Sender<RoomJob>,180
+ room_command_receiver: Option<mpsc::Receiver<RoomJob>>,181
+ global_account_gates: HashMap<String, oneshot::Receiver<()>>,182
+ room_account_gates: HashMap<(OwnedRoomId, String), oneshot::Receiver<()>>,183
+ media_flights: MediaFlights,184
+}185
+186
+struct CommandJob<T> {187
+ id: CommandId,188
+ command: T,189
+}190
+191
+enum EncryptionWork {192
+ Command(CommandJob<EncryptionCommand>),193
+}194
+195
+struct RoomJob {196
+ id: CommandId,197
+ command: RoomCommand,198
+ reply: Option<oneshot::Sender<Result<OwnedRoomId, CommandResultError>>>,199
+}200
+201
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]202
+enum RoomMutationKey {203
+ Room(OwnedRoomId),204
+ Target(String),205
+ Direct(ruma::OwnedUserId),206
+ Independent(CommandId),207
+}208
+209
+struct RoomJobCompletion {210
+ key: RoomMutationKey,211
+ id: CommandId,212
+ reply: Option<oneshot::Sender<Result<OwnedRoomId, CommandResultError>>>,213
+ result: Result<Option<OwnedRoomId>, CommandError>,214
+}215
+216
+#[derive(Clone)]217
+enum TaskContext {218
+ Command {219
+ id: CommandId,220
+ kind: CommandKind,221
+ },222
+ Watcher(&'static str),223
+ RoomActor {224
+ room_id: OwnedRoomId,225
+ generation: u64,226
+ },227
+}228
+229
+impl TaskContext {230
+ fn label(&self) -> String {231
+ match self {232
+ Self::Command { id, kind } => format!("{kind:?} command {}", id.get()),233
+ Self::Watcher(name) => (*name).to_owned(),234
+ Self::RoomActor {235
+ room_id,236
+ generation,237
+ } => format!("room actor {room_id} generation {generation}"),238
+ }239
+ }240
+}241
+242
+struct TaskRegistry {243
+ tasks: JoinSet<()>,244
+ contexts: HashMap<TaskId, TaskContext>,245
+}246
+247
+impl TaskRegistry {248
+ fn new() -> Self {249
+ Self {250
+ tasks: JoinSet::new(),251
+ contexts: HashMap::new(),252
+ }253
+ }254
+255
+ fn is_empty(&self) -> bool {256
+ self.tasks.is_empty()257
+ }258
+259
+ fn spawn(260
+ &mut self,261
+ context: TaskContext,262
+ future: impl Future<Output = ()> + Send + 'static,263
+ ) -> AbortHandle {264
+ let handle = self.tasks.spawn(future);265
+ self.contexts.insert(handle.id(), context);266
+ handle267
+ }268
+269
+ async fn join_next(&mut self) -> Option<(TaskContext, Result<(), tokio::task::JoinError>)> {270
+ let result = self.tasks.join_next_with_id().await?;271
+ let id = match &result {272
+ Ok((id, ())) => *id,273
+ Err(error) => error.id(),274
+ };275
+ let context = self276
+ .contexts277
+ .remove(&id)278
+ .expect("every registered task has context");279
+ Some((context, result.map(|(_, ())| ())))280
+ }281
+282
+ async fn shutdown(&mut self) {283
+ self.tasks.abort_all();284
+ while self.tasks.join_next().await.is_some() {}285
+ self.contexts.clear();286
+ }287
+}288
+289
+enum RoomActorState {290
+ Opening {291
+ generation: u64,292
+ sender: mpsc::Sender<RoomActorRequest>,293
+ },294
+ Open {295
+ generation: u64,296
+ sender: mpsc::Sender<RoomActorRequest>,297
+ },298
+ Closing {299
+ generation: u64,300
+ pending: VecDeque<DeferredRoomLifecycle>,301
+ },302
+}303
+304
+enum DeferredRoomLifecycle {305
+ Open(CommandId),306
+ Close(CommandId),307
+}308
+309
+impl RoomActorState {310
+ const fn generation(&self) -> u64 {311
+ match self {312
+ Self::Opening { generation, .. }313
+ | Self::Open { generation, .. }314
+ | Self::Closing { generation, .. } => *generation,315
+ }316
+ }317
+318
+ fn sender(&self) -> Option<&mpsc::Sender<RoomActorRequest>> {319
+ match self {320
+ Self::Opening { sender, .. } | Self::Open { sender, .. } => Some(sender),321
+ Self::Closing { .. } => None,322
+ }323
+ }324
+}325
+326
+enum RoomActorEvent {327
+ Ready {328
+ room_id: OwnedRoomId,329
+ generation: u64,330
+ },331
+ Exited {332
+ room_id: OwnedRoomId,333
+ generation: u64,334
+ },335
+}336
+337
+enum EngineActivity {338
+ Request(Option<Box<DriverRequest>>),339
+ Task(Option<(TaskContext, Result<(), tokio::task::JoinError>)>),340
+ RoomActor(Option<RoomActorEvent>),341
+}342
+343
+struct MediaRegistry {344
+ entries: HashMap<MediaId, MediaRegistration>,345
+ order: VecDeque<MediaId>,346
+ limit: usize,347
+}348
+349
+impl MediaRegistry {350
+ fn new(limit: usize) -> Self {351
+ Self {352
+ entries: HashMap::new(),353
+ order: VecDeque::new(),354
+ limit,355
+ }356
+ }357
+358
+ fn insert_all(&mut self, media: Vec<MediaRegistration>) {359
+ for media in media {360
+ if !self.entries.contains_key(&media.id) {361
+ self.order.push_back(media.id.clone());362
+ }363
+ self.entries.insert(media.id.clone(), media);364
+ }365
+ while self.entries.len() > self.limit {366
+ if let Some(id) = self.order.pop_front() {367
+ self.entries.remove(&id);368
+ }369
+ }370
+ }371
+}372
+373
+enum RoomActorRequest {374
+ Opened {375
+ id: CommandId,376
+ },377
+ Execute {378
+ id: CommandId,379
+ command: TimelineCommand,380
+ },381
+ Close {382
+ id: CommandId,383
+ },384
+}385
+386
+struct RoomActor {387
+ room_id: OwnedRoomId,388
+ generation: u64,389
+ room: Room,390
+ receiver: mpsc::Receiver<RoomActorRequest>,391
+ events: EventSender,392
+ engine_events: mpsc::UnboundedSender<RoomActorEvent>,393
+ media: Arc<Mutex<MediaRegistry>>,394
+ initial_history_limit: u16,395
+ open_ids: Vec<CommandId>,396
+}397
+398
+struct TimelineSession {399
+ id: TimelineId,400
+ timeline: Arc<Timeline>,401
+ events: EventSender,402
+ watcher: JoinHandle<()>,403
+ media: Arc<Mutex<MediaRegistry>>,404
+ current_user_id: Option<ruma::OwnedUserId>,405
+ scope: MediaScope,406
+ pagination_ids: Arc<Mutex<HashSet<CommandId>>>,407
+ pagination_sender: mpsc::Sender<PaginationRequest>,408
+ pagination_worker: JoinHandle<()>,409
+ pagination_results: Option<mpsc::UnboundedSender<(CommandId, Result<(), CommandError>)>>,410
+ pagination_result_task: JoinHandle<()>,411
+ reached_start: Arc<AtomicBool>,412
+}413
+414
+struct PaginationRequest {415
+ timeline: Arc<Timeline>,416
+ events: EventSender,417
+ timeline_id: TimelineId,418
+ reached_start: Arc<AtomicBool>,419
+ limit: u16,420
+ command_id: Option<CommandId>,421
+ results: mpsc::UnboundedSender<(CommandId, Result<(), CommandError>)>,422
+}423
+424
+impl TimelineSession {425
+ async fn start(426
+ id: TimelineId,427
+ timeline: Timeline,428
+ events: EventSender,429
+ media: Arc<Mutex<MediaRegistry>>,430
+ current_user_id: Option<ruma::OwnedUserId>,431
+ initial_history_limit: u16,432
+ ) -> Result<Self, SendEventError> {433
+ let timeline = Arc::new(timeline);434
+ let scope = MediaScope::new();435
+ let watcher = start_timeline_watcher(436
+ id.clone(),437
+ timeline.clone(),438
+ events.clone(),439
+ media.clone(),440
+ current_user_id.clone(),441
+ scope.clone(),442
+ )443
+ .await?;444
+445
+ let (pagination_sender, mut pagination_receiver) = mpsc::channel::<PaginationRequest>(32);446
+ let pagination_worker = tokio::spawn(async move {447
+ while let Some(request) = pagination_receiver.recv().await {448
+ let command_id = request.command_id;449
+ let results = request.results.clone();450
+ if AssertUnwindSafe(run_pagination(request))451
+ .catch_unwind()452
+ .await453
+ .is_err()454
+ {455
+ tracing::warn!(?command_id, "pagination task panicked");456
+ if let Some(id) = command_id {457
+ let _ = results.send((458
+ id,459
+ Err(CommandError::new(460
+ CommandErrorKind::Timeline,461
+ "pagination failed",462
+ )),463
+ ));464
+ }465
+ }466
+ }467
+ });468
+ let pagination_ids = Arc::new(Mutex::new(HashSet::new()));469
+ let (pagination_results, mut result_receiver) = mpsc::unbounded_channel();470
+ let result_events = events.clone();471
+ let result_ids = pagination_ids.clone();472
+ let pagination_result_task = tokio::spawn(async move {473
+ while let Some((id, result)) = result_receiver.recv().await {474
+ if result_ids.lock().await.remove(&id) {475
+ let _ = emit_command_result(&result_events, id, CommandKind::Timeline, result)476
+ .await;477
+ }478
+ }479
+ });480
+481
+ let mut session = Self {482
+ id,483
+ timeline,484
+ events,485
+ watcher,486
+ media,487
+ current_user_id,488
+ scope,489
+ pagination_sender,490
+ pagination_worker,491
+ pagination_ids,492
+ pagination_results: Some(pagination_results),493
+ pagination_result_task,494
+ reached_start: Arc::new(AtomicBool::new(false)),495
+ };496
+ if initial_history_limit > 0 {497
+ session.paginate(initial_history_limit, None).await;498
+ }499
+ Ok(session)500
+ }501
+502
+ async fn resync(&mut self) -> Result<(), SendEventError> {503
+ self.watcher.abort();504
+ let _ = (&mut self.watcher).await;505
+ self.watcher = start_timeline_watcher(506
+ self.id.clone(),507
+ self.timeline.clone(),508
+ self.events.clone(),509
+ self.media.clone(),510
+ self.current_user_id.clone(),511
+ self.scope.clone(),512
+ )513
+ .await?;514
+ Ok(())515
+ }516
+517
+ async fn paginate(&mut self, limit: u16, command_id: Option<CommandId>) {518
+ if let Some(id) = command_id {519
+ self.pagination_ids.lock().await.insert(id);520
+ }521
+ let request = PaginationRequest {522
+ timeline: self.timeline.clone(),523
+ events: self.events.clone(),524
+ timeline_id: self.id.clone(),525
+ reached_start: self.reached_start.clone(),526
+ limit,527
+ command_id,528
+ results: self529
+ .pagination_results530
+ .as_ref()531
+ .expect("pagination session is open")532
+ .clone(),533
+ };534
+ if let Err(error) = self.pagination_sender.try_send(request) {535
+ let kind = match error {536
+ mpsc::error::TrySendError::Full(_) => CommandErrorKind::QueueFull,537
+ mpsc::error::TrySendError::Closed(_) => CommandErrorKind::TimelineNotOpen,538
+ };539
+ if let Some(id) = command_id {540
+ self.pagination_ids.lock().await.remove(&id);541
+ let _ = emit_command_result(542
+ &self.events,543
+ id,544
+ CommandKind::Timeline,545
+ Err(CommandError::new(kind, "pagination queue is unavailable")),546
+ )547
+ .await;548
+ }549
+ }550
+ }551
+552
+ async fn close(mut self, kind: CommandErrorKind, message: &'static str) {553
+ self.pagination_worker.abort();554
+ let _ = (&mut self.pagination_worker).await;555
+ drop(self.pagination_results.take());556
+ let _ = (&mut self.pagination_result_task).await;557
+ let pending = self.pagination_ids.lock().await.drain().collect::<Vec<_>>();558
+ for id in pending {559
+ let _ = emit_command_result(560
+ &self.events,561
+ id,562
+ CommandKind::Timeline,563
+ Err(CommandError::new(kind, message)),564
+ )565
+ .await;566
+ }567
+ self.watcher.abort();568
+ let _ = (&mut self.watcher).await;569
+ }570
+}571
+572
+async fn start_timeline_watcher(573
+ id: TimelineId,574
+ timeline: Arc<Timeline>,575
+ events: EventSender,576
+ media: Arc<Mutex<MediaRegistry>>,577
+ current_user_id: Option<ruma::OwnedUserId>,578
+ scope: MediaScope,579
+) -> Result<JoinHandle<()>, SendEventError> {580
+ let (items, mut stream) = timeline.subscribe().await;581
+ let normalised = normalise_timeline(items.iter(), current_user_id.as_deref(), &scope);582
+ media.lock().await.insert_all(normalised.media);583
+ events584
+ .send(Event::TimelineReset {585
+ timeline_id: id.clone(),586
+ items: normalised.items,587
+ })588
+ .await?;589
+ Ok(tokio::spawn(async move {590
+ while let Some(diffs) = stream.next().await {591
+ let normalised = normalise_timeline_diffs(diffs, current_user_id.as_deref(), &scope);592
+ media.lock().await.insert_all(normalised.media);593
+ if events594
+ .send(Event::TimelineChanged {595
+ timeline_id: id.clone(),596
+ diffs: normalised.diffs,597
+ })598
+ .await599
+ .is_err()600
+ {601
+ tracing::debug!(602
+ ?id,603
+ "timeline watcher stopped because the event receiver closed"604
+ );605
+ return;606
+ }607
+ }608
+ tracing::debug!(?id, "timeline watcher stopped");609
+ }))610
+}611
+612
+impl Drop for TimelineSession {613
+ fn drop(&mut self) {614
+ self.watcher.abort();615
+ self.pagination_worker.abort();616
+ self.pagination_result_task.abort();617
+ }618
+}619
+620
+impl Engine {621
+ pub async fn from_client(622
+ client: Client,623
+ config: EngineConfig,624
+ ) -> Result<(Self, ClientHandle, EventStream), EngineError> {625
+ if client.user_id().is_none() {626
+ return Err(EngineError::Unauthenticated);627
+ }628
+ if config.command_capacity == 0629
+ || config.event_capacity == 0630
+ || config.max_media_sources == 0631
+ {632
+ return Err(EngineError::ZeroCapacity);633
+ }634
+ let session_changes = client.subscribe_to_session_changes();635
+ let sync = Arc::new(636
+ SyncService::builder(client.clone())637
+ .with_offline_mode()638
+ .build()639
+ .await640
+ .map_err(EngineError::SyncService)?,641
+ );642
+ let (handle, port, events) = transport_pair(643
+ client.clone(),644
+ sync.clone(),645
+ config.command_capacity,646
+ config.event_capacity,647
+ );648
+ client.send_queue().enable_upload_progress(true);649
+ let (room_actor_event_sender, room_actor_events) = mpsc::unbounded_channel();650
+ let (notification_commands, notification_command_receiver) =651
+ mpsc::channel(config.command_capacity);652
+ let (encryption_commands, encryption_command_receiver) =653
+ mpsc::channel(config.command_capacity);654
+ let (room_commands, room_command_receiver) = mpsc::channel(config.command_capacity);655
+ Ok((656
+ Self {657
+ client,658
+ sync,659
+ port,660
+ rooms: HashMap::new(),661
+ next_room_generation: 0,662
+ room_actor_events,663
+ room_actor_event_sender,664
+ upload_watchers: HashMap::new(),665
+ tasks: TaskRegistry::new(),666
+ session_changes: Some(session_changes),667
+ initial_history_limit: config.initial_history_limit,668
+ room_command_capacity: config.command_capacity,669
+ max_media_bytes: config.max_media_bytes,670
+ media: Arc::new(Mutex::new(MediaRegistry::new(config.max_media_sources))),671
+ notification_commands,672
+ notification_command_receiver: Some(notification_command_receiver),673
+ encryption_commands,674
+ encryption_command_receiver: Some(encryption_command_receiver),675
+ verification: Arc::new(Mutex::new(VerificationController::default())),676
+ room_commands,677
+ room_command_receiver: Some(room_command_receiver),678
+ global_account_gates: HashMap::new(),679
+ room_account_gates: HashMap::new(),680
+ media_flights: Arc::new(Mutex::new(HashMap::new())),681
+ },682
+ handle,683
+ events,684
+ ))685
+ }686
+687
+ pub async fn run(mut self) -> Result<(), EngineError> {688
+ let sender = self.port.event_sender();689
+ let user_id = self690
+ .client691
+ .user_id()692
+ .expect("authentication was checked during construction")693
+ .to_owned();694
+ if sender.send(Event::Authenticated { user_id }).await.is_err() {695
+ self.shutdown().await;696
+ return Err(EngineError::EventReceiverClosed);697
+ }698
+ self.port699
+ .connection_sender()700
+ .send_replace(ConnectionState::Connecting);701
+ if sender702
+ .send(Event::ConnectionChanged(ConnectionState::Connecting))703
+ .await704
+ .is_err()705
+ {706
+ self.shutdown().await;707
+ return Err(EngineError::EventReceiverClosed);708
+ }709
+ self.start_watchers();710
+ self.start_domain_workers();711
+ self.sync.start().await;712
+ let mut terminal_error = None;713
+714
+ loop {715
+ let activity = tokio::select! {716
+ biased;717
+ _ = sender.closed() => {718
+ terminal_error = Some(EngineError::EventReceiverClosed);719
+ break;720
+ }721
+ request = self.port.recv() => EngineActivity::Request(request.map(Box::new)),722
+ completed = self.tasks.join_next(), if !self.tasks.is_empty() => {723
+ EngineActivity::Task(completed)724
+ }725
+ event = self.room_actor_events.recv() => EngineActivity::RoomActor(event),726
+ };727
+ match activity {728
+ EngineActivity::Request(Some(request)) => {729
+ let Some(envelope) = self.dispatch_request(*request).await else {730
+ continue;731
+ };732
+ let id = envelope.id;733
+ let command = envelope.command;734
+ if matches!(command, Command::Lifecycle(LifecycleCommand::Shutdown)) {735
+ let _ =736
+ emit_command_result(&sender, id, CommandKind::Lifecycle, Ok(())).await;737
+ break;738
+ }739
+ let kind = command.kind();740
+ tracing::debug!(?id, ?kind, "dispatching command");741
+ let result = self.execute(id, command).await;742
+ if Self::emit_sync_result(&sender, id, kind, result)743
+ .await744
+ .is_err()745
+ {746
+ terminal_error = Some(EngineError::EventReceiverClosed);747
+ break;748
+ }749
+ }750
+ EngineActivity::Request(None) => break,751
+ EngineActivity::Task(Some((context, result))) => {752
+ if let Err(error) = result753
+ && error.is_panic()754
+ {755
+ match context {756
+ TaskContext::Command { id, kind } => {757
+ if sender758
+ .send(Event::CommandFailed(CommandFailure {759
+ id,760
+ command: kind,761
+ kind: CommandErrorKind::Other,762
+ message: "command task panicked".to_owned(),763
+ }))764
+ .await765
+ .is_err()766
+ {767
+ terminal_error = Some(EngineError::EventReceiverClosed);768
+ break;769
+ }770
+ }771
+ context => {772
+ terminal_error = Some(EngineError::TaskPanicked {773
+ task: context.label(),774
+ });775
+ break;776
+ }777
+ }778
+ }779
+ }780
+ EngineActivity::Task(None) => {}781
+ EngineActivity::RoomActor(Some(event)) => {782
+ if let Err(error) = self.handle_room_actor_event(event).await {783
+ terminal_error = Some(error);784
+ break;785
+ }786
+ }787
+ EngineActivity::RoomActor(None) => {788
+ terminal_error = Some(EngineError::TaskPanicked {789
+ task: "room actor event channel".to_owned(),790
+ });791
+ break;792
+ }793
+ }794
+ }795
+796
+ if let Some(error) = terminal_error.as_ref() {797
+ let _ = sender.send(Event::Failed(error.failure())).await;798
+ }799
+ self.shutdown().await;800
+ let _ = sender.send(Event::Stopped).await;801
+ if let Some(error) = terminal_error {802
+ Err(error)803
+ } else {804
+ Ok(())805
+ }806
+ }807
+808
+ async fn emit_sync_result(809
+ sender: &EventSender,810
+ id: CommandId,811
+ kind: CommandKind,812
+ result: Result<bool, CommandError>,813
+ ) -> Result<(), SendEventError> {814
+ match result {815
+ Ok(true) => Ok(()),816
+ Ok(false) => emit_command_result(sender, id, kind, Ok(())).await,817
+ Err(error) => {818
+ tracing::warn!(?id, ?kind, error = ?error, "command failed");819
+ emit_command_result(sender, id, kind, Err(error)).await?;820
+ Ok(())821
+ }822
+ }823
+ }824
+825
+ fn spawn_logout(&mut self, id: CommandId) {826
+ let events = self.port.event_sender();827
+ let client = self.client.clone();828
+ self.tasks.spawn(829
+ TaskContext::Command {830
+ id,831
+ kind: CommandKind::Account,832
+ },833
+ async move {834
+ match client835
+ .logout()836
+ .await837
+ .map(|_| ())838
+ .map_err(|source| CommandError::from_sdk("logout failed", source))839
+ {840
+ Ok(()) => {841
+ let _ =842
+ emit_command_result(&events, id, CommandKind::Account, Ok(())).await;843
+ let _ = events.send(Event::LoggedOut).await;844
+ }845
+ Err(error) => {846
+ let _ = emit_command_result(&events, id, CommandKind::Account, Err(error))847
+ .await;848
+ }849
+ }850
+ },851
+ );852
+ }853
+854
+ async fn dispatch_request(&mut self, request: DriverRequest) -> Option<CommandEnvelope> {855
+ match request {856
+ DriverRequest::Command(command) => Some(command),857
+ DriverRequest::DeleteDevices {858
+ id,859
+ device_ids,860
+ auth,861
+ reply,862
+ } => {863
+ self.spawn_delete_devices(id, device_ids, auth, Some(reply));864
+ None865
+ }866
+ DriverRequest::RoomResult { id, command, reply } => {867
+ if let Err(error) = self.spawn_room_command_with_reply(id, command, Some(reply)) {868
+ tracing::warn!(?id, error = ?error, "room result request failed");869
+ }870
+ None871
+ }872
+ DriverRequest::SetGlobalAccountData {873
+ event_type,874
+ content,875
+ reply,876
+ } => {877
+ self.spawn_global_account_data_write(event_type, content, reply);878
+ None879
+ }880
+ DriverRequest::SetRoomAccountData {881
+ room_id,882
+ event_type,883
+ content,884
+ reply,885
+ } => {886
+ self.spawn_room_account_data_write(room_id, event_type, content, reply);887
+ None888
+ }889
+ }890
+ }891
+892
+ fn spawn_mark_read(&mut self, id: CommandId, timeline_id: TimelineId, event_id: OwnedEventId) {893
+ let events = self.port.event_sender();894
+ let client = self.client.clone();895
+ self.tasks.spawn(896
+ TaskContext::Command {897
+ id,898
+ kind: CommandKind::Room,899
+ },900
+ async move {901
+ let result = mark_read(&client, &timeline_id, event_id).await;902
+ let _ = emit_command_result(&events, id, CommandKind::Room, result).await;903
+ },904
+ );905
+ }906
+907
+ fn spawn_typing(&mut self, id: CommandId, room_id: OwnedRoomId, typing: bool) {908
+ let events = self.port.event_sender();909
+ let client = self.client.clone();910
+ self.tasks.spawn(911
+ TaskContext::Command {912
+ id,913
+ kind: CommandKind::Room,914
+ },915
+ async move {916
+ let result = set_typing(&client, &room_id, typing).await;917
+ let _ = emit_command_result(&events, id, CommandKind::Room, result).await;918
+ },919
+ );920
+ }921
+922
+ fn spawn_cancel_send(923
+ &mut self,924
+ id: CommandId,925
+ room_id: OwnedRoomId,926
+ transaction_id: matrix_sdk::ruma::OwnedTransactionId,927
+ ) {928
+ let events = self.port.event_sender();929
+ let client = self.client.clone();930
+ self.tasks.spawn(931
+ TaskContext::Command {932
+ id,933
+ kind: CommandKind::Room,934
+ },935
+ async move {936
+ let result = cancel_send(&client, &room_id, &transaction_id).await;937
+ let _ = emit_command_result(&events, id, CommandKind::Room, result).await;938
+ },939
+ );940
+ }941
+942
+ fn spawn_retry_send(943
+ &mut self,944
+ id: CommandId,945
+ room_id: OwnedRoomId,946
+ transaction_id: matrix_sdk::ruma::OwnedTransactionId,947
+ ) {948
+ let events = self.port.event_sender();949
+ let client = self.client.clone();950
+ self.tasks.spawn(951
+ TaskContext::Command {952
+ id,953
+ kind: CommandKind::Room,954
+ },955
+ async move {956
+ let result = retry_send(&client, &room_id, &transaction_id).await;957
+ let _ = emit_command_result(&events, id, CommandKind::Room, result).await;958
+ },959
+ );960
+ }961
+962
+ fn spawn_delete_devices(963
+ &mut self,964
+ id: CommandId,965
+ device_ids: Vec<OwnedDeviceId>,966
+ auth: Option<InteractiveAuth>,967
+ reply: Option<oneshot::Sender<Result<InteractiveAuthOutcome, CommandResultError>>>,968
+ ) {969
+ let events = self.port.event_sender();970
+ let client = self.client.clone();971
+ self.tasks.spawn(972
+ TaskContext::Command {973
+ id,974
+ kind: CommandKind::Account,975
+ },976
+ async move {977
+ let auth = match auth {978
+ Some(auth) => match auth.into_sdk(979
+ client980
+ .user_id()981
+ .expect("authentication was checked during construction"),982
+ ) {983
+ Ok(auth) => Some(auth),984
+ Err(message) => {985
+ let failure = CommandFailure {986
+ id,987
+ command: CommandKind::Account,988
+ kind: CommandErrorKind::Other,989
+ message: format!(990
+ "interactive authentication data is invalid, {message}"991
+ ),992
+ };993
+ if let Some(reply) = reply {994
+ let _ =995
+ reply.send(Err(CommandResultError::Failed(failure.clone())));996
+ } else {997
+ let _ = events.send(Event::CommandFailed(failure)).await;998
+ }999
+ return;1000
+ }1001
+ },1002
+ None => None,1003
+ };1004
+ let result = client.delete_devices(&device_ids, auth).await;1005
+1006
+ match result {1007
+ Ok(_) => {1008
+ if let Some(reply) = reply {1009
+ let _ = reply.send(Ok(InteractiveAuthOutcome::Completed));1010
+ } else {1011
+ let _ = emit_command_result(&events, id, CommandKind::Account, Ok(()))1012
+ .await;1013
+ }1014
+ }1015
+ Err(source) => {1016
+ if let Some(info) = source.as_uiaa_response() {1017
+ let challenge = InteractiveAuthChallenge::from(info);1018
+ if let Some(reply) = reply {1019
+ let _ =1020
+ reply.send(Ok(InteractiveAuthOutcome::Challenge(challenge)));1021
+ } else {1022
+ let _ = events1023
+ .send(Event::InteractiveAuthRequired {1024
+ id,1025
+ command: CommandKind::Account,1026
+ challenge: challenge.clone(),1027
+ })1028
+ .await;1029
+ let _ = events1030
+ .send(Event::CommandFailed(CommandFailure {1031
+ id,1032
+ command: CommandKind::Account,1033
+ kind: CommandErrorKind::InteractiveAuthRequired,1034
+ message: "interactive authentication is required"1035
+ .to_owned(),1036
+ }))1037
+ .await;1038
+ }1039
+ } else {1040
+ let error = CommandError::from_http("deleting devices failed", source);1041
+ let failure = CommandFailure {1042
+ id,1043
+ command: CommandKind::Account,1044
+ kind: error.kind,1045
+ message: error.to_string(),1046
+ };1047
+ if let Some(reply) = reply {1048
+ let _ =1049
+ reply.send(Err(CommandResultError::Failed(failure.clone())));1050
+ } else {1051
+ let _ = events.send(Event::CommandFailed(failure)).await;1052
+ }1053
+ }1054
+ }1055
+ }1056
+ },1057
+ );1058
+ }1059
+1060
+ fn spawn_rename_device(1061
+ &mut self,1062
+ id: CommandId,1063
+ device_id: OwnedDeviceId,1064
+ display_name: String,1065
+ ) {1066
+ let events = self.port.event_sender();1067
+ let client = self.client.clone();1068
+ self.tasks.spawn(1069
+ TaskContext::Command {1070
+ id,1071
+ kind: CommandKind::Account,1072
+ },1073
+ async move {1074
+ match client.rename_device(&device_id, &display_name).await {1075
+ Ok(_) => {1076
+ let _ = events1077
+ .send(Event::CommandSucceeded {1078
+ id,1079
+ command: CommandKind::Account,1080
+ })1081
+ .await;1082
+ }1083
+ Err(source) => {1084
+ let error = CommandError::from_http("renaming device failed", source);1085
+ let _ = events1086
+ .send(Event::CommandFailed(CommandFailure {1087
+ id,1088
+ command: CommandKind::Account,1089
+ kind: error.kind,1090
+ message: error.to_string(),1091
+ }))1092
+ .await;1093
+ }1094
+ }1095
+ },1096
+ );1097
+ }1098
+1099
+ fn spawn_global_account_data_write(1100
+ &mut self,1101
+ event_type: GlobalAccountDataEventType,1102
+ content: Raw<AnyGlobalAccountDataEventContent>,1103
+ reply: oneshot::Sender<Result<(), ClientQueryError>>,1104
+ ) {1105
+ let (done, next) = oneshot::channel();1106
+ let previous = self1107
+ .global_account_gates1108
+ .insert(event_type.to_string(), next);1109
+ let client = self.client.clone();1110
+ self.tasks.spawn(1111
+ TaskContext::Watcher("global account data writer"),1112
+ async move {1113
+ if let Some(previous) = previous {1114
+ let _ = previous.await;1115
+ }1116
+ let result = client1117
+ .account()1118
+ .set_account_data_raw(event_type, content)1119
+ .await1120
+ .map(|_| ())1121
+ .map_err(ClientQueryError::Sdk);1122
+ let _ = reply.send(result);1123
+ let _ = done.send(());1124
+ },1125
+ );1126
+ }1127
+1128
+ fn spawn_room_account_data_write(1129
+ &mut self,1130
+ room_id: OwnedRoomId,1131
+ event_type: RoomAccountDataEventType,1132
+ content: Raw<matrix_sdk::ruma::events::AnyRoomAccountDataEventContent>,1133
+ reply: oneshot::Sender<Result<(), ClientQueryError>>,1134
+ ) {1135
+ let (done, next) = oneshot::channel();1136
+ let previous = self1137
+ .room_account_gates1138
+ .insert((room_id.clone(), event_type.to_string()), next);1139
+ let client = self.client.clone();1140
+ self.tasks.spawn(1141
+ TaskContext::Watcher("room account data writer"),1142
+ async move {1143
+ if let Some(previous) = previous {1144
+ let _ = previous.await;1145
+ }1146
+ let result = match client.get_room(&room_id) {1147
+ Some(room) => room1148
+ .set_account_data_raw(event_type, content)1149
+ .await1150
+ .map(|_| ())1151
+ .map_err(ClientQueryError::Sdk),1152
+ None => Err(ClientQueryError::RoomUnknown(room_id)),1153
+ };1154
+ let _ = reply.send(result);1155
+ let _ = done.send(());1156
+ },1157
+ );1158
+ }1159
+1160
+ fn start_domain_workers(&mut self) {1161
+ let mut notification_commands = self1162
+ .notification_command_receiver1163
+ .take()1164
+ .expect("notification worker has not started");1165
+ let notification_client = self.client.clone();1166
+ let notification_events = self.port.event_sender();1167
+ self.tasks.spawn(1168
+ TaskContext::Watcher("notification command worker"),1169
+ async move {1170
+ while let Some(CommandJob { id, command }) = notification_commands.recv().await {1171
+ let result = AssertUnwindSafe(execute_notification_command(1172
+ ¬ification_client,1173
+ command,1174
+ ))1175
+ .catch_unwind()1176
+ .await1177
+ .unwrap_or_else(|_| {1178
+ Err(CommandError::new(1179
+ CommandErrorKind::Other,1180
+ "notification command panicked",1181
+ ))1182
+ });1183
+ let _ = emit_command_result(1184
+ ¬ification_events,1185
+ id,1186
+ CommandKind::Notification,1187
+ result,1188
+ )1189
+ .await;1190
+ }1191
+ },1192
+ );1193
+1194
+ let mut encryption_commands = self1195
+ .encryption_command_receiver1196
+ .take()1197
+ .expect("encryption worker has not started");1198
+ let encryption_client = self.client.clone();1199
+ let encryption_events = self.port.event_sender();1200
+ let verification = self.verification.clone();1201
+ self.tasks.spawn(1202
+ TaskContext::Watcher("encryption command worker"),1203
+ async move {1204
+ while let Some(work) = encryption_commands.recv().await {1205
+ match work {1206
+ EncryptionWork::Command(CommandJob { id, command }) => {1207
+ if matches!(1208
+ command,1209
+ EncryptionCommand::Recover(_)1210
+ | EncryptionCommand::RecoverKeyBackup(_)1211
+ ) {1212
+ let result = AssertUnwindSafe(execute_encryption_command(1213
+ &encryption_client,1214
+ &encryption_events,1215
+ &mut VerificationController::default(),1216
+ command,1217
+ ))1218
+ .catch_unwind()1219
+ .await1220
+ .unwrap_or_else(|_| {1221
+ Err(CommandError::new(1222
+ CommandErrorKind::Other,1223
+ "encryption command panicked",1224
+ ))1225
+ });1226
+ let _ = emit_command_result(1227
+ &encryption_events,1228
+ id,1229
+ CommandKind::Encryption,1230
+ result,1231
+ )1232
+ .await;1233
+ continue;1234
+ }1235
+ let is_key_backup = matches!(1236
+ command,1237
+ EncryptionCommand::CreateKeyBackup1238
+ | EncryptionCommand::EnableKeyBackup1239
+ | EncryptionCommand::DisableKeyBackup { .. }1240
+ );1241
+ let result = if is_key_backup {1242
+ AssertUnwindSafe(execute_key_backup_command(1243
+ &encryption_client,1244
+ command,1245
+ ))1246
+ .catch_unwind()1247
+ .await1248
+ .unwrap_or_else(|_| {1249
+ Err(CommandError::new(1250
+ CommandErrorKind::Other,1251
+ "encryption command panicked",1252
+ ))1253
+ })1254
+ } else {1255
+ let mut verification = verification.lock().await;1256
+ let result = AssertUnwindSafe(execute_encryption_command(1257
+ &encryption_client,1258
+ &encryption_events,1259
+ &mut verification,1260
+ command,1261
+ ))1262
+ .catch_unwind()1263
+ .await1264
+ .unwrap_or_else(|_| {1265
+ Err(CommandError::new(1266
+ CommandErrorKind::Other,1267
+ "encryption command panicked",1268
+ ))1269
+ });1270
+ drop(verification);1271
+ result1272
+ };1273
+ let _ = emit_command_result(1274
+ &encryption_events,1275
+ id,1276
+ CommandKind::Encryption,1277
+ result,1278
+ )1279
+ .await;1280
+ }1281
+ }1282
+ }1283
+ },1284
+ );1285
+1286
+ let room_commands = self1287
+ .room_command_receiver1288
+ .take()1289
+ .expect("room command worker has not started");1290
+ let room_client = self.client.clone();1291
+ let room_events = self.port.event_sender();1292
+ self.tasks.spawn(1293
+ TaskContext::Watcher("room command worker"),1294
+ run_room_command_worker(1295
+ room_client,1296
+ room_events,1297
+ room_commands,1298
+ self.room_command_capacity,1299
+ ),1300
+ );1301
+ }1302
+1303
+ fn start_watchers(&mut self) {1304
+ let mut session_changes = self1305
+ .session_changes1306
+ .take()1307
+ .expect("session watcher has not started");1308
+ let session_sender = self.port.event_sender();1309
+ self.tasks1310
+ .spawn(TaskContext::Watcher("session change watcher"), async move {1311
+ loop {1312
+ let event = match session_changes.recv().await {1313
+ Ok(SessionChange::TokensRefreshed) => Event::SessionTokensChanged,1314
+ Ok(SessionChange::UnknownToken(_)) => Event::SessionAccessRejected,1315
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {1316
+ Event::SessionTokensChanged1317
+ }1318
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => break,1319
+ };1320
+ if session_sender.send(event).await.is_err() {1321
+ break;1322
+ }1323
+ }1324
+ });1325
+1326
+ let mut sync_states = self.sync.state();1327
+ let state_sender = self.port.event_sender();1328
+ let connection_sender = self.port.connection_sender();1329
+ self.tasks.spawn(1330
+ TaskContext::Watcher("connection state watcher"),1331
+ async move {1332
+ while let Some(state) = sync_states.next().await {1333
+ let state = match state {1334
+ SyncState::Idle => ConnectionState::Idle,1335
+ SyncState::Running => ConnectionState::Online,1336
+ SyncState::Offline => ConnectionState::Offline,1337
+ SyncState::Terminated => ConnectionState::Stopped,1338
+ SyncState::Error(error) => {1339
+ tracing::warn!(error = %error, "sync service reported an error");1340
+ ConnectionState::Offline1341
+ }1342
+ };1343
+ connection_sender.send_replace(state);1344
+ if state_sender1345
+ .send(Event::ConnectionChanged(state))1346
+ .await1347
+ .is_err()1348
+ {1349
+ tracing::debug!(1350
+ "connection watcher stopped because the event receiver closed"1351
+ );1352
+ break;1353
+ }1354
+ }1355
+ tracing::debug!("connection watcher stopped because the sync state stream ended");1356
+ },1357
+ );1358
+1359
+ let client = self.client.clone();1360
+ let encryption_sender = self.port.event_sender();1361
+ self.tasks.spawn(1362
+ TaskContext::Watcher("encryption state watcher"),1363
+ async move {1364
+ let Some(device_id) = client.device_id().map(ToOwned::to_owned) else {1365
+ tracing::warn!("encryption watcher stopped because the client has no device id");1366
+ return;1367
+ };1368
+ let recovery = client.encryption().recovery();1369
+ let backups = client.encryption().backups();1370
+ let mut recovery_stream = recovery.state_stream();1371
+ let mut backup_stream = backups.state_stream();1372
+ let mut verification_stream = client.encryption().verification_state();1373
+ let mut recovery_state = recovery.state();1374
+ let mut backup_state = backups.state();1375
+ let mut verification_state = verification_stream.get();1376
+ if encryption_sender1377
+ .send(Event::EncryptionChanged(encryption_state(1378
+ device_id.clone(),1379
+ recovery_state,1380
+ verification_state,1381
+ backup_state,1382
+ )))1383
+ .await1384
+ .is_err()1385
+ {1386
+ tracing::debug!(1387
+ "encryption watcher stopped because the event receiver closed"1388
+ );1389
+ return;1390
+ }1391
+ loop {1392
+ tokio::select! {1393
+ update = recovery_stream.next() => {1394
+ let Some(update) = update else {1395
+ tracing::debug!("encryption watcher stopped because the recovery stream ended");1396
+ break;1397
+ };1398
+ recovery_state = update;1399
+ }1400
+ update = verification_stream.next() => {1401
+ let Some(update) = update else {1402
+ tracing::debug!("encryption watcher stopped because the verification stream ended");1403
+ break;1404
+ };1405
+ verification_state = update;1406
+ }1407
+ update = backup_stream.next() => {1408
+ let Some(update) = update else {1409
+ tracing::debug!("backup watcher stopped because the backup state stream ended");1410
+ break;1411
+ };1412
+ backup_state = update.unwrap_or_else(|_| backups.state());1413
+ }1414
+ }1415
+ if encryption_sender1416
+ .send(Event::EncryptionChanged(encryption_state(1417
+ device_id.clone(),1418
+ recovery_state,1419
+ verification_state,1420
+ backup_state,1421
+ )))1422
+ .await1423
+ .is_err()1424
+ {1425
+ tracing::debug!(1426
+ "encryption watcher stopped because the event receiver closed"1427
+ );1428
+ break;1429
+ }1430
+ }1431
+ },1432
+ );1433
+1434
+ let client = self.client.clone();1435
+ let room_sender = self.port.event_sender();1436
+ self.tasks.spawn(1437
+ TaskContext::Watcher("room list watcher"),1438
+ async move {1439
+ let (_, mut room_stream) = client.rooms_stream();1440
+ let mut info_stream = client.room_info_notable_update_receiver();1441
+ if room_sender.send(Event::RoomsChanged).await.is_err() {1442
+ tracing::debug!("room watcher stopped because the event receiver closed");1443
+ return;1444
+ }1445
+ loop {1446
+ tokio::select! {1447
+ update = room_stream.next() => {1448
+ if update.is_none() {1449
+ tracing::debug!("room watcher stopped because the room stream ended");1450
+ break;1451
+ }1452
+ }1453
+ update = info_stream.recv() => {1454
+ if update.is_err() && info_stream.is_closed() {1455
+ tracing::debug!("room watcher stopped because the room info stream ended");1456
+ break;1457
+ }1458
+ }1459
+ }1460
+ if room_sender.send(Event::RoomsChanged).await.is_err() {1461
+ tracing::debug!("room watcher stopped because the event receiver closed");1462
+ break;1463
+ }1464
+ }1465
+ },1466
+ );1467
+1468
+ let client = self.client.clone();1469
+ let notification_sender = self.port.event_sender();1470
+ self.tasks.spawn(1471
+ TaskContext::Watcher("notification settings watcher"),1472
+ async move {1473
+ let settings = client.notification_settings().await;1474
+ let mut changes = settings.subscribe_to_changes();1475
+ while let Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) =1476
+ changes.recv().await1477
+ {1478
+ if notification_sender1479
+ .send(Event::NotificationSettingsChanged)1480
+ .await1481
+ .is_err()1482
+ {1483
+ break;1484
+ }1485
+ }1486
+ },1487
+ );1488
+ }1489
+1490
+ async fn execute(&mut self, id: CommandId, command: Command) -> Result<bool, CommandError> {1491
+ match command {1492
+ Command::Account(AccountCommand::Logout) => {1493
+ self.spawn_logout(id);1494
+ Ok(true)1495
+ }1496
+ Command::Account(AccountCommand::RenameDevice {1497
+ device_id,1498
+ display_name,1499
+ }) => {1500
+ self.spawn_rename_device(id, device_id, display_name);1501
+ Ok(true)1502
+ }1503
+ Command::Account(AccountCommand::DeleteDevices { device_ids, auth }) => {1504
+ self.spawn_delete_devices(id, device_ids, auth, None);1505
+ Ok(true)1506
+ }1507
+ Command::Room(RoomCommand::Open(room_id)) => self.open_room(id, room_id).await,1508
+ Command::Room(RoomCommand::Close(room_id)) => self.close_room(id, &room_id).await,1509
+ Command::Room(RoomCommand::MarkRead {1510
+ timeline_id,1511
+ event_id,1512
+ }) => {1513
+ self.spawn_mark_read(id, timeline_id, event_id);1514
+ Ok(true)1515
+ }1516
+ Command::Room(RoomCommand::SetTyping { room_id, typing }) => {1517
+ self.spawn_typing(id, room_id, typing);1518
+ Ok(true)1519
+ }1520
+ Command::Room(RoomCommand::Join { room, via }) => {1521
+ self.spawn_room_command(id, RoomCommand::Join { room, via })1522
+ }1523
+ Command::Room(RoomCommand::Leave(room_id)) => {1524
+ self.spawn_room_command(id, RoomCommand::Leave(room_id))1525
+ }1526
+ Command::Room(RoomCommand::Forget(room_id)) => {1527
+ self.spawn_room_command(id, RoomCommand::Forget(room_id))1528
+ }1529
+ Command::Room(RoomCommand::AcceptInvite(room_id)) => {1530
+ self.spawn_room_command(id, RoomCommand::AcceptInvite(room_id))1531
+ }1532
+ Command::Room(RoomCommand::RejectInvite(room_id)) => {1533
+ self.spawn_room_command(id, RoomCommand::RejectInvite(room_id))1534
+ }1535
+ Command::Room(command @ RoomCommand::InviteUser { .. })1536
+ | Command::Room(command @ RoomCommand::KickUser { .. })1537
+ | Command::Room(command @ RoomCommand::BanUser { .. })1538
+ | Command::Room(command @ RoomCommand::UnbanUser { .. })1539
+ | Command::Room(command @ RoomCommand::SetPowerLevels { .. })1540
+ | Command::Room(command @ RoomCommand::SetUserPowerLevel { .. }) => {1541
+ self.spawn_room_command(id, command)1542
+ }1543
+ Command::Room(RoomCommand::Create(options)) => {1544
+ self.spawn_room_command(id, RoomCommand::Create(options))1545
+ }1546
+ Command::Room(RoomCommand::CreateDirect(user_id)) => {1547
+ self.spawn_room_command(id, RoomCommand::CreateDirect(user_id))1548
+ }1549
+ Command::Room(RoomCommand::CancelSend {1550
+ room_id,1551
+ transaction_id,1552
+ }) => {1553
+ self.spawn_cancel_send(id, room_id, transaction_id);1554
+ Ok(true)1555
+ }1556
+ Command::Room(RoomCommand::RetrySend {1557
+ room_id,1558
+ transaction_id,1559
+ }) => {1560
+ self.spawn_retry_send(id, room_id, transaction_id);1561
+ Ok(true)1562
+ }1563
+ Command::Timeline(command) => self.route_timeline(id, command),1564
+ Command::Media(MediaCommand::Fetch { id: media_id, size }) => {1565
+ self.spawn_media_fetch(id, media_id, size);1566
+ Ok(true)1567
+ }1568
+ Command::Media(MediaCommand::Upload {1569
+ timeline_id,1570
+ filename,1571
+ mimetype,1572
+ bytes,1573
+ caption,1574
+ }) => {1575
+ let mut upload = AttachmentUpload::new(timeline_id, filename, mimetype, bytes);1576
+ upload.caption = caption;1577
+ self.spawn_media_upload(id, upload).await.map(|()| true)1578
+ }1579
+ Command::Media(MediaCommand::UploadAttachment(upload)) => {1580
+ self.spawn_media_upload(id, upload).await.map(|()| true)1581
+ }1582
+ Command::Encryption(command) => {1583
+ self.encryption_commands1584
+ .try_send(EncryptionWork::Command(CommandJob { id, command }))1585
+ .map_err(|error| domain_queue_error(error, "encryption"))?;1586
+ Ok(true)1587
+ }1588
+ Command::Notification(command) => {1589
+ self.notification_commands1590
+ .try_send(CommandJob { id, command })1591
+ .map_err(|error| domain_queue_error(error, "notification"))?;1592
+ Ok(true)1593
+ }1594
+ Command::Lifecycle(LifecycleCommand::Shutdown) => Ok(false),1595
+ }1596
+ }1597
+1598
+ async fn open_room(1599
+ &mut self,1600
+ id: CommandId,1601
+ room_id: OwnedRoomId,1602
+ ) -> Result<bool, CommandError> {1603
+ match self.rooms.get_mut(&room_id) {1604
+ Some(RoomActorState::Opening { sender, .. }) => {1605
+ send_room_actor_request(sender, RoomActorRequest::Opened { id })?;1606
+ return Ok(true);1607
+ }1608
+ Some(RoomActorState::Open { .. }) => return Ok(false),1609
+ Some(RoomActorState::Closing { pending, .. }) => {1610
+ if pending.len() >= self.room_command_capacity {1611
+ return Err(CommandError::new(1612
+ CommandErrorKind::RoomBusy,1613
+ "room lifecycle queue is full",1614
+ ));1615
+ }1616
+ pending.push_back(DeferredRoomLifecycle::Open(id));1617
+ return Ok(true);1618
+ }1619
+ None => {}1620
+ }1621
+ self.start_room_actor(room_id, vec![id]).await?;1622
+ Ok(true)1623
+ }1624
+1625
+ async fn start_room_actor(1626
+ &mut self,1627
+ room_id: OwnedRoomId,1628
+ open_ids: Vec<CommandId>,1629
+ ) -> Result<(), CommandError> {1630
+ let room = self.client.get_room(&room_id).ok_or_else(|| {1631
+ CommandError::new(1632
+ CommandErrorKind::RoomUnknown,1633
+ "room is not available in the local store",1634
+ )1635
+ })?;1636
+ self.next_room_generation = self.next_room_generation.wrapping_add(1);1637
+ let generation = self.next_room_generation;1638
+ let (sender, receiver) = mpsc::channel(self.room_command_capacity);1639
+ let actor = RoomActor {1640
+ room_id: room_id.clone(),1641
+ generation,1642
+ room,1643
+ receiver,1644
+ events: self.port.event_sender(),1645
+ engine_events: self.room_actor_event_sender.clone(),1646
+ media: self.media.clone(),1647
+ initial_history_limit: self.initial_history_limit,1648
+ open_ids,1649
+ };1650
+ let current_user_id = self.client.user_id().map(ToOwned::to_owned);1651
+ self.rooms.insert(1652
+ room_id.clone(),1653
+ RoomActorState::Opening { generation, sender },1654
+ );1655
+ let actor_room_id = room_id.clone();1656
+ let engine_events = self.room_actor_event_sender.clone();1657
+ self.tasks.spawn(1658
+ TaskContext::RoomActor {1659
+ room_id: room_id.clone(),1660
+ generation,1661
+ },1662
+ async move {1663
+ actor.run(current_user_id).await;1664
+ let _ = engine_events.send(RoomActorEvent::Exited {1665
+ room_id: actor_room_id,1666
+ generation,1667
+ });1668
+ },1669
+ );1670
+ self.update_room_subscriptions().await;1671
+ Ok(())1672
+ }1673
+1674
+ fn spawn_room_command(1675
+ &mut self,1676
+ id: CommandId,1677
+ command: RoomCommand,1678
+ ) -> Result<bool, CommandError> {1679
+ self.spawn_room_command_with_reply(id, command, None)?;1680
+ Ok(true)1681
+ }1682
+1683
+ fn spawn_room_command_with_reply(1684
+ &mut self,1685
+ id: CommandId,1686
+ command: RoomCommand,1687
+ reply: Option<oneshot::Sender<Result<OwnedRoomId, CommandResultError>>>,1688
+ ) -> Result<(), CommandError> {1689
+ let job = RoomJob { id, command, reply };1690
+ self.room_commands.try_send(job).map_err(|error| {1691
+ let (job, kind, message) = match error {1692
+ mpsc::error::TrySendError::Full(job) => (1693
+ job,1694
+ CommandErrorKind::QueueFull,1695
+ "room command queue is full",1696
+ ),1697
+ mpsc::error::TrySendError::Closed(job) => (1698
+ job,1699
+ CommandErrorKind::Other,1700
+ "room command worker has stopped",1701
+ ),1702
+ };1703
+ let error = CommandError::new(kind, message);1704
+ if let Some(reply) = job.reply {1705
+ let _ = reply.send(Err(CommandResultError::Failed(CommandFailure {1706
+ id: job.id,1707
+ command: CommandKind::Room,1708
+ kind: error.kind,1709
+ message: error.to_string(),1710
+ })));1711
+ }1712
+ error1713
+ })1714
+ }1715
+1716
+ fn route_timeline(1717
+ &mut self,1718
+ id: CommandId,1719
+ command: TimelineCommand,1720
+ ) -> Result<bool, CommandError> {1721
+ let room_id = command.room_id();1722
+ let sender = self1723
+ .rooms1724
+ .get(room_id)1725
+ .and_then(RoomActorState::sender)1726
+ .ok_or_else(|| CommandError::new(CommandErrorKind::RoomNotOpen, "room is not open"))?;1727
+ send_room_actor_request(sender, RoomActorRequest::Execute { id, command })?;1728
+ Ok(true)1729
+ }1730
+1731
+ async fn close_room(&mut self, id: CommandId, room_id: &RoomId) -> Result<bool, CommandError> {1732
+ let Some(state) = self.rooms.get_mut(room_id) else {1733
+ return Ok(false);1734
+ };1735
+ if let RoomActorState::Closing { pending, .. } = state {1736
+ if pending.len() >= self.room_command_capacity {1737
+ return Err(CommandError::new(1738
+ CommandErrorKind::RoomBusy,1739
+ "room lifecycle queue is full",1740
+ ));1741
+ }1742
+ pending.push_back(DeferredRoomLifecycle::Close(id));1743
+ return Ok(true);1744
+ }1745
+ let Some(sender) = state.sender().cloned() else {1746
+ return Ok(false);1747
+ };1748
+ let generation = state.generation();1749
+ send_room_actor_request(&sender, RoomActorRequest::Close { id })?;1750
+ self.rooms.insert(1751
+ room_id.to_owned(),1752
+ RoomActorState::Closing {1753
+ generation,1754
+ pending: VecDeque::new(),1755
+ },1756
+ );1757
+ self.update_room_subscriptions().await;1758
+ Ok(true)1759
+ }1760
+1761
+ async fn handle_room_actor_event(&mut self, event: RoomActorEvent) -> Result<(), EngineError> {1762
+ match event {1763
+ RoomActorEvent::Ready {1764
+ room_id,1765
+ generation,1766
+ } => {1767
+ let stale = self1768
+ .rooms1769
+ .get(&room_id)1770
+ .is_none_or(|state| state.generation() != generation);1771
+ if stale {1772
+ return Ok(());1773
+ }1774
+ let Some(state) = self.rooms.remove(&room_id) else {1775
+ return Ok(());1776
+ };1777
+ match state {1778
+ RoomActorState::Opening {1779
+ generation: current,1780
+ sender,1781
+ } if current == generation => {1782
+ self.rooms1783
+ .insert(room_id, RoomActorState::Open { generation, sender });1784
+ }1785
+ state => {1786
+ self.rooms.insert(room_id, state);1787
+ }1788
+ }1789
+ }1790
+ RoomActorEvent::Exited {1791
+ room_id,1792
+ generation,1793
+ } => {1794
+ if self1795
+ .rooms1796
+ .get(&room_id)1797
+ .is_none_or(|state| state.generation() != generation)1798
+ {1799
+ return Ok(());1800
+ }1801
+ let state = self1802
+ .rooms1803
+ .remove(&room_id)1804
+ .expect("room generation was checked");1805
+ let pending = match state {1806
+ RoomActorState::Closing { pending, .. } => pending,1807
+ RoomActorState::Opening { .. } | RoomActorState::Open { .. } => VecDeque::new(),1808
+ };1809
+ for operation in pending {1810
+ let (id, result) = match operation {1811
+ DeferredRoomLifecycle::Open(id) => {1812
+ (id, self.open_room(id, room_id.clone()).await)1813
+ }1814
+ DeferredRoomLifecycle::Close(id) => {1815
+ (id, self.close_room(id, &room_id).await)1816
+ }1817
+ };1818
+ match result {1819
+ Ok(true) => {}1820
+ Ok(false) => emit_command_result(1821
+ &self.port.event_sender(),1822
+ id,1823
+ CommandKind::Room,1824
+ Ok(()),1825
+ )1826
+ .await1827
+ .map_err(|_| EngineError::EventReceiverClosed)?,1828
+ Err(error) => emit_command_result(1829
+ &self.port.event_sender(),1830
+ id,1831
+ CommandKind::Room,1832
+ Err(error),1833
+ )1834
+ .await1835
+ .map_err(|_| EngineError::EventReceiverClosed)?,1836
+ }1837
+ }1838
+ self.update_room_subscriptions().await;1839
+ }1840
+ }1841
+ Ok(())1842
+ }1843
+1844
+ async fn update_room_subscriptions(&self) {1845
+ let room_ids = self1846
+ .rooms1847
+ .iter()1848
+ .filter(|(_, state)| !matches!(state, RoomActorState::Closing { .. }))1849
+ .map(|(room_id, _)| room_id.clone())1850
+ .collect::<Vec<_>>();1851
+ let room_refs = room_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>();1852
+ self.sync1853
+ .room_list_service()1854
+ .subscribe_to_rooms(&room_refs)1855
+ .await;1856
+ }1857
+1858
+ fn spawn_media_fetch(&mut self, command_id: CommandId, media_id: MediaId, size: MediaSize) {1859
+ let client = self.client.clone();1860
+ let media = self.media.clone();1861
+ let events = self.port.event_sender();1862
+ let max_media_bytes = self.max_media_bytes;1863
+ let flights = self.media_flights.clone();1864
+ self.tasks.spawn(1865
+ TaskContext::Command {1866
+ id: command_id,1867
+ kind: CommandKind::Media,1868
+ },1869
+ async move {1870
+ let leader = {1871
+ let mut flights = flights.lock().await;1872
+ let waiters = flights.entry((media_id.clone(), size)).or_default();1873
+ waiters.push(command_id);1874
+ waiters.len() == 11875
+ };1876
+ if !leader {1877
+ return;1878
+ }1879
+ let result = fetch_media(1880
+ client,1881
+ media,1882
+ events.clone(),1883
+ max_media_bytes,1884
+ media_id.clone(),1885
+ size,1886
+ )1887
+ .await;1888
+ let waiters = {1889
+ let mut flights = flights.lock().await;1890
+ flights.remove(&(media_id, size)).unwrap_or_default()1891
+ };1892
+ let result = match result {1893
+ Ok(()) => Ok(()),1894
+ Err(error) => {1895
+ let kind = error.kind;1896
+ let message = error.to_string();1897
+ Err((kind, message))1898
+ }1899
+ };1900
+ for waiter in waiters {1901
+ let waiter_result = match &result {1902
+ Ok(()) => Ok(()),1903
+ Err((kind, message)) => Err(CommandError::new(*kind, message.clone())),1904
+ };1905
+ if emit_command_result(&events, waiter, CommandKind::Media, waiter_result)1906
+ .await1907
+ .is_err()1908
+ {1909
+ tracing::debug!(?waiter, "media fetch result was not delivered");1910
+ }1911
+ }1912
+ },1913
+ );1914
+ }1915
+1916
+ async fn spawn_media_upload(1917
+ &mut self,1918
+ command_id: CommandId,1919
+ upload: AttachmentUpload,1920
+ ) -> Result<(), CommandError> {1921
+ let room_id = upload.timeline_id.room_id().clone();1922
+ let room = self.client.get_room(&room_id).ok_or_else(|| {1923
+ CommandError::new(1924
+ CommandErrorKind::RoomUnknown,1925
+ "room is not available in the local store",1926
+ )1927
+ })?;1928
+ if self1929
+ .upload_watchers1930
+ .get(&room_id)1931
+ .is_some_and(|(watcher, _)| watcher.is_finished())1932
+ {1933
+ self.upload_watchers.remove(&room_id);1934
+ }1935
+ let ready = if let Some((_, ready)) = self.upload_watchers.get(&room_id) {1936
+ ready.clone()1937
+ } else {1938
+ let (ready_sender, ready) = watch::channel(None);1939
+ let watched_room_id = room_id.clone();1940
+ let progress_room_id = room_id.clone();1941
+ let queued_room = room.clone();1942
+ let events = self.port.event_sender();1943
+ let watcher =1944
+ self.tasks1945
+ .spawn(TaskContext::Watcher("media upload watcher"), async move {1946
+ let (_, mut updates) = match queued_room.send_queue().subscribe().await {1947
+ Ok(subscription) => subscription,1948
+ Err(source) => {1949
+ tracing::warn!(room_id = %progress_room_id, error = %source, "reading media send queue failed");1950
+ ready_sender.send_replace(Some(Err(CommandErrorKind::Store)));1951
+ return;1952
+ }1953
+ };1954
+ ready_sender.send_replace(Some(Ok(())));1955
+ loop {1956
+ match updates.recv().await {1957
+ Ok(RoomSendQueueUpdate::MediaUpload {1958
+ related_to,1959
+ file,1960
+ progress,1961
+ ..1962
+ }) => {1963
+ if events1964
+ .send(Event::MediaUploadProgress {1965
+ room_id: progress_room_id.clone(),1966
+ transaction_id: related_to,1967
+ uploaded_bytes: progress.current as u64,1968
+ total_bytes: progress.total as u64,1969
+ complete: file.is_some()1970
+ && progress.current == progress.total,1971
+ })1972
+ .await1973
+ .is_err()1974
+ {1975
+ break;1976
+ }1977
+ }1978
+ Ok(RoomSendQueueUpdate::SendError {1979
+ transaction_id,1980
+ error,1981
+ is_recoverable,1982
+ }) => {1983
+ let is_attachment = queued_room1984
+ .send_queue()1985
+ .subscribe()1986
+ .await1987
+ .is_ok_and(|(echoes, _)| {1988
+ echoes.iter().any(|echo| {1989
+ echo.transaction_id == transaction_id1990
+ && is_attachment_echo(&echo.content)1991
+ })1992
+ });1993
+ if is_attachment1994
+ && events1995
+ .send(Event::MediaUploadFailed {1996
+ room_id: progress_room_id.clone(),1997
+ transaction_id,1998
+ kind: classify_sdk_error(&error),1999
+ recoverable: is_recoverable,2000
+ })2001
+ .await2002
+ .is_err()2003
+ {2004
+ break;2005
+ }2006
+ }2007
+ Ok(_)2008
+ | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}2009
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => break,2010
+ }2011
+ }2012
+ });2013
+ self.upload_watchers2014
+ .insert(watched_room_id, (watcher, ready.clone()));2015
+ ready2016
+ };2017
+ let max_media_bytes = self.max_media_bytes;2018
+ let events = self.port.event_sender();2019
+ self.tasks.spawn(2020
+ TaskContext::Command {2021
+ id: command_id,2022
+ kind: CommandKind::Media,2023
+ },2024
+ async move {2025
+ let mut ready = ready;2026
+ let result = async {2027
+ loop {2028
+ if let Some(result) = *ready.borrow_and_update() {2029
+ result.map_err(|kind| {2030
+ CommandError::new(kind, "reading media send queue failed")2031
+ })?;2032
+ break;2033
+ }2034
+ ready.changed().await.map_err(|_| {2035
+ CommandError::new(2036
+ CommandErrorKind::Store,2037
+ "media send queue watcher stopped",2038
+ )2039
+ })?;2040
+ }2041
+ queue_media_upload(room, upload, max_media_bytes).await2042
+ }2043
+ .await;2044
+ if emit_command_result(&events, command_id, CommandKind::Media, result)2045
+ .await2046
+ .is_err()2047
+ {2048
+ tracing::debug!(?command_id, "media upload result was not delivered");2049
+ }2050
+ },2051
+ );2052
+ Ok(())2053
+ }2054
+2055
+ async fn shutdown(&mut self) {2056
+ self.tasks.shutdown().await;2057
+ self.verification.lock().await.stop().await;2058
+ self.rooms.clear();2059
+ self.upload_watchers.clear();2060
+ self.media.lock().await.entries.clear();2061
+ self.sync.stop().await;2062
+ self.port2063
+ .connection_sender()2064
+ .send_replace(ConnectionState::Stopped);2065
+ let _ = self2066
+ .port2067
+ .send(Event::ConnectionChanged(ConnectionState::Stopped))2068
+ .await;2069
+ }2070
+}2071
+2072
+async fn mark_read(2073
+ client: &Client,2074
+ timeline_id: &TimelineId,2075
+ event_id: OwnedEventId,2076
+) -> Result<(), CommandError> {2077
+ let room = client2078
+ .get_room(timeline_id.room_id())2079
+ .ok_or_else(room_unknown)?;2080
+ let receipt_thread = match timeline_id {2081
+ TimelineId::Room(_) => ReceiptThread::Main,2082
+ TimelineId::Thread { root_event_id, .. } => ReceiptThread::Thread(root_event_id.clone()),2083
+ };2084
+ room.send_single_receipt(ReceiptType::Read, receipt_thread, event_id.clone())2085
+ .await2086
+ .map_err(|source| CommandError::from_sdk("sending read receipt failed", source))?;2087
+ if matches!(timeline_id, TimelineId::Room(_)) {2088
+ room.send_single_receipt(ReceiptType::FullyRead, ReceiptThread::Unthreaded, event_id)2089
+ .await2090
+ .map_err(|source| {2091
+ CommandError::from_sdk("sending fully read receipt failed", source)2092
+ })?;2093
+ }2094
+ Ok(())2095
+}2096
+2097
+async fn set_typing(client: &Client, room_id: &RoomId, typing: bool) -> Result<(), CommandError> {2098
+ let room = client.get_room(room_id).ok_or_else(room_unknown)?;2099
+ room.typing_notice(typing)2100
+ .await2101
+ .map_err(|source| CommandError::from_sdk("typing update failed", source))2102
+}2103
+2104
+fn is_attachment_echo(content: &LocalEchoContent) -> bool {2105
+ let LocalEchoContent::Event {2106
+ serialized_event, ..2107
+ } = content2108
+ else {2109
+ return false;2110
+ };2111
+ matches!(2112
+ serialized_event.deserialize(),2113
+ Ok(AnyMessageLikeEventContent::RoomMessage(content))2114
+ if matches!(2115
+ content.msgtype,2116
+ MessageType::Audio(_) | MessageType::File(_) | MessageType::Image(_) | MessageType::Video(_)2117
+ )2118
+ )2119
+}2120
+2121
+async fn send_handle(2122
+ client: &Client,2123
+ room_id: &RoomId,2124
+ transaction_id: &matrix_sdk::ruma::TransactionId,2125
+) -> Result<SendHandle, CommandError> {2126
+ let room = client.get_room(room_id).ok_or_else(room_unknown)?;2127
+ let (echoes, _) = room.send_queue().subscribe().await.map_err(|source| {2128
+ CommandError::with_source(CommandErrorKind::Store, "reading send queue failed", source)2129
+ })?;2130
+ echoes2131
+ .into_iter()2132
+ .find_map(|echo| {2133
+ if echo.transaction_id.as_str() != transaction_id.as_str() {2134
+ return None;2135
+ }2136
+ match echo.content {2137
+ LocalEchoContent::Event { send_handle, .. } => Some(send_handle),2138
+ _ => None,2139
+ }2140
+ })2141
+ .ok_or_else(|| {2142
+ CommandError::new(2143
+ CommandErrorKind::Other,2144
+ "send is no longer pending or is not a message",2145
+ )2146
+ })2147
+}2148
+2149
+async fn cancel_send(2150
+ client: &Client,2151
+ room_id: &RoomId,2152
+ transaction_id: &matrix_sdk::ruma::TransactionId,2153
+) -> Result<(), CommandError> {2154
+ let handle = send_handle(client, room_id, transaction_id).await?;2155
+ match handle.abort().await {2156
+ Ok(true) => Ok(()),2157
+ Ok(false) => Err(CommandError::new(2158
+ CommandErrorKind::Other,2159
+ "send could not be cancelled because it has already been sent",2160
+ )),2161
+ Err(source) => Err(CommandError::with_source(2162
+ CommandErrorKind::Store,2163
+ "cancelling send failed",2164
+ source,2165
+ )),2166
+ }2167
+}2168
+2169
+async fn retry_send(2170
+ client: &Client,2171
+ room_id: &RoomId,2172
+ transaction_id: &matrix_sdk::ruma::TransactionId,2173
+) -> Result<(), CommandError> {2174
+ let handle = send_handle(client, room_id, transaction_id).await?;2175
+ handle.unwedge().await.map_err(|source| {2176
+ CommandError::with_source(CommandErrorKind::Store, "retrying send failed", source)2177
+ })?;2178
+ client.send_queue().set_enabled(true).await;2179
+ Ok(())2180
+}2181
+2182
+fn send_room_actor_request(2183
+ sender: &mpsc::Sender<RoomActorRequest>,2184
+ request: RoomActorRequest,2185
+) -> Result<(), CommandError> {2186
+ sender.try_send(request).map_err(|error| match error {2187
+ mpsc::error::TrySendError::Full(_) => {2188
+ CommandError::new(CommandErrorKind::RoomBusy, "room command queue is full")2189
+ }2190
+ mpsc::error::TrySendError::Closed(_) => {2191
+ CommandError::new(CommandErrorKind::RoomNotOpen, "room is not open")2192
+ }2193
+ })2194
+}2195
+2196
+fn domain_queue_error<T>(error: mpsc::error::TrySendError<T>, domain: &str) -> CommandError {2197
+ match error {2198
+ mpsc::error::TrySendError::Full(_) => CommandError::new(2199
+ CommandErrorKind::QueueFull,2200
+ format!("{domain} command queue is full"),2201
+ ),2202
+ mpsc::error::TrySendError::Closed(_) => CommandError::new(2203
+ CommandErrorKind::Other,2204
+ format!("{domain} command worker has stopped"),2205
+ ),2206
+ }2207
+}2208
+2209
+async fn run_room_command_worker(2210
+ client: Client,2211
+ events: EventSender,2212
+ mut commands: mpsc::Receiver<RoomJob>,2213
+ queue_limit: usize,2214
+) {2215
+ let mut queues = HashMap::<RoomMutationKey, VecDeque<RoomJob>>::new();2216
+ let mut running = HashSet::<RoomMutationKey>::new();2217
+ let mut tasks = JoinSet::<RoomJobCompletion>::new();2218
+ let mut receiving = true;2219
+ let mut queued = 0_usize;2220
+ while receiving || !tasks.is_empty() {2221
+ tokio::select! {2222
+ biased;2223
+ completed = tasks.join_next(), if !tasks.is_empty() => {2224
+ let completion = match completed {2225
+ Some(Ok(completion)) => completion,2226
+ Some(Err(error)) => panic!("room command task failed: {error}"),2227
+ None => continue,2228
+ };2229
+ let key = completion.key.clone();2230
+ emit_room_job_result(&events, completion).await;2231
+ let next = queues.get_mut(&key).and_then(VecDeque::pop_front);2232
+ if next.is_some() {2233
+ queued -= 1;2234
+ }2235
+ if queues.get(&key).is_some_and(VecDeque::is_empty) {2236
+ queues.remove(&key);2237
+ }2238
+ if let Some(job) = next {2239
+ spawn_room_job(&mut tasks, client.clone(), key, job);2240
+ } else {2241
+ running.remove(&key);2242
+ }2243
+ }2244
+ job = commands.recv(), if receiving => {2245
+ match job {2246
+ Some(job) => {2247
+ let key = room_mutation_key(job.id, &job.command);2248
+ if running.insert(key.clone()) {2249
+ spawn_room_job(&mut tasks, client.clone(), key, job);2250
+ } else if queued < queue_limit {2251
+ queues.entry(key).or_default().push_back(job);2252
+ queued += 1;2253
+ } else {2254
+ let failure = CommandFailure {2255
+ id: job.id,2256
+ command: CommandKind::Room,2257
+ kind: CommandErrorKind::QueueFull,2258
+ message: "room command queue is full".to_owned(),2259
+ };2260
+ if let Some(reply) = job.reply {2261
+ let _ = reply.send(Err(CommandResultError::Failed(failure)));2262
+ } else {2263
+ let _ = events.send(Event::CommandFailed(failure)).await;2264
+ }2265
+ }2266
+ }2267
+ None => receiving = false,2268
+ }2269
+ }2270
+ }2271
+ }2272
+}2273
+2274
+fn spawn_room_job(2275
+ tasks: &mut JoinSet<RoomJobCompletion>,2276
+ client: Client,2277
+ key: RoomMutationKey,2278
+ job: RoomJob,2279
+) {2280
+ tasks.spawn(async move {2281
+ let RoomJob { id, command, reply } = job;2282
+ let result = AssertUnwindSafe(execute_room_command(&client, command))2283
+ .catch_unwind()2284
+ .await2285
+ .unwrap_or_else(|_| {2286
+ Err(CommandError::new(2287
+ CommandErrorKind::Other,2288
+ "room command panicked",2289
+ ))2290
+ });2291
+ RoomJobCompletion {2292
+ key,2293
+ id,2294
+ reply,2295
+ result,2296
+ }2297
+ });2298
+}2299
+2300
+fn room_mutation_key(id: CommandId, command: &RoomCommand) -> RoomMutationKey {2301
+ match command {2302
+ RoomCommand::Join { room, .. } if room.is_room_id() => RoomMutationKey::Room(2303
+ OwnedRoomId::try_from(room.clone()).expect("room ID variant was checked"),2304
+ ),2305
+ RoomCommand::Join { room, .. } => RoomMutationKey::Target(room.to_string()),2306
+ RoomCommand::Leave(room_id)2307
+ | RoomCommand::Forget(room_id)2308
+ | RoomCommand::AcceptInvite(room_id)2309
+ | RoomCommand::RejectInvite(room_id)2310
+ | RoomCommand::InviteUser { room_id, .. }2311
+ | RoomCommand::KickUser { room_id, .. }2312
+ | RoomCommand::BanUser { room_id, .. }2313
+ | RoomCommand::UnbanUser { room_id, .. }2314
+ | RoomCommand::SetPowerLevels { room_id, .. }2315
+ | RoomCommand::SetUserPowerLevel { room_id, .. } => RoomMutationKey::Room(room_id.clone()),2316
+ RoomCommand::CreateDirect(user_id) => RoomMutationKey::Direct(user_id.clone()),2317
+ _ => RoomMutationKey::Independent(id),2318
+ }2319
+}2320
+2321
+async fn emit_room_job_result(events: &EventSender, completion: RoomJobCompletion) {2322
+ let RoomJobCompletion {2323
+ id, reply, result, ..2324
+ } = completion;2325
+ match result {2326
+ Ok(Some(room_id)) => {2327
+ if let Some(reply) = reply {2328
+ let _ = reply.send(Ok(room_id.clone()));2329
+ } else {2330
+ let _ = emit_command_result_with_room(events, id, room_id).await;2331
+ }2332
+ }2333
+ Ok(None) => {2334
+ if let Some(reply) = reply {2335
+ let _ = reply.send(Err(CommandResultError::Failed(CommandFailure {2336
+ id,2337
+ command: CommandKind::Room,2338
+ kind: CommandErrorKind::Other,2339
+ message: "room operation did not return a room ID".to_owned(),2340
+ })));2341
+ } else {2342
+ let _ = emit_command_result(events, id, CommandKind::Room, Ok(())).await;2343
+ }2344
+ }2345
+ Err(error) => {2346
+ let failure = CommandFailure {2347
+ id,2348
+ command: CommandKind::Room,2349
+ kind: error.kind,2350
+ message: error.to_string(),2351
+ };2352
+ if let Some(reply) = reply {2353
+ let _ = reply.send(Err(CommandResultError::Failed(failure)));2354
+ } else {2355
+ let _ = events.send(Event::CommandFailed(failure)).await;2356
+ }2357
+ }2358
+ }2359
+}2360
+2361
+async fn execute_room_command(2362
+ client: &Client,2363
+ command: RoomCommand,2364
+) -> Result<Option<OwnedRoomId>, CommandError> {2365
+ match command {2366
+ RoomCommand::Join { room, via } => client2367
+ .join_room_by_id_or_alias(&room, &via)2368
+ .await2369
+ .map(|room| Some(room.room_id().to_owned()))2370
+ .map_err(|source| CommandError::from_sdk("joining room failed", source)),2371
+ RoomCommand::Leave(room_id) => match client.get_room(&room_id) {2372
+ Some(room) => room2373
+ .leave()2374
+ .await2375
+ .map(|_| None)2376
+ .map_err(|source| CommandError::from_sdk("leaving room failed", source)),2377
+ None => Err(room_unknown()),2378
+ },2379
+ RoomCommand::Forget(room_id) => match client.get_room(&room_id) {2380
+ Some(room) => room2381
+ .forget()2382
+ .await2383
+ .map(|_| None)2384
+ .map_err(|source| CommandError::from_sdk("forgetting room failed", source)),2385
+ None => Err(room_unknown()),2386
+ },2387
+ RoomCommand::AcceptInvite(room_id) => match client.get_room(&room_id) {2388
+ Some(room) => room2389
+ .join()2390
+ .await2391
+ .map(|_| None)2392
+ .map_err(|source| CommandError::from_sdk("accepting invite failed", source)),2393
+ None => Err(room_unknown()),2394
+ },2395
+ RoomCommand::RejectInvite(room_id) => match client.get_room(&room_id) {2396
+ Some(room) => room2397
+ .leave()2398
+ .await2399
+ .map(|_| None)2400
+ .map_err(|source| CommandError::from_sdk("rejecting invite failed", source)),2401
+ None => Err(room_unknown()),2402
+ },2403
+ RoomCommand::InviteUser { room_id, user_id } => match client.get_room(&room_id) {2404
+ Some(room) => room2405
+ .invite_user_by_id(&user_id)2406
+ .await2407
+ .map(|()| None)2408
+ .map_err(|source| CommandError::from_sdk("inviting user failed", source)),2409
+ None => Err(room_unknown()),2410
+ },2411
+ RoomCommand::KickUser {2412
+ room_id,2413
+ user_id,2414
+ reason,2415
+ } => match client.get_room(&room_id) {2416
+ Some(room) => room2417
+ .kick_user(&user_id, reason.as_deref())2418
+ .await2419
+ .map(|_| None)2420
+ .map_err(|source| CommandError::from_sdk("kicking user failed", source)),2421
+ None => Err(room_unknown()),2422
+ },2423
+ RoomCommand::BanUser {2424
+ room_id,2425
+ user_id,2426
+ reason,2427
+ } => match client.get_room(&room_id) {2428
+ Some(room) => room2429
+ .ban_user(&user_id, reason.as_deref())2430
+ .await2431
+ .map(|_| None)2432
+ .map_err(|source| CommandError::from_sdk("banning user failed", source)),2433
+ None => Err(room_unknown()),2434
+ },2435
+ RoomCommand::UnbanUser {2436
+ room_id,2437
+ user_id,2438
+ reason,2439
+ } => match client.get_room(&room_id) {2440
+ Some(room) => room2441
+ .unban_user(&user_id, reason.as_deref())2442
+ .await2443
+ .map(|_| None)2444
+ .map_err(|source| CommandError::from_sdk("unbanning user failed", source)),2445
+ None => Err(room_unknown()),2446
+ },2447
+ RoomCommand::SetPowerLevels { room_id, changes } => match client.get_room(&room_id) {2448
+ Some(room) => apply_power_level_changes(&room, changes)2449
+ .await2450
+ .map(|_| None),2451
+ None => Err(room_unknown()),2452
+ },2453
+ RoomCommand::SetUserPowerLevel {2454
+ room_id,2455
+ user_id,2456
+ level,2457
+ } => match client.get_room(&room_id) {2458
+ Some(room) => match matrix_sdk::ruma::Int::try_from(level) {2459
+ Ok(level) => room2460
+ .update_power_levels(vec![(&user_id, level)])2461
+ .await2462
+ .map(|_| None)2463
+ .map_err(|source| {2464
+ CommandError::from_sdk("updating user power level failed", source)2465
+ }),2466
+ Err(_) => Err(CommandError::new(2467
+ CommandErrorKind::Other,2468
+ "power level is outside the Matrix integer range",2469
+ )),2470
+ },2471
+ None => Err(room_unknown()),2472
+ },2473
+ RoomCommand::Create(options) => client2474
+ .create_room(create_room_request(options))2475
+ .await2476
+ .map(|room| Some(room.room_id().to_owned()))2477
+ .map_err(|source| CommandError::from_sdk("creating room failed", source)),2478
+ RoomCommand::CreateDirect(user_id) => client2479
+ .create_dm(&user_id)2480
+ .await2481
+ .map(|room| Some(room.room_id().to_owned()))2482
+ .map_err(|source| CommandError::from_sdk("creating direct room failed", source)),2483
+ _ => Err(CommandError::new(2484
+ CommandErrorKind::Other,2485
+ "unsupported room command",2486
+ )),2487
+ }2488
+}2489
+2490
+fn room_unknown() -> CommandError {2491
+ CommandError::new(2492
+ CommandErrorKind::RoomUnknown,2493
+ "room is not available in the local store",2494
+ )2495
+}2496
+2497
+const fn sdk_notification_mode(2498
+ mode: RoomNotificationMode,2499
+) -> matrix_sdk::notification_settings::RoomNotificationMode {2500
+ match mode {2501
+ RoomNotificationMode::AllMessages => {2502
+ matrix_sdk::notification_settings::RoomNotificationMode::AllMessages2503
+ }2504
+ RoomNotificationMode::MentionsAndKeywordsOnly => {2505
+ matrix_sdk::notification_settings::RoomNotificationMode::MentionsAndKeywordsOnly2506
+ }2507
+ RoomNotificationMode::Mute => matrix_sdk::notification_settings::RoomNotificationMode::Mute,2508
+ }2509
+}2510
+2511
+async fn execute_notification_command(2512
+ client: &Client,2513
+ command: NotificationCommand,2514
+) -> Result<(), CommandError> {2515
+ use matrix_sdk::notification_settings::{IsEncrypted, IsOneToOne};2516
+2517
+ let settings = client.notification_settings().await;2518
+ let result = match command {2519
+ NotificationCommand::SetRoomMode { room_id, mode } => {2520
+ settings2521
+ .set_room_notification_mode(&room_id, sdk_notification_mode(mode))2522
+ .await2523
+ }2524
+ NotificationCommand::ResetRoomMode(room_id) => {2525
+ settings.delete_user_defined_room_rules(&room_id).await2526
+ }2527
+ NotificationCommand::SetDefaultMode {2528
+ encrypted,2529
+ one_to_one,2530
+ mode,2531
+ } => {2532
+ settings2533
+ .set_default_room_notification_mode(2534
+ IsEncrypted::from(encrypted),2535
+ IsOneToOne::from(one_to_one),2536
+ sdk_notification_mode(mode),2537
+ )2538
+ .await2539
+ }2540
+ NotificationCommand::AddKeyword(keyword) => settings.add_keyword(keyword).await,2541
+ NotificationCommand::RemoveKeyword(keyword) => settings.remove_keyword(&keyword).await,2542
+ };2543
+ result.map_err(|source| {2544
+ CommandError::with_source(2545
+ CommandErrorKind::Server,2546
+ "updating notification settings failed",2547
+ source,2548
+ )2549
+ })2550
+}2551
+2552
+async fn execute_encryption_command(2553
+ client: &Client,2554
+ events: &EventSender,2555
+ verification: &mut VerificationController,2556
+ command: EncryptionCommand,2557
+) -> Result<(), CommandError> {2558
+ match command {2559
+ EncryptionCommand::Recover(key) => client2560
+ .encryption()2561
+ .recovery()2562
+ .recover(&key)2563
+ .await2564
+ .map_err(|source| CommandError::from_recovery("account recovery failed", source)),2565
+ EncryptionCommand::StartVerification(target) => {2566
+ verification.start(client, events.clone(), target).await2567
+ }2568
+ EncryptionCommand::Verify(action) => {2569
+ verification.apply(client, events.clone(), action).await2570
+ }2571
+ command => execute_key_backup_command(client, command).await,2572
+ }2573
+}2574
+2575
+async fn execute_key_backup_command(2576
+ client: &Client,2577
+ command: EncryptionCommand,2578
+) -> Result<(), CommandError> {2579
+ match command {2580
+ EncryptionCommand::CreateKeyBackup => client2581
+ .encryption()2582
+ .backups()2583
+ .create()2584
+ .await2585
+ .map_err(|source| CommandError::from_sdk("creating key backup failed", source)),2586
+ EncryptionCommand::EnableKeyBackup => client2587
+ .encryption()2588
+ .recovery()2589
+ .enable_backup()2590
+ .await2591
+ .map_err(|source| CommandError::from_recovery("enabling key backup failed", source)),2592
+ EncryptionCommand::DisableKeyBackup { delete_from_server } => {2593
+ let backups = client.encryption().backups();2594
+ if delete_from_server {2595
+ backups.disable_and_delete().await2596
+ } else {2597
+ backups.disable().await2598
+ }2599
+ .map_err(|source| CommandError::from_sdk("disabling key backup failed", source))2600
+ }2601
+ EncryptionCommand::RecoverKeyBackup(key) => client2602
+ .encryption()2603
+ .recovery()2604
+ .recover_and_fix_backup(&key)2605
+ .await2606
+ .map_err(|source| CommandError::from_recovery("recovering key backup failed", source)),2607
+ _ => unreachable!("only key backup commands are spawned here"),2608
+ }2609
+}2610
+2611
+async fn apply_power_level_changes(2612
+ room: &Room,2613
+ changes: PowerLevelChanges,2614
+) -> Result<(), CommandError> {2615
+ use matrix_sdk::ruma::events::{2616
+ MessageLikeEventType, StateEventType, room::power_levels::RoomPowerLevelsEventContent,2617
+ };2618
+2619
+ let mut levels = room2620
+ .power_levels()2621
+ .await2622
+ .map_err(|source| CommandError::from_sdk("reading power levels failed", source.into()))?;2623
+ if let Some(value) = changes.ban {2624
+ levels.ban = matrix_power_level(value)?;2625
+ }2626
+ if let Some(value) = changes.invite {2627
+ levels.invite = matrix_power_level(value)?;2628
+ }2629
+ if let Some(value) = changes.kick {2630
+ levels.kick = matrix_power_level(value)?;2631
+ }2632
+ if let Some(value) = changes.redact {2633
+ levels.redact = matrix_power_level(value)?;2634
+ }2635
+ if let Some(value) = changes.events_default {2636
+ levels.events_default = matrix_power_level(value)?;2637
+ }2638
+ if let Some(value) = changes.state_default {2639
+ levels.state_default = matrix_power_level(value)?;2640
+ }2641
+ if let Some(value) = changes.users_default {2642
+ levels.users_default = matrix_power_level(value)?;2643
+ }2644
+ if let Some(value) = changes.room_notification {2645
+ levels.notifications.room = matrix_power_level(value)?;2646
+ }2647
+ for (event_type, value) in [2648
+ (StateEventType::RoomName.into(), changes.room_name),2649
+ (StateEventType::RoomAvatar.into(), changes.room_avatar),2650
+ (StateEventType::RoomTopic.into(), changes.room_topic),2651
+ (StateEventType::SpaceChild.into(), changes.space_child),2652
+ (MessageLikeEventType::Beacon.into(), changes.beacon),2653
+ (StateEventType::BeaconInfo.into(), changes.beacon_info),2654
+ ] {2655
+ if let Some(value) = value {2656
+ levels.events.insert(event_type, matrix_power_level(value)?);2657
+ }2658
+ }2659
+ let content = RoomPowerLevelsEventContent::try_from(levels).map_err(|source| {2660
+ CommandError::with_source(2661
+ CommandErrorKind::Other,2662
+ "normalizing power levels failed",2663
+ source,2664
+ )2665
+ })?;2666
+ room.send_state_event(content)2667
+ .await2668
+ .map(|_| ())2669
+ .map_err(|source| CommandError::from_sdk("updating power levels failed", source))2670
+}2671
+2672
+fn matrix_power_level(value: i64) -> Result<matrix_sdk::ruma::Int, CommandError> {2673
+ value.try_into().map_err(|source| {2674
+ CommandError::with_source(2675
+ CommandErrorKind::Other,2676
+ "power level is outside the Matrix integer range",2677
+ source,2678
+ )2679
+ })2680
+}2681
+2682
+fn create_room_request(2683
+ options: CreateRoomOptions,2684
+) -> matrix_sdk::ruma::api::client::room::create_room::v3::Request {2685
+ use matrix_sdk::ruma::api::client::room::{2686
+ Visibility as SdkVisibility,2687
+ create_room::v3::{Request, RoomPreset as SdkPreset},2688
+ };2689
+ let mut request = Request::new();2690
+ request.name = options.name;2691
+ request.topic = options.topic;2692
+ request.room_alias_name = options.alias_localpart;2693
+ request.invite = options.invite;2694
+ request.is_direct = options.direct;2695
+ request.visibility = match options.visibility {2696
+ RoomVisibility::Private => SdkVisibility::Private,2697
+ RoomVisibility::Public => SdkVisibility::Public,2698
+ };2699
+ request.preset = options.preset.map(|preset| match preset {2700
+ RoomPreset::PrivateChat => SdkPreset::PrivateChat,2701
+ RoomPreset::TrustedPrivateChat => SdkPreset::TrustedPrivateChat,2702
+ RoomPreset::PublicChat => SdkPreset::PublicChat,2703
+ });2704
+ request2705
+}2706
+2707
+async fn queue_media_upload(2708
+ room: Room,2709
+ upload: AttachmentUpload,2710
+ max_media_bytes: usize,2711
+) -> Result<(), CommandError> {2712
+ let (upload, mime, mut config) = prepare_upload(upload, max_media_bytes)?;2713
+ if let TimelineId::Thread { root_event_id, .. } = &upload.timeline_id {2714
+ config.reply = Some(SdkReply {2715
+ event_id: root_event_id.clone(),2716
+ enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),2717
+ add_mentions: AddMentions::No,2718
+ });2719
+ }2720
+ room.send_queue()2721
+ .send_attachment(upload.filename, mime, upload.bytes, config)2722
+ .await2723
+ .map(|_| ())2724
+ .map_err(|source| {2725
+ CommandError::with_source(2726
+ CommandErrorKind::MediaUnavailable,2727
+ "media upload could not be queued",2728
+ source,2729
+ )2730
+ })2731
+}2732
+2733
+async fn fetch_media(2734
+ client: Client,2735
+ registry: Arc<Mutex<MediaRegistry>>,2736
+ events: EventSender,2737
+ max_media_bytes: usize,2738
+ media_id: MediaId,2739
+ size: MediaSize,2740
+) -> Result<(), CommandError> {2741
+ let media = registry2742
+ .lock()2743
+ .await2744
+ .entries2745
+ .get(&media_id)2746
+ .cloned()2747
+ .ok_or_else(|| {2748
+ CommandError::new(2749
+ CommandErrorKind::MediaUnknown,2750
+ "media is not registered in a retained timeline",2751
+ )2752
+ })?;2753
+ let (source, format, mimetype, declared_size) = match size {2754
+ MediaSize::Full => (2755
+ media.full.source,2756
+ MediaFormat::File,2757
+ media.full.mimetype,2758
+ media.full.declared_size,2759
+ ),2760
+ MediaSize::Thumbnail => {2761
+ if let Some(thumbnail) = media.thumbnail {2762
+ (2763
+ thumbnail.source,2764
+ MediaFormat::File,2765
+ thumbnail.mimetype,2766
+ thumbnail.declared_size,2767
+ )2768
+ } else if matches!(2769
+ media.kind,2770
+ crate::MediaKind::Image | crate::MediaKind::Video2771
+ ) && matches!(2772
+ &media.full.source,2773
+ matrix_sdk::ruma::events::room::MediaSource::Plain(_)2774
+ ) {2775
+ (2776
+ media.full.source,2777
+ MediaFormat::Thumbnail(MediaThumbnailSettings::new(2778
+ 800_u32.into(),2779
+ 800_u32.into(),2780
+ )),2781
+ media.full.mimetype,2782
+ None,2783
+ )2784
+ } else {2785
+ return Err(CommandError::new(2786
+ CommandErrorKind::MediaUnavailable,2787
+ "the requested media size is unavailable",2788
+ ));2789
+ }2790
+ }2791
+ };2792
+ if declared_size.is_some_and(|size| size > max_media_bytes as u64) {2793
+ return Err(CommandError::new(2794
+ CommandErrorKind::MediaTooLarge,2795
+ "media exceeds the configured byte limit",2796
+ ));2797
+ }2798
+ let request = MediaRequestParameters { source, format };2799
+ if let Some(cached) = cached_media(&client, &request).await? {2800
+ if cached.len() > max_media_bytes {2801
+ remove_oversize_media(&client, &request).await;2802
+ return Err(CommandError::new(2803
+ CommandErrorKind::MediaTooLarge,2804
+ "cached media exceeds the configured byte limit",2805
+ ));2806
+ }2807
+ let bytes = maybe_decrypt_media(cached, &request, max_media_bytes)?;2808
+ events2809
+ .send(Event::MediaReady {2810
+ media_id,2811
+ size,2812
+ bytes: Arc::from(bytes),2813
+ mimetype: mimetype.unwrap_or_else(|| "application/octet-stream".into()),2814
+ })2815
+ .await2816
+ .map_err(send_error)?;2817
+ return Ok(());2818
+ }2819
+ let bytes = download_bounded(&client, &request, max_media_bytes).await?;2820
+ cache_media(&client, &request, &bytes).await;2821
+ let bytes = maybe_decrypt_media(bytes, &request, max_media_bytes)?;2822
+ events2823
+ .send(Event::MediaReady {2824
+ media_id,2825
+ size,2826
+ bytes: Arc::from(bytes),2827
+ mimetype: mimetype.unwrap_or_else(|| "application/octet-stream".into()),2828
+ })2829
+ .await2830
+ .map_err(send_error)2831
+}2832
+async fn cached_media(2833
+ client: &Client,2834
+ request: &MediaRequestParameters,2835
+) -> Result<Option<Vec<u8>>, CommandError> {2836
+ client2837
+ .media_store()2838
+ .lock()2839
+ .await2840
+ .map_err(|source| {2841
+ CommandError::with_source(2842
+ CommandErrorKind::Store,2843
+ "reading media cache failed",2844
+ source,2845
+ )2846
+ })?2847
+ .get_media_content(request)2848
+ .await2849
+ .map_err(|source| {2850
+ CommandError::with_source(2851
+ CommandErrorKind::Store,2852
+ "reading media cache failed",2853
+ source,2854
+ )2855
+ })2856
+}2857
+2858
+async fn remove_oversize_media(client: &Client, request: &MediaRequestParameters) {2859
+ if let Ok(store) = client.media_store().lock().await {2860
+ let _ = store.remove_media_content(request).await;2861
+ }2862
+}2863
+2864
+async fn cache_media(client: &Client, request: &MediaRequestParameters, bytes: &[u8]) {2865
+ if let Ok(store) = client.media_store().lock().await {2866
+ let _ = store2867
+ .add_media_content(2868
+ request,2869
+ bytes.to_vec(),2870
+ matrix_sdk::media::store::IgnoreMediaRetentionPolicy::No,2871
+ )2872
+ .await;2873
+ }2874
+}2875
+2876
+fn maybe_decrypt_media(2877
+ bytes: Vec<u8>,2878
+ request: &MediaRequestParameters,2879
+ max_media_bytes: usize,2880
+) -> Result<Vec<u8>, CommandError> {2881
+ let matrix_sdk::ruma::events::room::MediaSource::Encrypted(file) = &request.source else {2882
+ return Ok(bytes);2883
+ };2884
+ let info: MediaEncryptionInfo = (**file).clone().into();2885
+ let mut cursor = std::io::Cursor::new(bytes);2886
+ let mut decryptor = AttachmentDecryptor::new(&mut cursor, info).map_err(|source| {2887
+ CommandError::with_source(2888
+ CommandErrorKind::MediaUnavailable,2889
+ "media decryption failed",2890
+ source,2891
+ )2892
+ })?;2893
+ let mut decrypted = Vec::new();2894
+ let mut chunk = [0_u8; 8192];2895
+ loop {2896
+ match decryptor.read(&mut chunk) {2897
+ Ok(0) => break,2898
+ Ok(read) => {2899
+ if read > max_media_bytes.saturating_sub(decrypted.len()) {2900
+ return Err(CommandError::new(2901
+ CommandErrorKind::MediaTooLarge,2902
+ "decrypted media exceeds the configured byte limit",2903
+ ));2904
+ }2905
+ decrypted.extend_from_slice(&chunk[..read]);2906
+ }2907
+ Err(source) => {2908
+ return Err(CommandError::with_source(2909
+ CommandErrorKind::MediaUnavailable,2910
+ "media decryption failed",2911
+ source,2912
+ ));2913
+ }2914
+ }2915
+ }2916
+ Ok(decrypted)2917
+}2918
+2919
+async fn download_bounded(2920
+ client: &Client,2921
+ request: &MediaRequestParameters,2922
+ max_media_bytes: usize,2923
+) -> Result<Vec<u8>, CommandError> {2924
+ if is_local_media(&request.source) {2925
+ return Err(CommandError::new(2926
+ CommandErrorKind::MediaUnavailable,2927
+ "local media is not in the media cache",2928
+ ));2929
+ }2930
+ let http_request = media_http_request(client, request).await?;2931
+ let http_client = client.http_client().clone();2932
+ let request = http_client2933
+ .execute(request_from_http(http_request)?)2934
+ .await2935
+ .map_err(|source| {2936
+ CommandError::with_source(CommandErrorKind::Network, "media download failed", source)2937
+ })?;2938
+ let status = request.status();2939
+ if status == matrix_sdk::reqwest::StatusCode::UNAUTHORIZED {2940
+ client.refresh_access_token().await.map_err(|source| {2941
+ CommandError::from_refresh_token("refreshing media access token failed", source)2942
+ })?;2943
+ return Err(CommandError::new(2944
+ CommandErrorKind::Unauthenticated,2945
+ "media download was not authorized",2946
+ ));2947
+ }2948
+ if !status.is_success() {2949
+ return Err(CommandError::new(2950
+ CommandErrorKind::MediaUnavailable,2951
+ format!("media download failed with status {status}"),2952
+ ));2953
+ }2954
+ if request2955
+ .content_length()2956
+ .is_some_and(|length| length > max_media_bytes as u64)2957
+ {2958
+ return Err(CommandError::new(2959
+ CommandErrorKind::MediaTooLarge,2960
+ "media exceeds the configured byte limit",2961
+ ));2962
+ }2963
+ let mut stream = request.bytes_stream();2964
+ let mut bytes = Vec::new();2965
+ while let Some(chunk) = stream.next().await {2966
+ let chunk = chunk.map_err(|source| {2967
+ CommandError::with_source(CommandErrorKind::Network, "media download failed", source)2968
+ })?;2969
+ if chunk.len() > max_media_bytes.saturating_sub(bytes.len()) {2970
+ return Err(CommandError::new(2971
+ CommandErrorKind::MediaTooLarge,2972
+ "downloaded media exceeds the configured byte limit",2973
+ ));2974
+ }2975
+ bytes.extend_from_slice(&chunk);2976
+ }2977
+ Ok(bytes)2978
+}2979
+2980
+fn is_local_media(source: &matrix_sdk::ruma::events::room::MediaSource) -> bool {2981
+ let uri = match source {2982
+ matrix_sdk::ruma::events::room::MediaSource::Plain(uri) => uri,2983
+ matrix_sdk::ruma::events::room::MediaSource::Encrypted(file) => &file.url,2984
+ };2985
+ let Ok(server) = uri.server_name() else {2986
+ return false;2987
+ };2988
+ server.as_str() == "send-queue.localhost"2989
+}2990
+2991
+async fn media_http_request(2992
+ client: &Client,2993
+ request: &MediaRequestParameters,2994
+) -> Result<http::Request<Vec<u8>>, CommandError> {2995
+ use matrix_sdk::ruma::api::client::{authenticated_media, media};2996
+2997
+ let versions = client.supported_versions().await.map_err(|source| {2998
+ CommandError::with_source(2999
+ CommandErrorKind::Network,3000
+ "reading supported server versions failed",3001
+ source,3002
+ )3003
+ })?;3004
+ let use_auth =3005
+ authenticated_media::get_content::v1::Request::PATH_BUILDER.is_supported(&versions);3006
+ let base = client.homeserver().to_string();3007
+ let token = client.access_token();3008
+ let build = |request: http::Request<Vec<u8>>| -> Result<http::Request<Vec<u8>>, CommandError> {3009
+ let mut request = request;3010
+ if let Some(token) = token.as_deref() {3011
+ request.headers_mut().insert(3012
+ http::header::AUTHORIZATION,3013
+ format!("Bearer {token}").parse().map_err(|_| {3014
+ CommandError::new(3015
+ CommandErrorKind::Unauthenticated,3016
+ "stored access token is invalid",3017
+ )3018
+ })?,3019
+ );3020
+ }3021
+ Ok(request)3022
+ };3023
+ match &request.source {3024
+ matrix_sdk::ruma::events::room::MediaSource::Encrypted(file) => {3025
+ if use_auth {3026
+ let endpoint = authenticated_media::get_content::v1::Request::from_uri(&file.url)3027
+ .map_err(|source| {3028
+ CommandError::with_source(3029
+ CommandErrorKind::Other,3030
+ "media URI is invalid",3031
+ source,3032
+ )3033
+ })?;3034
+ endpoint3035
+ .try_into_http_request::<Vec<u8>>(3036
+ &base,3037
+ SendAccessToken::IfRequired(token.as_deref().unwrap_or("")),3038
+ Cow::Borrowed(&versions),3039
+ )3040
+ .map_err(|_| {3041
+ CommandError::new(3042
+ CommandErrorKind::Unauthenticated,3043
+ "media request could not be authorized",3044
+ )3045
+ })3046
+ .and_then(build)3047
+ } else {3048
+ #[allow(deprecated)]3049
+ let endpoint =3050
+ media::get_content::v3::Request::from_url(&file.url).map_err(|source| {3051
+ CommandError::with_source(3052
+ CommandErrorKind::Other,3053
+ "media URI is invalid",3054
+ source,3055
+ )3056
+ })?;3057
+ endpoint3058
+ .try_into_http_request::<Vec<u8>>(3059
+ &base,3060
+ SendAccessToken::None,3061
+ Cow::Borrowed(&versions),3062
+ )3063
+ .map_err(|_| {3064
+ CommandError::new(CommandErrorKind::Other, "media request is invalid")3065
+ })3066
+ .and_then(build)3067
+ }3068
+ }3069
+ matrix_sdk::ruma::events::room::MediaSource::Plain(uri) => {3070
+ if let MediaFormat::Thumbnail(settings) = &request.format {3071
+ if use_auth {3072
+ let mut endpoint =3073
+ authenticated_media::get_content_thumbnail::v1::Request::from_uri(3074
+ uri,3075
+ settings.width,3076
+ settings.height,3077
+ )3078
+ .map_err(|source| {3079
+ CommandError::with_source(3080
+ CommandErrorKind::Other,3081
+ "media URI is invalid",3082
+ source,3083
+ )3084
+ })?;3085
+ endpoint.method = Some(settings.method.clone());3086
+ endpoint.animated = Some(settings.animated);3087
+ endpoint3088
+ .try_into_http_request::<Vec<u8>>(3089
+ &base,3090
+ SendAccessToken::IfRequired(token.as_deref().unwrap_or("")),3091
+ Cow::Borrowed(&versions),3092
+ )3093
+ .map_err(|_| {3094
+ CommandError::new(3095
+ CommandErrorKind::Unauthenticated,3096
+ "media request could not be authorized",3097
+ )3098
+ })3099
+ .and_then(build)3100
+ } else {3101
+ #[allow(deprecated)]3102
+ let endpoint = {3103
+ let mut endpoint = media::get_content_thumbnail::v3::Request::from_url(3104
+ uri,3105
+ settings.width,3106
+ settings.height,3107
+ )3108
+ .map_err(|source| {3109
+ CommandError::with_source(3110
+ CommandErrorKind::Other,3111
+ "media URI is invalid",3112
+ source,3113
+ )3114
+ })?;3115
+ endpoint.method = Some(settings.method.clone());3116
+ endpoint.animated = Some(settings.animated);3117
+ endpoint3118
+ };3119
+ endpoint3120
+ .try_into_http_request::<Vec<u8>>(3121
+ &base,3122
+ SendAccessToken::None,3123
+ Cow::Borrowed(&versions),3124
+ )3125
+ .map_err(|_| {3126
+ CommandError::new(CommandErrorKind::Other, "media request is invalid")3127
+ })3128
+ .and_then(build)3129
+ }3130
+ } else if use_auth {3131
+ let endpoint = authenticated_media::get_content::v1::Request::from_uri(uri)3132
+ .map_err(|source| {3133
+ CommandError::with_source(3134
+ CommandErrorKind::Other,3135
+ "media URI is invalid",3136
+ source,3137
+ )3138
+ })?;3139
+ endpoint3140
+ .try_into_http_request::<Vec<u8>>(3141
+ &base,3142
+ SendAccessToken::IfRequired(token.as_deref().unwrap_or("")),3143
+ Cow::Borrowed(&versions),3144
+ )3145
+ .map_err(|_| {3146
+ CommandError::new(3147
+ CommandErrorKind::Unauthenticated,3148
+ "media request could not be authorized",3149
+ )3150
+ })3151
+ .and_then(build)3152
+ } else {3153
+ #[allow(deprecated)]3154
+ let endpoint =3155
+ media::get_content::v3::Request::from_url(uri).map_err(|source| {3156
+ CommandError::with_source(3157
+ CommandErrorKind::Other,3158
+ "media URI is invalid",3159
+ source,3160
+ )3161
+ })?;3162
+ endpoint3163
+ .try_into_http_request::<Vec<u8>>(3164
+ &base,3165
+ SendAccessToken::None,3166
+ Cow::Borrowed(&versions),3167
+ )3168
+ .map_err(|_| {3169
+ CommandError::new(CommandErrorKind::Other, "media request is invalid")3170
+ })3171
+ .and_then(build)3172
+ }3173
+ }3174
+ }3175
+}3176
+3177
+fn request_from_http(3178
+ request: http::Request<Vec<u8>>,3179
+) -> Result<matrix_sdk::reqwest::Request, CommandError> {3180
+ let (parts, _) = request.into_parts();3181
+ let mut builder = matrix_sdk::reqwest::Client::new().get(parts.uri.to_string());3182
+ for (name, value) in parts.headers.iter() {3183
+ builder = builder.header(name.as_str(), value.as_bytes());3184
+ }3185
+ builder3186
+ .build()3187
+ .map_err(|_| CommandError::new(CommandErrorKind::Network, "media request is invalid"))3188
+}3189
+3190
+impl RoomActor {3191
+ async fn run(mut self, current_user_id: Option<ruma::OwnedUserId>) {3192
+ let timeline = match self3193
+ .room3194
+ .timeline_builder()3195
+ .with_focus(TimelineFocus::Live {3196
+ hide_threaded_events: true,3197
+ })3198
+ .track_read_marker_and_receipts(TimelineReadReceiptTracking::AllEvents)3199
+ .build()3200
+ .await3201
+ {3202
+ Ok(timeline) => timeline,3203
+ Err(error) => {3204
+ tracing::warn!(error = %error, room_id = %self.room_id, "room actor could not build its timeline");3205
+ let message = format!("room timeline build failed: {error}");3206
+ for id in self.open_ids.drain(..) {3207
+ let _ = emit_command_result(3208
+ &self.events,3209
+ id,3210
+ CommandKind::Room,3211
+ Err(CommandError::new(3212
+ CommandErrorKind::Timeline,3213
+ message.clone(),3214
+ )),3215
+ )3216
+ .await;3217
+ }3218
+ self.receiver.close();3219
+ while let Some(request) = self.receiver.recv().await {3220
+ let (id, command) = match request {3221
+ RoomActorRequest::Opened { id } | RoomActorRequest::Close { id } => {3222
+ (id, CommandKind::Room)3223
+ }3224
+ RoomActorRequest::Execute { id, .. } => (id, CommandKind::Timeline),3225
+ };3226
+ let _ = emit_command_result(3227
+ &self.events,3228
+ id,3229
+ command,3230
+ Err(CommandError::new(3231
+ CommandErrorKind::Timeline,3232
+ "room timeline build failed",3233
+ )),3234
+ )3235
+ .await;3236
+ }3237
+ return;3238
+ }3239
+ };3240
+ let room_timeline_id = TimelineId::Room(self.room_id.clone());3241
+ let mut room_timeline = match TimelineSession::start(3242
+ room_timeline_id,3243
+ timeline,3244
+ self.events.clone(),3245
+ self.media.clone(),3246
+ current_user_id.clone(),3247
+ self.initial_history_limit,3248
+ )3249
+ .await3250
+ {3251
+ Ok(timeline) => timeline,3252
+ Err(_) => return,3253
+ };3254
+ let _ = self.engine_events.send(RoomActorEvent::Ready {3255
+ room_id: self.room_id.clone(),3256
+ generation: self.generation,3257
+ });3258
+ for id in self.open_ids.drain(..) {3259
+ if emit_command_result(&self.events, id, CommandKind::Room, Ok(()))3260
+ .await3261
+ .is_err()3262
+ {3263
+ return;3264
+ }3265
+ }3266
+3267
+ let (typing_guard, mut typing_rx) = self.room.subscribe_to_typing_notifications();3268
+ let typing_sender = self.events.clone();3269
+ let typing_room_id = self.room_id.clone();3270
+ let mut room_tasks = JoinSet::new();3271
+ room_tasks.spawn(async move {3272
+ loop {3273
+ match typing_rx.recv().await {3274
+ Ok(users) => {3275
+ let state = TypingState {3276
+ room_id: typing_room_id.clone(),3277
+ users,3278
+ };3279
+ if typing_sender3280
+ .send(Event::TypingChanged(state))3281
+ .await3282
+ .is_err()3283
+ {3284
+ tracing::debug!(3285
+ room_id = %typing_room_id,3286
+ "typing watcher stopped because the event receiver closed"3287
+ );3288
+ break;3289
+ }3290
+ }3291
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {3292
+ tracing::warn!(room_id = %typing_room_id, skipped, "typing watcher lagged");3293
+ }3294
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => {3295
+ tracing::debug!(room_id = %typing_room_id, "typing watcher stopped");3296
+ break;3297
+ }3298
+ }3299
+ }3300
+ });3301
+3302
+ enum QueuedThreadCommand {3303
+ Open {3304
+ id: CommandId,3305
+ },3306
+ Execute {3307
+ id: CommandId,3308
+ command: TimelineCommand,3309
+ },3310
+ }3311
+3312
+ struct OpeningThread {3313
+ generation: u64,3314
+ abort: AbortHandle,3315
+ task_id: TaskId,3316
+ queued: VecDeque<QueuedThreadCommand>,3317
+ }3318
+3319
+ let mut thread_timelines = HashMap::<OwnedEventId, TimelineSession>::new();3320
+ let mut opening_threads = HashMap::<OwnedEventId, OpeningThread>::new();3321
+ let mut thread_task_ids = HashMap::<TaskId, OwnedEventId>::new();3322
+ let mut thread_open_tasks = JoinSet::new();3323
+ let mut next_thread_generation = 0_u64;3324
+ let mut close_id = None;3325
+ let thread_queue_cap = self.receiver.max_capacity();3326
+ let mut queued_thread_count = 0;3327
+3328
+ 'actor: loop {3329
+ tokio::select! {3330
+ request = self.receiver.recv() => {3331
+ let Some(request) = request else {3332
+ tracing::debug!(room_id = %self.room_id, "room actor stopped because its request channel closed");3333
+ break;3334
+ };3335
+ match request {3336
+ RoomActorRequest::Opened { id } => {3337
+ if emit_command_result(3338
+ &self.events,3339
+ id,3340
+ CommandKind::Room,3341
+ Ok(()),3342
+ )3343
+ .await3344
+ .is_err()3345
+ {3346
+ break;3347
+ }3348
+ }3349
+ RoomActorRequest::Execute { id, command } => {3350
+ match command {3351
+ TimelineCommand::OpenThread { root_event_id, .. } => {3352
+ if thread_timelines.contains_key(&root_event_id) {3353
+ if emit_command_result(3354
+ &self.events,3355
+ id,3356
+ CommandKind::Timeline,3357
+ Ok(()),3358
+ )3359
+ .await3360
+ .is_err()3361
+ {3362
+ break 'actor;3363
+ }3364
+ } else if let Some(opening) =3365
+ opening_threads.get_mut(&root_event_id)3366
+ {3367
+ if queued_thread_count >= thread_queue_cap {3368
+ if emit_command_result(3369
+ &self.events,3370
+ id,3371
+ CommandKind::Timeline,3372
+ Err(CommandError::new(3373
+ CommandErrorKind::QueueFull,3374
+ "thread command queue is full",3375
+ )),3376
+ )3377
+ .await3378
+ .is_err()3379
+ {3380
+ break 'actor;3381
+ }3382
+ } else {3383
+ opening3384
+ .queued3385
+ .push_back(QueuedThreadCommand::Open { id });3386
+ queued_thread_count += 1;3387
+ }3388
+ } else if queued_thread_count >= thread_queue_cap {3389
+ if emit_command_result(3390
+ &self.events,3391
+ id,3392
+ CommandKind::Timeline,3393
+ Err(CommandError::new(3394
+ CommandErrorKind::QueueFull,3395
+ "thread command queue is full",3396
+ )),3397
+ )3398
+ .await3399
+ .is_err()3400
+ {3401
+ break 'actor;3402
+ }3403
+ } else {3404
+ let generation = next_thread_generation;3405
+ next_thread_generation =3406
+ next_thread_generation.wrapping_add(1);3407
+ let task_root = root_event_id.clone();3408
+ let room = self.room.clone();3409
+ let abort = thread_open_tasks.spawn(async move {3410
+ let result = room3411
+ .timeline_builder()3412
+ .with_focus(TimelineFocus::Thread {3413
+ root_event_id: root_event_id.clone(),3414
+ })3415
+ .track_read_marker_and_receipts(3416
+ TimelineReadReceiptTracking::AllEvents,3417
+ )3418
+ .build()3419
+ .await;3420
+ (root_event_id, generation, result)3421
+ });3422
+ let task_id = abort.id();3423
+ thread_task_ids.insert(task_id, task_root.clone());3424
+ opening_threads.insert(3425
+ task_root,3426
+ OpeningThread {3427
+ generation,3428
+ abort,3429
+ task_id,3430
+ queued: VecDeque::from([3431
+ QueuedThreadCommand::Open { id },3432
+ ]),3433
+ },3434
+ );3435
+ queued_thread_count += 1;3436
+ }3437
+ }3438
+ TimelineCommand::CloseThread { root_event_id, .. } => {3439
+ if let Some(timeline) = thread_timelines.remove(&root_event_id) {3440
+ timeline3441
+ .close(3442
+ CommandErrorKind::TimelineNotOpen,3443
+ "thread closed while pagination was in progress",3444
+ )3445
+ .await;3446
+ if emit_command_result(3447
+ &self.events,3448
+ id,3449
+ CommandKind::Timeline,3450
+ Ok(()),3451
+ )3452
+ .await3453
+ .is_err()3454
+ {3455
+ break 'actor;3456
+ }3457
+ } else if let Some(opening) =3458
+ opening_threads.remove(&root_event_id)3459
+ {3460
+ thread_task_ids.remove(&opening.task_id);3461
+ queued_thread_count -= opening.queued.len();3462
+ opening.abort.abort();3463
+ for queued in opening.queued {3464
+ let queued_id = match queued {3465
+ QueuedThreadCommand::Open { id } => id,3466
+ QueuedThreadCommand::Execute { id, .. } => id,3467
+ };3468
+ let _ = emit_command_result(3469
+ &self.events,3470
+ queued_id,3471
+ CommandKind::Timeline,3472
+ Err(CommandError::new(3473
+ CommandErrorKind::TimelineNotOpen,3474
+ "thread was closed before it finished opening",3475
+ )),3476
+ )3477
+ .await;3478
+ }3479
+ if emit_command_result(3480
+ &self.events,3481
+ id,3482
+ CommandKind::Timeline,3483
+ Ok(()),3484
+ )3485
+ .await3486
+ .is_err()3487
+ {3488
+ break 'actor;3489
+ }3490
+ } else if emit_command_result(3491
+ &self.events,3492
+ id,3493
+ CommandKind::Timeline,3494
+ Err(CommandError::new(3495
+ CommandErrorKind::TimelineNotOpen,3496
+ "thread timeline is not open",3497
+ )),3498
+ )3499
+ .await3500
+ .is_err()3501
+ {3502
+ break 'actor;3503
+ }3504
+ }3505
+ TimelineCommand::Paginate { timeline_id, limit } => {3506
+ match timeline_id {3507
+ TimelineId::Room(_) => {3508
+ room_timeline.paginate(limit, Some(id)).await;3509
+ }3510
+ TimelineId::Thread { room_id, root_event_id } => {3511
+ if let Some(timeline) =3512
+ thread_timelines.get_mut(&root_event_id)3513
+ {3514
+ timeline.paginate(limit, Some(id)).await;3515
+ } else if let Some(opening) =3516
+ opening_threads.get_mut(&root_event_id)3517
+ {3518
+ if queued_thread_count >= thread_queue_cap {3519
+ if emit_command_result(3520
+ &self.events,3521
+ id,3522
+ CommandKind::Timeline,3523
+ Err(CommandError::new(3524
+ CommandErrorKind::QueueFull,3525
+ "thread command queue is full",3526
+ )),3527
+ )3528
+ .await3529
+ .is_err()3530
+ {3531
+ break 'actor;3532
+ }3533
+ } else {3534
+ opening.queued.push_back(3535
+ QueuedThreadCommand::Execute {3536
+ id,3537
+ command:3538
+ TimelineCommand::Paginate {3539
+ timeline_id:3540
+ TimelineId::Thread {3541
+ room_id,3542
+ root_event_id,3543
+ },3544
+ limit,3545
+ },3546
+ },3547
+ );3548
+ queued_thread_count += 1;3549
+ }3550
+ } else if emit_command_result(3551
+ &self.events,3552
+ id,3553
+ CommandKind::Timeline,3554
+ Err(CommandError::new(3555
+ CommandErrorKind::TimelineNotOpen,3556
+ "timeline is not open",3557
+ )),3558
+ )3559
+ .await3560
+ .is_err()3561
+ {3562
+ break 'actor;3563
+ }3564
+ }3565
+ }3566
+ }3567
+ command => {3568
+ let thread_target = match command3569
+ .timeline_id()3570
+ .expect("non-lifecycle timeline command has a timeline id")3571
+ {3572
+ TimelineId::Room(_) => None,3573
+ TimelineId::Thread { root_event_id, .. } => {3574
+ Some(root_event_id.clone())3575
+ }3576
+ };3577
+ match thread_target {3578
+ None => {3579
+ let result = execute_timeline_command(3580
+ &mut room_timeline,3581
+ command,3582
+ None,3583
+ )3584
+ .await;3585
+ if emit_command_result(3586
+ &self.events,3587
+ id,3588
+ CommandKind::Timeline,3589
+ result,3590
+ )3591
+ .await3592
+ .is_err()3593
+ {3594
+ tracing::debug!(room_id = %self.room_id, "room actor stopped because the event receiver closed");3595
+ break;3596
+ }3597
+ }3598
+ Some(root_event_id) => {3599
+ if let Some(timeline) =3600
+ thread_timelines.get_mut(&root_event_id)3601
+ {3602
+ let thread_root =3603
+ timeline.id.thread_root().cloned();3604
+ let result = execute_timeline_command(3605
+ timeline,3606
+ command,3607
+ thread_root.as_ref(),3608
+ )3609
+ .await;3610
+ if emit_command_result(3611
+ &self.events,3612
+ id,3613
+ CommandKind::Timeline,3614
+ result,3615
+ )3616
+ .await3617
+ .is_err()3618
+ {3619
+ tracing::debug!(room_id = %self.room_id, "room actor stopped because the event receiver closed");3620
+ break;3621
+ }3622
+ } else if let Some(opening) =3623
+ opening_threads.get_mut(&root_event_id)3624
+ {3625
+ if queued_thread_count >= thread_queue_cap {3626
+ if emit_command_result(3627
+ &self.events,3628
+ id,3629
+ CommandKind::Timeline,3630
+ Err(CommandError::new(3631
+ CommandErrorKind::QueueFull,3632
+ "thread command queue is full",3633
+ )),3634
+ )3635
+ .await3636
+ .is_err()3637
+ {3638
+ break 'actor;3639
+ }3640
+ } else {3641
+ opening.queued.push_back(3642
+ QueuedThreadCommand::Execute { id, command },3643
+ );3644
+ queued_thread_count += 1;3645
+ }3646
+ } else if emit_command_result(3647
+ &self.events,3648
+ id,3649
+ CommandKind::Timeline,3650
+ Err(CommandError::new(3651
+ CommandErrorKind::TimelineNotOpen,3652
+ "timeline is not open",3653
+ )),3654
+ )3655
+ .await3656
+ .is_err()3657
+ {3658
+ tracing::debug!(room_id = %self.room_id, "room actor stopped because the event receiver closed");3659
+ break;3660
+ }3661
+ }3662
+ }3663
+ }3664
+ }3665
+ }3666
+ RoomActorRequest::Close { id } => {3667
+ close_id = Some(id);3668
+ break 'actor;3669
+ }3670
+ }3671
+ }3672
+ completed = thread_open_tasks.join_next(), if !thread_open_tasks.is_empty() => {3673
+ match completed {3674
+ Some(Ok((root_event_id, generation, result))) => {3675
+ if opening_threads3676
+ .get(&root_event_id)3677
+ .is_none_or(|opening| opening.generation != generation)3678
+ {3679
+ continue;3680
+ }3681
+ let opening = opening_threads3682
+ .remove(&root_event_id)3683
+ .expect("thread generation was checked");3684
+ thread_task_ids.remove(&opening.task_id);3685
+ queued_thread_count -= opening.queued.len();3686
+ match result {3687
+ Ok(timeline) => {3688
+ let timeline_id = TimelineId::thread(3689
+ self.room_id.clone(),3690
+ root_event_id.clone(),3691
+ );3692
+ match TimelineSession::start(3693
+ timeline_id,3694
+ timeline,3695
+ self.events.clone(),3696
+ self.media.clone(),3697
+ current_user_id.clone(),3698
+ self.initial_history_limit,3699
+ )3700
+ .await3701
+ {3702
+ Ok(session) => {3703
+ thread_timelines3704
+ .insert(root_event_id.clone(), session);3705
+ let timeline = thread_timelines3706
+ .get_mut(&root_event_id)3707
+ .expect("thread session was inserted");3708
+ for queued in opening.queued {3709
+ match queued {3710
+ QueuedThreadCommand::Open { id } => {3711
+ if emit_command_result(3712
+ &self.events,3713
+ id,3714
+ CommandKind::Timeline,3715
+ Ok(()),3716
+ )3717
+ .await3718
+ .is_err()3719
+ {3720
+ break 'actor;3721
+ }3722
+ }3723
+ QueuedThreadCommand::Execute {3724
+ id,3725
+ command,3726
+ } => match command {3727
+ TimelineCommand::Paginate {3728
+ limit,3729
+ ..3730
+ } => {3731
+ timeline3732
+ .paginate(limit, Some(id))3733
+ .await;3734
+ }3735
+ command => {3736
+ let thread_root = timeline3737
+ .id3738
+ .thread_root()3739
+ .cloned();3740
+ let result =3741
+ execute_timeline_command(3742
+ timeline,3743
+ command,3744
+ thread_root.as_ref(),3745
+ )3746
+ .await;3747
+ if emit_command_result(3748
+ &self.events,3749
+ id,3750
+ CommandKind::Timeline,3751
+ result,3752
+ )3753
+ .await3754
+ .is_err()3755
+ {3756
+ break 'actor;3757
+ }3758
+ }3759
+ },3760
+ }3761
+ }3762
+ }3763
+ Err(_) => break 'actor,3764
+ }3765
+ }3766
+ Err(error) => {3767
+ tracing::warn!(error = %error, room_id = %self.room_id, %root_event_id, "thread timeline build failed");3768
+ let message = format!("thread timeline build failed: {error}");3769
+ for queued in opening.queued {3770
+ let queued_id = match queued {3771
+ QueuedThreadCommand::Open { id } => id,3772
+ QueuedThreadCommand::Execute { id, .. } => id,3773
+ };3774
+ if emit_command_result(3775
+ &self.events,3776
+ queued_id,3777
+ CommandKind::Timeline,3778
+ Err(CommandError::new(3779
+ CommandErrorKind::Timeline,3780
+ message.clone(),3781
+ )),3782
+ )3783
+ .await3784
+ .is_err()3785
+ {3786
+ break 'actor;3787
+ }3788
+ }3789
+ }3790
+ }3791
+ }3792
+ Some(Err(error)) => {3793
+ if let Some(root_event_id) = thread_task_ids.remove(&error.id()) {3794
+ tracing::warn!(error = %error, room_id = %self.room_id, %root_event_id, "thread open task failed");3795
+ if let Some(opening) = opening_threads.remove(&root_event_id) {3796
+ queued_thread_count -= opening.queued.len();3797
+ for queued in opening.queued {3798
+ let queued_id = match queued {3799
+ QueuedThreadCommand::Open { id } => id,3800
+ QueuedThreadCommand::Execute { id, .. } => id,3801
+ };3802
+ if emit_command_result(3803
+ &self.events,3804
+ queued_id,3805
+ CommandKind::Timeline,3806
+ Err(CommandError::new(3807
+ CommandErrorKind::Timeline,3808
+ "thread timeline build task failed",3809
+ )),3810
+ )3811
+ .await3812
+ .is_err()3813
+ {3814
+ break 'actor;3815
+ }3816
+ }3817
+ }3818
+ } else {3819
+ tracing::debug!(error = %error, room_id = %self.room_id, "thread open task was aborted");3820
+ }3821
+ }3822
+ None => {}3823
+ }3824
+ }3825
+ completed = room_tasks.join_next(), if !room_tasks.is_empty() => {3826
+ match completed {3827
+ Some(Err(error)) => {3828
+ tracing::warn!(error = %error, room_id = %self.room_id, "room actor task failed");3829
+ }3830
+ Some(Ok(())) => {3831
+ tracing::warn!(room_id = %self.room_id, "room actor task stopped unexpectedly");3832
+ }3833
+ None => {}3834
+ }3835
+ }3836
+ }3837
+ }3838
+3839
+ drop(typing_guard);3840
+ room_tasks.abort_all();3841
+ while room_tasks.join_next().await.is_some() {}3842
+ thread_task_ids.clear();3843
+ let pending_opens = opening_threads3844
+ .drain()3845
+ .flat_map(|(_, opening)| opening.queued)3846
+ .collect::<Vec<_>>();3847
+ for queued in pending_opens {3848
+ let queued_id = match queued {3849
+ QueuedThreadCommand::Open { id } => id,3850
+ QueuedThreadCommand::Execute { id, .. } => id,3851
+ };3852
+ let _ = emit_command_result(3853
+ &self.events,3854
+ queued_id,3855
+ CommandKind::Timeline,3856
+ Err(CommandError::new(3857
+ CommandErrorKind::RoomNotOpen,3858
+ "room closed while thread timeline was opening",3859
+ )),3860
+ )3861
+ .await;3862
+ }3863
+ thread_open_tasks.abort_all();3864
+ while thread_open_tasks.join_next().await.is_some() {}3865
+ for (_, timeline) in thread_timelines {3866
+ timeline3867
+ .close(3868
+ CommandErrorKind::RoomNotOpen,3869
+ "room closed while thread pagination was in progress",3870
+ )3871
+ .await;3872
+ }3873
+ room_timeline3874
+ .close(3875
+ CommandErrorKind::RoomNotOpen,3876
+ "room closed while pagination was in progress",3877
+ )3878
+ .await;3879
+ if let Some(id) = close_id3880
+ && emit_command_result(&self.events, id, CommandKind::Room, Ok(()))3881
+ .await3882
+ .is_err()3883
+ {3884
+ tracing::debug!(room_id = %self.room_id, "room close acknowledgement was not delivered");3885
+ }3886
+ }3887
+}3888
+3889
+async fn execute_timeline_command(3890
+ session: &mut TimelineSession,3891
+ command: TimelineCommand,3892
+ thread_root: Option<&OwnedEventId>,3893
+) -> Result<(), CommandError> {3894
+ let timeline = &session.timeline;3895
+ match command {3896
+ TimelineCommand::OpenThread { .. } | TimelineCommand::CloseThread { .. } => {3897
+ Err(CommandError::new(3898
+ CommandErrorKind::Other,3899
+ "thread lifecycle command was misrouted",3900
+ ))3901
+ }3902
+ TimelineCommand::Resync { .. } => session.resync().await.map_err(|_| {3903
+ CommandError::new(3904
+ CommandErrorKind::Timeline,3905
+ "timeline resync could not emit a reset",3906
+ )3907
+ }),3908
+ TimelineCommand::Paginate { .. } => Ok(()),3909
+ TimelineCommand::Send { content, .. } => {3910
+ let body = match &content {3911
+ MessageContent::Text { content, .. }3912
+ | MessageContent::Emote { content, .. }3913
+ | MessageContent::Reply { content, .. } => &content.body,3914
+ };3915
+ if body.trim().is_empty() {3916
+ return Err(CommandError::new(3917
+ CommandErrorKind::EmptyBody,3918
+ "message body is empty",3919
+ ));3920
+ }3921
+ match content {3922
+ MessageContent::Text { content, mentions } => timeline3923
+ .send(3924
+ message_content(content, mentions, false)3925
+ .with_relation(None)3926
+ .into(),3927
+ )3928
+ .await3929
+ .map(|_| ())3930
+ .map_err(timeline_error),3931
+ MessageContent::Emote { content, mentions } => timeline3932
+ .send(3933
+ message_content(content, mentions, true)3934
+ .with_relation(None)3935
+ .into(),3936
+ )3937
+ .await3938
+ .map(|_| ())3939
+ .map_err(timeline_error),3940
+ MessageContent::Reply {3941
+ event_id,3942
+ content,3943
+ mentions,3944
+ notify_replied_to,3945
+ } => {3946
+ let content = timeline3947
+ .room()3948
+ .make_reply_event(3949
+ message_content(content, mentions, false),3950
+ SdkReply {3951
+ event_id,3952
+ enforce_thread: if thread_root.is_some() {3953
+ EnforceThread::Threaded(ReplyWithinThread::Yes)3954
+ } else {3955
+ EnforceThread::MaybeThreaded3956
+ },3957
+ add_mentions: if notify_replied_to {3958
+ AddMentions::Yes3959
+ } else {3960
+ AddMentions::No3961
+ },3962
+ },3963
+ )3964
+ .await3965
+ .map_err(|source| {3966
+ CommandError::with_source(3967
+ CommandErrorKind::Timeline,3968
+ "reply operation failed",3969
+ source,3970
+ )3971
+ })?;3972
+ if let Some(thread_root) = thread_root3973
+ && !matches!(3974
+ content.relates_to.as_ref(),3975
+ Some(Relation::Thread(thread)) if &thread.event_id == thread_root3976
+ )3977
+ {3978
+ return Err(CommandError::new(3979
+ CommandErrorKind::Timeline,3980
+ "reply target does not belong to the selected thread",3981
+ ));3982
+ }3983
+ timeline3984
+ .send(content.into())3985
+ .await3986
+ .map(|_| ())3987
+ .map_err(timeline_error)3988
+ }3989
+ }3990
+ }3991
+ TimelineCommand::Edit {3992
+ event_id, content, ..3993
+ } => {3994
+ let (content, mentions, emote) = match content {3995
+ MessageEditContent::Text { content, mentions } => (content, mentions, false),3996
+ MessageEditContent::Emote { content, mentions } => (content, mentions, true),3997
+ };3998
+ if content.body.trim().is_empty() {3999
+ return Err(CommandError::new(4000
+ CommandErrorKind::EmptyBody,4001
+ "message body is empty",4002
+ ));4003
+ }4004
+ timeline4005
+ .edit(4006
+ &TimelineEventItemId::EventId(event_id),4007
+ EditedContent::RoomMessage(message_content(content, mentions, emote)),4008
+ )4009
+ .await4010
+ .map_err(timeline_error)4011
+ }4012
+ TimelineCommand::Redact {4013
+ event_id, reason, ..4014
+ } => timeline4015
+ .redact(&TimelineEventItemId::EventId(event_id), reason.as_deref())4016
+ .await4017
+ .map_err(timeline_error),4018
+ TimelineCommand::ToggleReaction { event_id, key, .. } => timeline4019
+ .toggle_reaction(&TimelineEventItemId::EventId(event_id), &key)4020
+ .await4021
+ .map(|_| ())4022
+ .map_err(timeline_error),4023
+ }4024
+}4025
+4026
+fn message_content(4027
+ content: RichText,4028
+ mentions: MessageMentions,4029
+ emote: bool,4030
+) -> RoomMessageEventContentWithoutRelation {4031
+ let content = match content.formatted_body {4032
+ Some(formatted_body) if emote => {4033
+ RoomMessageEventContentWithoutRelation::emote_html(content.body, formatted_body)4034
+ }4035
+ Some(formatted_body) => {4036
+ RoomMessageEventContentWithoutRelation::text_html(content.body, formatted_body)4037
+ }4038
+ None if emote => RoomMessageEventContentWithoutRelation::emote_plain(content.body),4039
+ None => RoomMessageEventContentWithoutRelation::text_plain(content.body),4040
+ };4041
+ let content = content.add_mentions(Mentions::with_user_ids(mentions.user_ids));4042
+ if mentions.room {4043
+ content.add_mentions(Mentions::with_room_mention())4044
+ } else {4045
+ content4046
+ }4047
+}4048
+4049
+async fn run_pagination(request: PaginationRequest) {4050
+ let PaginationRequest {4051
+ timeline,4052
+ events,4053
+ timeline_id,4054
+ reached_start,4055
+ limit,4056
+ command_id,4057
+ results,4058
+ } = request;4059
+ // One worker per timeline preserves pagination order without accumulating waiting tasks.4060
+ if reached_start.load(Ordering::Acquire) {4061
+ let _ = events4062
+ .send(Event::HistoryChanged {4063
+ timeline_id,4064
+ state: HistoryState::StartReached,4065
+ })4066
+ .await;4067
+ if let Some(id) = command_id {4068
+ let _ = results.send((id, Ok(())));4069
+ }4070
+ return;4071
+ }4072
+ if events4073
+ .send(Event::HistoryChanged {4074
+ timeline_id: timeline_id.clone(),4075
+ state: HistoryState::Loading,4076
+ })4077
+ .await4078
+ .is_err()4079
+ {4080
+ if let Some(id) = command_id {4081
+ let _ = results.send((4082
+ id,4083
+ Err(CommandError::new(4084
+ CommandErrorKind::EventReceiverClosed,4085
+ "event receiver has closed",4086
+ )),4087
+ ));4088
+ }4089
+ return;4090
+ }4091
+ let result = timeline4092
+ .paginate_backwards(limit.max(1))4093
+ .await4094
+ .map_err(timeline_error);4095
+ match result {4096
+ Ok(at_start) => {4097
+ reached_start.store(at_start, Ordering::Release);4098
+ let _ = events4099
+ .send(Event::HistoryChanged {4100
+ timeline_id,4101
+ state: if at_start {4102
+ HistoryState::StartReached4103
+ } else {4104
+ HistoryState::Idle4105
+ },4106
+ })4107
+ .await;4108
+ if let Some(id) = command_id {4109
+ let _ = results.send((id, Ok(())));4110
+ }4111
+ }4112
+ Err(error) => {4113
+ let _ = events4114
+ .send(Event::HistoryChanged {4115
+ timeline_id,4116
+ state: HistoryState::Failed(error.to_string()),4117
+ })4118
+ .await;4119
+ if let Some(id) = command_id {4120
+ let _ = results.send((id, Err(error)));4121
+ }4122
+ }4123
+ }4124
+}4125
+4126
+async fn emit_command_result_with_room(4127
+ events: &EventSender,4128
+ id: CommandId,4129
+ room_id: OwnedRoomId,4130
+) -> Result<(), SendEventError> {4131
+ events4132
+ .send(Event::CommandSucceededWithRoom {4133
+ id,4134
+ command: CommandKind::Room,4135
+ room_id,4136
+ })4137
+ .await4138
+}4139
+4140
+async fn emit_command_result(4141
+ events: &EventSender,4142
+ id: CommandId,4143
+ command: CommandKind,4144
+ result: Result<(), CommandError>,4145
+) -> Result<(), SendEventError> {4146
+ let event = match result {4147
+ Ok(()) => Event::CommandSucceeded { id, command },4148
+ Err(error) => {4149
+ tracing::warn!(?id, ?command, error = ?error, "room actor command failed");4150
+ Event::CommandFailed(CommandFailure {4151
+ id,4152
+ command,4153
+ kind: error.kind,4154
+ message: error.to_string(),4155
+ })4156
+ }4157
+ };4158
+ events.send(event).await4159
+}4160
+4161
+fn send_error(source: SendEventError) -> CommandError {4162
+ CommandError::with_source(4163
+ CommandErrorKind::EventReceiverClosed,4164
+ "event receiver has closed",4165
+ source,4166
+ )4167
+}4168
+4169
+fn timeline_error(source: matrix_sdk_ui::timeline::Error) -> CommandError {4170
+ CommandError::with_source(4171
+ CommandErrorKind::Timeline,4172
+ "timeline operation failed",4173
+ source,4174
+ )4175
+}@@ -0,0 +1,170 @@
1
+use thiserror::Error;2
+3
+use matrix_sdk::{4
+ HttpError, RefreshTokenError,5
+ authentication::oauth::error::{6
+ OAuthAuthorizationCodeError, OAuthDiscoveryError, OAuthError, OAuthTokenRevocationError,7
+ RequestTokenError,8
+ },9
+ encryption::{10
+ identities::{ManualVerifyError, RequestVerificationError},11
+ recovery::RecoveryError,12
+ secret_storage::{ImportError, SecretStorageError},13
+ },14
+};15
+16
+use crate::CommandErrorKind;17
+18
+#[derive(Debug, Error)]19
+#[error("{message}")]20
+pub(crate) struct CommandError {21
+ pub(crate) kind: CommandErrorKind,22
+ message: String,23
+ #[source]24
+ source: Option<Box<dyn std::error::Error + Send + Sync>>,25
+}26
+27
+impl CommandError {28
+ pub(crate) fn new(kind: CommandErrorKind, message: impl Into<String>) -> Self {29
+ Self {30
+ kind,31
+ message: message.into(),32
+ source: None,33
+ }34
+ }35
+36
+ pub(crate) fn with_source(37
+ kind: CommandErrorKind,38
+ message: impl Into<String>,39
+ source: impl std::error::Error + Send + Sync + 'static,40
+ ) -> Self {41
+ Self {42
+ kind,43
+ message: message.into(),44
+ source: Some(Box::new(source)),45
+ }46
+ }47
+48
+ pub(crate) fn from_sdk(message: impl Into<String>, source: matrix_sdk::Error) -> Self {49
+ Self::with_source(classify_sdk_error(&source), message, source)50
+ }51
+52
+ pub(crate) fn from_http(message: impl Into<String>, source: HttpError) -> Self {53
+ Self::with_source(classify_http_error(&source), message, source)54
+ }55
+ pub(crate) fn from_refresh_token(56
+ message: impl Into<String>,57
+ source: RefreshTokenError,58
+ ) -> Self {59
+ Self::with_source(classify_refresh_token_error(&source), message, source)60
+ }61
+62
+ pub(crate) fn from_recovery(message: impl Into<String>, source: RecoveryError) -> Self {63
+ let kind = match &source {64
+ RecoveryError::Sdk(source) => classify_sdk_error(source),65
+ RecoveryError::SecretStorage(SecretStorageError::Sdk(source)) => {66
+ classify_sdk_error(source)67
+ }68
+ RecoveryError::SecretStorage(SecretStorageError::ImportError { error, .. }) => {69
+ match error {70
+ ImportError::Sdk(source) => classify_sdk_error(source),71
+ _ => CommandErrorKind::RecoveryRejected,72
+ }73
+ }74
+ RecoveryError::SecretStorage(SecretStorageError::Verification(source)) => {75
+ match source {76
+ ManualVerifyError::Http(source) => classify_http_error(source),77
+ _ => CommandErrorKind::RecoveryRejected,78
+ }79
+ }80
+ RecoveryError::SecretStorage(SecretStorageError::SecretStorageKey(_)) => {81
+ CommandErrorKind::RecoveryRejected82
+ }83
+ RecoveryError::SecretStorage(SecretStorageError::Storage(_)) => CommandErrorKind::Store,84
+ RecoveryError::BackupExistsOnServer | RecoveryError::SecretStorage(_) => {85
+ CommandErrorKind::RecoveryRejected86
+ }87
+ };88
+ Self::with_source(kind, message, source)89
+ }90
+91
+ pub(crate) fn from_verification_request(92
+ message: impl Into<String>,93
+ source: RequestVerificationError,94
+ ) -> Self {95
+ let kind = match &source {96
+ RequestVerificationError::Sdk(source) => classify_sdk_error(source),97
+ RequestVerificationError::RoomCreation(_) => CommandErrorKind::Encryption,98
+ };99
+ Self::with_source(kind, message, source)100
+ }101
+}102
+103
+pub(crate) fn classify_sdk_error(source: &matrix_sdk::Error) -> CommandErrorKind {104
+ match source {105
+ matrix_sdk::Error::Http(source) => classify_http_error(source),106
+ matrix_sdk::Error::OAuth(source) => classify_oauth_error(source),107
+ matrix_sdk::Error::AuthenticationRequired => CommandErrorKind::Unauthenticated,108
+ matrix_sdk::Error::BadCryptoStoreState109
+ | matrix_sdk::Error::CryptoStoreError(_)110
+ | matrix_sdk::Error::CrossProcessLockError(_)111
+ | matrix_sdk::Error::StateStore(_)112
+ | matrix_sdk::Error::EventCacheStore(_)113
+ | matrix_sdk::Error::MediaStore(_) => CommandErrorKind::Store,114
+ matrix_sdk::Error::NoOlmMachine115
+ | matrix_sdk::Error::OlmError(_)116
+ | matrix_sdk::Error::MegolmError(_)117
+ | matrix_sdk::Error::DecryptorError(_) => CommandErrorKind::Encryption,118
+ matrix_sdk::Error::Media(_) => CommandErrorKind::MediaUnavailable,119
+ matrix_sdk::Error::Timeout => CommandErrorKind::Network,120
+ _ => CommandErrorKind::Other,121
+ }122
+}123
+124
+fn classify_http_error(source: &HttpError) -> CommandErrorKind {125
+ match source {126
+ HttpError::Reqwest(_) => CommandErrorKind::Network,127
+ HttpError::Api(_) => CommandErrorKind::Server,128
+ HttpError::RefreshToken(source) => classify_refresh_token_error(source),129
+ HttpError::Cached(source) => classify_http_error(source),130
+ _ => CommandErrorKind::Other,131
+ }132
+}133
+134
+fn classify_refresh_token_error(source: &RefreshTokenError) -> CommandErrorKind {135
+ match source {136
+ RefreshTokenError::RefreshTokenRequired => CommandErrorKind::Unauthenticated,137
+ RefreshTokenError::MatrixAuth(source) => classify_http_error(source),138
+ RefreshTokenError::OAuth(source) => classify_oauth_error(source),139
+ }140
+}141
+142
+fn classify_oauth_error(source: &OAuthError) -> CommandErrorKind {143
+ let classify_token_request_error = |source: &_| match source {144
+ RequestTokenError::Request(_) => CommandErrorKind::Network,145
+ RequestTokenError::ServerResponse(_) => CommandErrorKind::Unauthenticated,146
+ _ => CommandErrorKind::Other,147
+ };148
+ match source {149
+ OAuthError::Discovery(source) => match source {150
+ OAuthDiscoveryError::Http(source) => classify_http_error(source),151
+ OAuthDiscoveryError::Oidc(source) => classify_token_request_error(source),152
+ _ => CommandErrorKind::Unauthenticated,153
+ },154
+ OAuthError::RefreshToken(source) => classify_token_request_error(source),155
+ OAuthError::AuthorizationCode(OAuthAuthorizationCodeError::RequestToken(source)) => {156
+ classify_token_request_error(source)157
+ }158
+ OAuthError::AuthorizationCode(_) => CommandErrorKind::Unauthenticated,159
+ OAuthError::Logout(source) => match source {160
+ OAuthTokenRevocationError::Revoke(RequestTokenError::Request(_)) => {161
+ CommandErrorKind::Network162
+ }163
+ OAuthTokenRevocationError::Revoke(RequestTokenError::ServerResponse(_)) => {164
+ CommandErrorKind::Unauthenticated165
+ }166
+ _ => CommandErrorKind::Other,167
+ },168
+ _ => CommandErrorKind::Unauthenticated,169
+ }170
+}@@ -0,0 +1,42 @@
1
+//! A compact application backend contract on top of the Matrix Rust SDK.2
+3
+mod encryption;4
+mod engine;5
+mod error;6
+mod model;7
+mod normalise;8
+mod transport;9
+mod uiaa;10
+mod upload;11
+12
+#[cfg(feature = "sqlite")]13
+mod account;14
+15
+#[cfg(feature = "sqlite")]16
+pub use account::{17
+ AccountError, OAuthApplicationType, OAuthLogin, PasswordLogin, PendingOAuthLogin, Session,18
+ SessionRecord, StoreConfig,19
+};20
+21
+pub use engine::{Engine, EngineConfig, EngineError};22
+pub use model::{23
+ AccountCommand, AttachmentUpload, Command, CommandErrorKind, CommandFailure, CommandId,24
+ CommandKind, ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState,25
+ EngineErrorKind, EngineFailure, Event, HistoryState, KeyBackupState, LifecycleCommand,26
+ MediaCommand, MediaDescriptor, MediaId, MediaKind, MediaSize, MemberFilter, MemberMembership,27
+ MemberSummary, MessageContent, MessageEditContent, MessageMentions, NotificationCommand,28
+ OwnDevice, PowerLevelChanges, PushNotification, PushNotificationEvent, PushNotificationRoom,29
+ PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState, ReplyReference,30
+ RichText, RoomCategory, RoomCommand, RoomListFilter, RoomMembership, RoomNotificationMode,31
+ RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState,32
+ ThreadSummary, TimelineCommand, TimelineDiff, TimelineDiffError, TimelineId, TimelineItem,33
+ TimelineItemId, TimelineMessage, TypingState, UploadDetails, UploadThumbnail,34
+ VerificationAction, VerificationFailure, VerificationState, VerificationTarget,35
+};36
+pub use transport::{37
+ ClientHandle, ClientQueryError, CommandResultError, EventStream, SendCommandError,38
+};39
+pub use uiaa::{40
+ InteractiveAuth, InteractiveAuthChallenge, InteractiveAuthFailure, InteractiveAuthFlow,41
+ InteractiveAuthOutcome,42
+};@@ -0,0 +1,1368 @@
1
+use std::{fmt, sync::Arc};2
+3
+use ruma::{4
+ OwnedDeviceId, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedRoomOrAliasId,5
+ OwnedServerName, OwnedTransactionId, OwnedUserId,6
+ events::{AnySyncTimelineEvent, room::member::StrippedRoomMemberEvent},7
+ serde::Raw,8
+};9
+#[cfg(feature = "serde")]10
+use serde::{Deserialize, Serialize};11
+12
+#[derive(Debug, Eq, PartialEq)]13
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]14
+#[non_exhaustive]15
+pub enum Command {16
+ Account(AccountCommand),17
+ Room(RoomCommand),18
+ Timeline(TimelineCommand),19
+ Media(MediaCommand),20
+ Encryption(EncryptionCommand),21
+ Notification(NotificationCommand),22
+ Lifecycle(LifecycleCommand),23
+}24
+25
+#[derive(Debug, Eq, PartialEq)]26
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]27
+#[non_exhaustive]28
+pub enum AccountCommand {29
+ Logout,30
+ RenameDevice {31
+ device_id: OwnedDeviceId,32
+ display_name: String,33
+ },34
+ DeleteDevices {35
+ device_ids: Vec<OwnedDeviceId>,36
+ auth: Option<crate::InteractiveAuth>,37
+ },38
+}39
+40
+#[derive(Clone, Debug, Eq, PartialEq)]41
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]42
+#[non_exhaustive]43
+pub enum RoomCommand {44
+ Open(OwnedRoomId),45
+ Close(OwnedRoomId),46
+ Join {47
+ room: OwnedRoomOrAliasId,48
+ via: Vec<OwnedServerName>,49
+ },50
+ Leave(OwnedRoomId),51
+ Forget(OwnedRoomId),52
+ AcceptInvite(OwnedRoomId),53
+ RejectInvite(OwnedRoomId),54
+ InviteUser {55
+ room_id: OwnedRoomId,56
+ user_id: OwnedUserId,57
+ },58
+ KickUser {59
+ room_id: OwnedRoomId,60
+ user_id: OwnedUserId,61
+ reason: Option<String>,62
+ },63
+ BanUser {64
+ room_id: OwnedRoomId,65
+ user_id: OwnedUserId,66
+ reason: Option<String>,67
+ },68
+ UnbanUser {69
+ room_id: OwnedRoomId,70
+ user_id: OwnedUserId,71
+ reason: Option<String>,72
+ },73
+ SetPowerLevels {74
+ room_id: OwnedRoomId,75
+ changes: PowerLevelChanges,76
+ },77
+ SetUserPowerLevel {78
+ room_id: OwnedRoomId,79
+ user_id: OwnedUserId,80
+ level: i64,81
+ },82
+ Create(CreateRoomOptions),83
+ CreateDirect(OwnedUserId),84
+ CancelSend {85
+ room_id: OwnedRoomId,86
+ transaction_id: OwnedTransactionId,87
+ },88
+ RetrySend {89
+ room_id: OwnedRoomId,90
+ transaction_id: OwnedTransactionId,91
+ },92
+ MarkRead {93
+ timeline_id: TimelineId,94
+ event_id: OwnedEventId,95
+ },96
+ SetTyping {97
+ room_id: OwnedRoomId,98
+ typing: bool,99
+ },100
+}101
+102
+#[derive(Clone, Debug, Default, Eq, PartialEq)]103
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]104
+pub struct CreateRoomOptions {105
+ pub name: Option<String>,106
+ pub topic: Option<String>,107
+ pub alias_localpart: Option<String>,108
+ pub invite: Vec<OwnedUserId>,109
+ pub direct: bool,110
+ pub visibility: RoomVisibility,111
+ pub preset: Option<RoomPreset>,112
+}113
+114
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]115
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]116
+pub enum RoomVisibility {117
+ #[default]118
+ Private,119
+ Public,120
+}121
+122
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]123
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]124
+pub enum RoomPreset {125
+ PrivateChat,126
+ TrustedPrivateChat,127
+ PublicChat,128
+}129
+130
+#[derive(Clone, Debug, Eq, PartialEq)]131
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]132
+#[non_exhaustive]133
+pub enum TimelineCommand {134
+ OpenThread {135
+ room_id: OwnedRoomId,136
+ root_event_id: OwnedEventId,137
+ },138
+ CloseThread {139
+ room_id: OwnedRoomId,140
+ root_event_id: OwnedEventId,141
+ },142
+ /// Re-emit an authoritative reset for an open room or thread without143
+ /// closing it. The reset arrives before this command's terminal result.144
+ Resync {145
+ timeline_id: TimelineId,146
+ },147
+ Paginate {148
+ timeline_id: TimelineId,149
+ limit: u16,150
+ },151
+ Send {152
+ timeline_id: TimelineId,153
+ content: MessageContent,154
+ },155
+ Edit {156
+ timeline_id: TimelineId,157
+ event_id: OwnedEventId,158
+ content: MessageEditContent,159
+ },160
+ Redact {161
+ timeline_id: TimelineId,162
+ event_id: OwnedEventId,163
+ reason: Option<String>,164
+ },165
+ ToggleReaction {166
+ timeline_id: TimelineId,167
+ event_id: OwnedEventId,168
+ key: String,169
+ },170
+}171
+172
+impl TimelineCommand {173
+ #[must_use]174
+ pub const fn room_id(&self) -> &OwnedRoomId {175
+ match self {176
+ Self::OpenThread { room_id, .. } | Self::CloseThread { room_id, .. } => room_id,177
+ Self::Resync { timeline_id }178
+ | Self::Paginate { timeline_id, .. }179
+ | Self::Send { timeline_id, .. }180
+ | Self::Edit { timeline_id, .. }181
+ | Self::Redact { timeline_id, .. }182
+ | Self::ToggleReaction { timeline_id, .. } => timeline_id.room_id(),183
+ }184
+ }185
+186
+ #[must_use]187
+ pub const fn timeline_id(&self) -> Option<&TimelineId> {188
+ match self {189
+ Self::OpenThread { .. } | Self::CloseThread { .. } => None,190
+ Self::Resync { timeline_id }191
+ | Self::Paginate { timeline_id, .. }192
+ | Self::Send { timeline_id, .. }193
+ | Self::Edit { timeline_id, .. }194
+ | Self::Redact { timeline_id, .. }195
+ | Self::ToggleReaction { timeline_id, .. } => Some(timeline_id),196
+ }197
+ }198
+}199
+200
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]201
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]202
+#[non_exhaustive]203
+pub enum TimelineId {204
+ Room(OwnedRoomId),205
+ Thread {206
+ room_id: OwnedRoomId,207
+ root_event_id: OwnedEventId,208
+ },209
+}210
+211
+impl TimelineId {212
+ #[must_use]213
+ pub const fn room_id(&self) -> &OwnedRoomId {214
+ match self {215
+ Self::Room(room_id) | Self::Thread { room_id, .. } => room_id,216
+ }217
+ }218
+219
+ #[must_use]220
+ pub fn thread(room_id: OwnedRoomId, root_event_id: OwnedEventId) -> Self {221
+ Self::Thread {222
+ room_id,223
+ root_event_id,224
+ }225
+ }226
+227
+ #[must_use]228
+ pub fn thread_root(&self) -> Option<&OwnedEventId> {229
+ match self {230
+ Self::Room(_) => None,231
+ Self::Thread { root_event_id, .. } => Some(root_event_id),232
+ }233
+ }234
+}235
+236
+impl From<OwnedRoomId> for TimelineId {237
+ fn from(room_id: OwnedRoomId) -> Self {238
+ Self::Room(room_id)239
+ }240
+}241
+242
+#[derive(Clone, Debug, Eq, PartialEq)]243
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]244
+#[non_exhaustive]245
+pub enum MediaCommand {246
+ Fetch {247
+ id: MediaId,248
+ size: MediaSize,249
+ },250
+ Upload {251
+ timeline_id: TimelineId,252
+ filename: String,253
+ mimetype: String,254
+ bytes: Vec<u8>,255
+ caption: Option<String>,256
+ },257
+ UploadAttachment(AttachmentUpload),258
+}259
+260
+#[derive(Clone, Debug, Eq, PartialEq)]261
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]262
+pub struct AttachmentUpload {263
+ pub timeline_id: TimelineId,264
+ pub filename: String,265
+ pub mimetype: String,266
+ pub bytes: Vec<u8>,267
+ pub caption: Option<String>,268
+ pub details: Option<UploadDetails>,269
+ pub thumbnail: Option<UploadThumbnail>,270
+}271
+272
+impl AttachmentUpload {273
+ #[must_use]274
+ pub fn new(275
+ timeline_id: impl Into<TimelineId>,276
+ filename: impl Into<String>,277
+ mimetype: impl Into<String>,278
+ bytes: Vec<u8>,279
+ ) -> Self {280
+ Self {281
+ timeline_id: timeline_id.into(),282
+ filename: filename.into(),283
+ mimetype: mimetype.into(),284
+ bytes,285
+ caption: None,286
+ details: None,287
+ thumbnail: None,288
+ }289
+ }290
+}291
+292
+#[derive(Clone, Debug, Eq, PartialEq)]293
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]294
+#[non_exhaustive]295
+pub enum UploadDetails {296
+ Image {297
+ width: Option<u32>,298
+ height: Option<u32>,299
+ blurhash: Option<String>,300
+ },301
+ Video {302
+ width: Option<u32>,303
+ height: Option<u32>,304
+ duration_ms: Option<u64>,305
+ blurhash: Option<String>,306
+ },307
+ Audio {308
+ duration_ms: Option<u64>,309
+ },310
+ Voice {311
+ duration_ms: u64,312
+ waveform: Vec<u16>,313
+ },314
+ File,315
+}316
+317
+#[derive(Clone, Debug, Eq, PartialEq)]318
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]319
+pub struct UploadThumbnail {320
+ pub bytes: Vec<u8>,321
+ pub mimetype: String,322
+ pub width: u32,323
+ pub height: u32,324
+}325
+326
+#[derive(Eq, PartialEq)]327
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]328
+#[non_exhaustive]329
+pub enum EncryptionCommand {330
+ Recover(String),331
+ CreateKeyBackup,332
+ EnableKeyBackup,333
+ DisableKeyBackup { delete_from_server: bool },334
+ RecoverKeyBackup(String),335
+ StartVerification(VerificationTarget),336
+ Verify(VerificationAction),337
+}338
+339
+impl fmt::Debug for EncryptionCommand {340
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {341
+ match self {342
+ Self::Recover(_) => formatter.debug_tuple("Recover").field(&"...").finish(),343
+ Self::CreateKeyBackup => formatter.write_str("CreateKeyBackup"),344
+ Self::EnableKeyBackup => formatter.write_str("EnableKeyBackup"),345
+ Self::DisableKeyBackup { delete_from_server } => formatter346
+ .debug_struct("DisableKeyBackup")347
+ .field("delete_from_server", delete_from_server)348
+ .finish(),349
+ Self::RecoverKeyBackup(_) => formatter350
+ .debug_tuple("RecoverKeyBackup")351
+ .field(&"...")352
+ .finish(),353
+ Self::StartVerification(target) => formatter354
+ .debug_tuple("StartVerification")355
+ .field(target)356
+ .finish(),357
+ Self::Verify(action) => formatter.debug_tuple("Verify").field(action).finish(),358
+ }359
+ }360
+}361
+362
+#[derive(Clone, Debug, Eq, PartialEq)]363
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]364
+#[non_exhaustive]365
+pub enum NotificationCommand {366
+ SetRoomMode {367
+ room_id: OwnedRoomId,368
+ mode: RoomNotificationMode,369
+ },370
+ ResetRoomMode(OwnedRoomId),371
+ SetDefaultMode {372
+ encrypted: bool,373
+ one_to_one: bool,374
+ mode: RoomNotificationMode,375
+ },376
+ AddKeyword(String),377
+ RemoveKeyword(String),378
+}379
+380
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]381
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]382
+#[non_exhaustive]383
+pub enum LifecycleCommand {384
+ Shutdown,385
+}386
+387
+impl Command {388
+ #[must_use]389
+ pub const fn kind(&self) -> CommandKind {390
+ match self {391
+ Self::Account(_) => CommandKind::Account,392
+ Self::Room(_) => CommandKind::Room,393
+ Self::Timeline(_) => CommandKind::Timeline,394
+ Self::Media(_) => CommandKind::Media,395
+ Self::Encryption(_) => CommandKind::Encryption,396
+ Self::Notification(_) => CommandKind::Notification,397
+ Self::Lifecycle(_) => CommandKind::Lifecycle,398
+ }399
+ }400
+}401
+402
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]403
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]404
+pub enum CommandKind {405
+ Account,406
+ Room,407
+ Timeline,408
+ Media,409
+ Encryption,410
+ Notification,411
+ Lifecycle,412
+}413
+414
+#[derive(Clone, Debug, Eq, PartialEq)]415
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]416
+#[non_exhaustive]417
+pub enum MessageContent {418
+ Text {419
+ content: RichText,420
+ mentions: MessageMentions,421
+ },422
+ Emote {423
+ content: RichText,424
+ mentions: MessageMentions,425
+ },426
+ Reply {427
+ event_id: OwnedEventId,428
+ content: RichText,429
+ mentions: MessageMentions,430
+ notify_replied_to: bool,431
+ },432
+}433
+434
+impl MessageContent {435
+ #[must_use]436
+ pub fn text(body: impl Into<String>) -> Self {437
+ Self::Text {438
+ content: RichText::plain(body),439
+ mentions: MessageMentions::default(),440
+ }441
+ }442
+443
+ #[must_use]444
+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {445
+ Self::Text {446
+ content: RichText::html(body, formatted_body),447
+ mentions: MessageMentions::default(),448
+ }449
+ }450
+451
+ #[must_use]452
+ pub fn emote(content: RichText, mentions: MessageMentions) -> Self {453
+ Self::Emote { content, mentions }454
+ }455
+456
+ #[must_use]457
+ pub fn reply(event_id: OwnedEventId, content: RichText, mentions: MessageMentions) -> Self {458
+ Self::Reply {459
+ event_id,460
+ content,461
+ mentions,462
+ notify_replied_to: true,463
+ }464
+ }465
+466
+ #[must_use]467
+ pub fn reply_without_notification(468
+ event_id: OwnedEventId,469
+ content: RichText,470
+ mentions: MessageMentions,471
+ ) -> Self {472
+ Self::Reply {473
+ event_id,474
+ content,475
+ mentions,476
+ notify_replied_to: false,477
+ }478
+ }479
+}480
+481
+#[derive(Clone, Debug, Eq, PartialEq)]482
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]483
+#[non_exhaustive]484
+pub enum MessageEditContent {485
+ Text {486
+ content: RichText,487
+ mentions: MessageMentions,488
+ },489
+ Emote {490
+ content: RichText,491
+ mentions: MessageMentions,492
+ },493
+}494
+495
+impl MessageEditContent {496
+ #[must_use]497
+ pub fn text(body: impl Into<String>) -> Self {498
+ Self::Text {499
+ content: RichText::plain(body),500
+ mentions: MessageMentions::default(),501
+ }502
+ }503
+504
+ #[must_use]505
+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {506
+ Self::Text {507
+ content: RichText::html(body, formatted_body),508
+ mentions: MessageMentions::default(),509
+ }510
+ }511
+512
+ #[must_use]513
+ pub fn emote(content: RichText, mentions: MessageMentions) -> Self {514
+ Self::Emote { content, mentions }515
+ }516
+}517
+518
+#[derive(Clone, Debug, Default, Eq, PartialEq)]519
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]520
+#[non_exhaustive]521
+pub struct RichText {522
+ pub body: String,523
+ pub formatted_body: Option<String>,524
+}525
+526
+impl RichText {527
+ #[must_use]528
+ pub fn plain(body: impl Into<String>) -> Self {529
+ Self {530
+ body: body.into(),531
+ formatted_body: None,532
+ }533
+ }534
+535
+ #[must_use]536
+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {537
+ Self {538
+ body: body.into(),539
+ formatted_body: Some(formatted_body.into()),540
+ }541
+ }542
+}543
+544
+#[derive(Clone, Debug, Default, Eq, PartialEq)]545
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]546
+#[non_exhaustive]547
+pub struct MessageMentions {548
+ pub user_ids: Vec<OwnedUserId>,549
+ pub room: bool,550
+}551
+552
+impl MessageMentions {553
+ #[must_use]554
+ pub fn with_user_ids(user_ids: impl IntoIterator<Item = OwnedUserId>) -> Self {555
+ Self {556
+ user_ids: user_ids.into_iter().collect(),557
+ room: false,558
+ }559
+ }560
+561
+ #[must_use]562
+ pub fn room() -> Self {563
+ Self {564
+ user_ids: Vec::new(),565
+ room: true,566
+ }567
+ }568
+}569
+570
+#[derive(Clone, Debug, Eq, PartialEq)]571
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]572
+#[non_exhaustive]573
+pub enum Event {574
+ ConnectionChanged(ConnectionState),575
+ Authenticated {576
+ user_id: OwnedUserId,577
+ },578
+ /// Refresh tokens changed. Persist a fresh `ClientHandle::session_record()`579
+ /// atomically; a record captured before this event may no longer restore.580
+ SessionTokensChanged,581
+ /// Authentication was rejected. Remove the saved session record and store.582
+ SessionAccessRejected,583
+ NotificationSettingsChanged,584
+ VerificationTargetChanged(VerificationTarget),585
+ InteractiveAuthRequired {586
+ id: CommandId,587
+ command: CommandKind,588
+ challenge: crate::InteractiveAuthChallenge,589
+ },590
+ RoomsChanged,591
+ /// Authoritative snapshot on open or resync. A resync reset precedes the592
+ /// corresponding terminal command event on this stream.593
+ TimelineReset {594
+ timeline_id: TimelineId,595
+ items: Vec<TimelineItem>,596
+ },597
+ TimelineChanged {598
+ timeline_id: TimelineId,599
+ diffs: Vec<TimelineDiff>,600
+ },601
+ HistoryChanged {602
+ timeline_id: TimelineId,603
+ state: HistoryState,604
+ },605
+ TypingChanged(TypingState),606
+ MediaReady {607
+ media_id: MediaId,608
+ size: MediaSize,609
+ bytes: Arc<[u8]>,610
+ mimetype: String,611
+ },612
+ MediaUploadProgress {613
+ room_id: OwnedRoomId,614
+ transaction_id: OwnedTransactionId,615
+ uploaded_bytes: u64,616
+ total_bytes: u64,617
+ complete: bool,618
+ },619
+ MediaUploadFailed {620
+ room_id: OwnedRoomId,621
+ transaction_id: OwnedTransactionId,622
+ kind: CommandErrorKind,623
+ recoverable: bool,624
+ },625
+ EncryptionChanged(EncryptionState),626
+ VerificationChanged(VerificationState),627
+ /// Terminal command success. For sends, this means the SDK send queue628
+ /// accepted the item; network delivery is reflected by its send state.629
+ CommandSucceeded {630
+ id: CommandId,631
+ command: CommandKind,632
+ },633
+ CommandSucceededWithRoom {634
+ id: CommandId,635
+ command: CommandKind,636
+ room_id: OwnedRoomId,637
+ },638
+ /// Terminal failure correlated by `CommandFailure::id`.639
+ CommandFailed(CommandFailure),640
+ /// Explicit logout completed; delete the saved authentication record.641
+ LoggedOut,642
+ Failed(EngineFailure),643
+ /// Engine stopped without logging out; retain saved credentials for restore.644
+ Stopped,645
+}646
+647
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]648
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]649
+pub struct CommandId(u64);650
+651
+impl CommandId {652
+ pub(crate) const fn new(value: u64) -> Self {653
+ Self(value)654
+ }655
+656
+ #[must_use]657
+ pub const fn get(self) -> u64 {658
+ self.0659
+ }660
+}661
+662
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]663
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]664
+pub enum ConnectionState {665
+ Idle,666
+ Connecting,667
+ Online,668
+ Offline,669
+ Stopped,670
+}671
+672
+#[derive(Clone, Debug, Eq, PartialEq)]673
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]674
+#[non_exhaustive]675
+pub struct RoomSummary {676
+ pub room_id: OwnedRoomId,677
+ pub name: String,678
+ pub membership: RoomMembership,679
+ pub unread_messages: u64,680
+ pub unread_mentions: u64,681
+ pub encrypted: bool,682
+ pub space: bool,683
+ pub direct: bool,684
+ pub favourite: bool,685
+ pub low_priority: bool,686
+ pub avatar: Option<OwnedMxcUri>,687
+ pub latest_event_timestamp_ms: Option<u64>,688
+ pub notification_mode: Option<RoomNotificationMode>,689
+ pub notification_mode_custom: bool,690
+}691
+692
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]693
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]694
+pub enum RoomNotificationMode {695
+ AllMessages,696
+ MentionsAndKeywordsOnly,697
+ Mute,698
+}699
+700
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]701
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]702
+#[non_exhaustive]703
+pub struct RoomNotificationSettings {704
+ pub mode: RoomNotificationMode,705
+ pub custom: bool,706
+}707
+708
+#[derive(Clone, Debug)]709
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]710
+#[non_exhaustive]711
+pub enum PushNotificationStatus {712
+ Event(Box<PushNotification>),713
+ NotFound,714
+ FilteredOut,715
+ Redacted,716
+}717
+718
+#[derive(Clone, Debug)]719
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]720
+#[non_exhaustive]721
+pub struct PushNotification {722
+ pub event_id: OwnedEventId,723
+ pub event: PushNotificationEvent,724
+ pub sender: PushNotificationSender,725
+ pub room: PushNotificationRoom,726
+ pub noisy: Option<bool>,727
+ pub has_mention: Option<bool>,728
+ pub thread_id: Option<OwnedEventId>,729
+}730
+731
+#[derive(Clone, Debug, Eq, PartialEq)]732
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]733
+#[non_exhaustive]734
+pub struct PushNotificationSender {735
+ pub user_id: OwnedUserId,736
+ pub display_name: Option<String>,737
+ pub avatar_url: Option<OwnedMxcUri>,738
+ pub name_ambiguous: bool,739
+}740
+741
+#[derive(Clone, Debug, Eq, PartialEq)]742
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]743
+#[non_exhaustive]744
+pub struct PushNotificationRoom {745
+ pub room_id: OwnedRoomId,746
+ pub name: String,747
+ pub avatar_url: Option<OwnedMxcUri>,748
+ pub canonical_alias: Option<OwnedRoomAliasId>,749
+ pub topic: Option<String>,750
+ pub encrypted: Option<bool>,751
+ pub direct: bool,752
+ pub joined_members_count: u64,753
+ pub service_members: Vec<OwnedUserId>,754
+ pub active_service_members_count: u64,755
+ pub space: bool,756
+}757
+758
+#[derive(Clone, Debug)]759
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]760
+#[non_exhaustive]761
+pub enum PushNotificationEvent {762
+ Timeline(Raw<AnySyncTimelineEvent>),763
+ Invite(Raw<StrippedRoomMemberEvent>),764
+}765
+766
+#[derive(Clone, Debug, Eq, PartialEq)]767
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]768
+#[non_exhaustive]769
+pub struct RoomPowerLevels {770
+ pub ban: i64,771
+ pub invite: i64,772
+ pub kick: i64,773
+ pub redact: i64,774
+ pub events_default: i64,775
+ pub state_default: i64,776
+ pub users_default: i64,777
+ pub room_notification: i64,778
+ pub users: Vec<(OwnedUserId, i64)>,779
+ pub events: Vec<(String, i64)>,780
+}781
+782
+#[derive(Clone, Debug, Default, Eq, PartialEq)]783
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]784
+#[non_exhaustive]785
+pub struct PowerLevelChanges {786
+ pub ban: Option<i64>,787
+ pub invite: Option<i64>,788
+ pub kick: Option<i64>,789
+ pub redact: Option<i64>,790
+ pub events_default: Option<i64>,791
+ pub state_default: Option<i64>,792
+ pub users_default: Option<i64>,793
+ pub room_notification: Option<i64>,794
+ pub room_name: Option<i64>,795
+ pub room_avatar: Option<i64>,796
+ pub room_topic: Option<i64>,797
+ pub space_child: Option<i64>,798
+ pub beacon: Option<i64>,799
+ pub beacon_info: Option<i64>,800
+}801
+802
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]803
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]804
+pub enum RoomMembership {805
+ Joined,806
+ Invited,807
+ Left,808
+ Knocked,809
+ Banned,810
+}811
+812
+#[derive(Clone, Debug, Default, Eq, PartialEq)]813
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]814
+#[non_exhaustive]815
+pub struct RoomListFilter {816
+ pub membership: Option<RoomMembership>,817
+ pub category: Option<RoomCategory>,818
+ pub favourite: Option<bool>,819
+ pub unread_only: bool,820
+ pub search: Option<String>,821
+}822
+823
+impl RoomListFilter {824
+ #[must_use]825
+ pub fn with_membership(mut self, membership: RoomMembership) -> Self {826
+ self.membership = Some(membership);827
+ self828
+ }829
+830
+ #[must_use]831
+ pub fn with_category(mut self, category: RoomCategory) -> Self {832
+ self.category = Some(category);833
+ self834
+ }835
+836
+ #[must_use]837
+ pub fn with_favourite(mut self, favourite: bool) -> Self {838
+ self.favourite = Some(favourite);839
+ self840
+ }841
+842
+ #[must_use]843
+ pub const fn with_unread_only(mut self, unread_only: bool) -> Self {844
+ self.unread_only = unread_only;845
+ self846
+ }847
+848
+ #[must_use]849
+ pub fn with_search(mut self, search: impl Into<String>) -> Self {850
+ self.search = Some(search.into());851
+ self852
+ }853
+854
+ #[must_use]855
+ pub fn matches(&self, room: &RoomSummary) -> bool {856
+ self.membership857
+ .is_none_or(|membership| room.membership == membership)858
+ && self.category.is_none_or(|category| match category {859
+ RoomCategory::People => room.direct && !room.space,860
+ RoomCategory::Rooms => !room.direct && !room.space,861
+ RoomCategory::Spaces => room.space,862
+ })863
+ && self864
+ .favourite865
+ .is_none_or(|favourite| room.favourite == favourite)866
+ && (!self.unread_only || room.unread_messages > 0 || room.unread_mentions > 0)867
+ && self.search.as_ref().is_none_or(|search| {868
+ let search = search.to_lowercase();869
+ room.name.to_lowercase().contains(&search)870
+ || room.room_id.as_str().to_lowercase().contains(&search)871
+ })872
+ }873
+}874
+875
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]876
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]877
+pub enum RoomCategory {878
+ People,879
+ Rooms,880
+ Spaces,881
+}882
+883
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]884
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]885
+pub enum MemberFilter {886
+ Active,887
+ Joined,888
+ Invited,889
+ All,890
+}891
+892
+#[derive(Clone, Debug, Eq, PartialEq)]893
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]894
+#[non_exhaustive]895
+pub enum MemberMembership {896
+ Joined,897
+ Invited,898
+ Left,899
+ Knocked,900
+ Banned,901
+ Custom(String),902
+}903
+904
+#[derive(Clone, Debug, Eq, PartialEq)]905
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]906
+#[non_exhaustive]907
+pub struct MemberSummary {908
+ pub user_id: OwnedUserId,909
+ pub name: String,910
+ pub display_name: Option<String>,911
+ pub avatar: Option<OwnedMxcUri>,912
+ pub membership: MemberMembership,913
+ pub own: bool,914
+ pub name_ambiguous: bool,915
+ pub ignored: bool,916
+ pub service: bool,917
+}918
+919
+#[derive(Clone, Debug, Eq, PartialEq)]920
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]921
+#[non_exhaustive]922
+pub struct OwnDevice {923
+ pub device_id: OwnedDeviceId,924
+ pub display_name: Option<String>,925
+ pub last_seen_ip: Option<String>,926
+ pub last_seen_timestamp_ms: Option<u64>,927
+ pub current: bool,928
+}929
+930
+#[derive(Clone, Debug, Eq, PartialEq)]931
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]932
+pub enum TimelineItem {933
+ Message(Box<TimelineMessage>),934
+ DayDivider { timestamp_ms: u64 },935
+ ReadMarker,936
+ TimelineStart,937
+}938
+939
+#[derive(Clone, Debug, Eq, PartialEq)]940
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]941
+#[non_exhaustive]942
+pub enum TimelineDiff {943
+ Append { values: Vec<TimelineItem> },944
+ Clear,945
+ PushFront { value: TimelineItem },946
+ PushBack { value: TimelineItem },947
+ PopFront,948
+ PopBack,949
+ Insert { index: usize, value: TimelineItem },950
+ Set { index: usize, value: TimelineItem },951
+ Remove { index: usize },952
+ Truncate { length: usize },953
+ Reset { values: Vec<TimelineItem> },954
+}955
+956
+#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]957
+#[error("timeline diff is incompatible with the current {length}-item snapshot")]958
+pub struct TimelineDiffError {959
+ pub length: usize,960
+}961
+962
+impl TimelineDiff {963
+ /// Apply a diff only when its indices match the current snapshot.964
+ /// On error the snapshot is unchanged; request `TimelineCommand::Resync`965
+ /// and ignore further diffs until the corresponding `TimelineReset`.966
+ pub fn try_apply(self, items: &mut Vec<TimelineItem>) -> Result<(), TimelineDiffError> {967
+ let length = items.len();968
+ match self {969
+ Self::Append { values } => items.extend(values),970
+ Self::Clear => items.clear(),971
+ Self::PushFront { value } => items.insert(0, value),972
+ Self::PushBack { value } => items.push(value),973
+ Self::PopFront if length == 0 => return Err(TimelineDiffError { length }),974
+ Self::PopFront => {975
+ items.remove(0);976
+ }977
+ Self::PopBack if length == 0 => return Err(TimelineDiffError { length }),978
+ Self::PopBack => {979
+ items.pop();980
+ }981
+ Self::Insert { index, .. } if index > length => {982
+ return Err(TimelineDiffError { length });983
+ }984
+ Self::Insert { index, value } => items.insert(index, value),985
+ Self::Set { index, .. } if index >= length => return Err(TimelineDiffError { length }),986
+ Self::Set { index, value } => items[index] = value,987
+ Self::Remove { index } if index >= length => return Err(TimelineDiffError { length }),988
+ Self::Remove { index } => {989
+ items.remove(index);990
+ }991
+ Self::Truncate { length: target } if target > length => {992
+ return Err(TimelineDiffError { length });993
+ }994
+ Self::Truncate { length } => items.truncate(length),995
+ Self::Reset { values } => *items = values,996
+ }997
+ Ok(())998
+ }999
+}1000
+1001
+#[cfg(test)]1002
+mod tests {1003
+ use super::{TimelineDiff, TimelineItem};1004
+1005
+ fn item(timestamp_ms: u64) -> TimelineItem {1006
+ TimelineItem::DayDivider { timestamp_ms }1007
+ }1008
+1009
+ #[test]1010
+ fn timeline_diff_try_apply_handles_every_variant() {1011
+ let mut items = vec![item(1), item(2)];1012
+1013
+ TimelineDiff::Append {1014
+ values: vec![item(3)],1015
+ }1016
+ .try_apply(&mut items)1017
+ .unwrap();1018
+ assert_eq!(items, vec![item(1), item(2), item(3)]);1019
+1020
+ TimelineDiff::Clear.try_apply(&mut items).unwrap();1021
+ assert!(items.is_empty());1022
+1023
+ TimelineDiff::PushFront { value: item(2) }1024
+ .try_apply(&mut items)1025
+ .unwrap();1026
+ TimelineDiff::PushBack { value: item(3) }1027
+ .try_apply(&mut items)1028
+ .unwrap();1029
+ assert_eq!(items, vec![item(2), item(3)]);1030
+1031
+ TimelineDiff::PopFront.try_apply(&mut items).unwrap();1032
+ TimelineDiff::PopBack.try_apply(&mut items).unwrap();1033
+ assert!(items.is_empty());1034
+1035
+ TimelineDiff::Reset {1036
+ values: vec![item(1), item(3)],1037
+ }1038
+ .try_apply(&mut items)1039
+ .unwrap();1040
+ TimelineDiff::Insert {1041
+ index: 1,1042
+ value: item(2),1043
+ }1044
+ .try_apply(&mut items)1045
+ .unwrap();1046
+ assert_eq!(items, vec![item(1), item(2), item(3)]);1047
+1048
+ TimelineDiff::Set {1049
+ index: 1,1050
+ value: item(4),1051
+ }1052
+ .try_apply(&mut items)1053
+ .unwrap();1054
+ assert_eq!(items, vec![item(1), item(4), item(3)]);1055
+1056
+ TimelineDiff::Remove { index: 1 }1057
+ .try_apply(&mut items)1058
+ .unwrap();1059
+ assert_eq!(items, vec![item(1), item(3)]);1060
+1061
+ TimelineDiff::Truncate { length: 1 }1062
+ .try_apply(&mut items)1063
+ .unwrap();1064
+ assert_eq!(items, vec![item(1)]);1065
+ }1066
+1067
+ #[test]1068
+ fn invalid_timeline_diff_leaves_snapshot_unchanged() {1069
+ let invalid = [1070
+ TimelineDiff::Insert {1071
+ index: 3,1072
+ value: item(3),1073
+ },1074
+ TimelineDiff::Set {1075
+ index: 2,1076
+ value: item(3),1077
+ },1078
+ TimelineDiff::Remove { index: 2 },1079
+ TimelineDiff::Truncate { length: 3 },1080
+ ];1081
+ for diff in invalid {1082
+ let mut items = vec![item(1), item(2)];1083
+ assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 2);1084
+ assert_eq!(items, vec![item(1), item(2)]);1085
+ }1086
+ for diff in [TimelineDiff::PopFront, TimelineDiff::PopBack] {1087
+ let mut items = Vec::new();1088
+ assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 0);1089
+ assert!(items.is_empty());1090
+ }1091
+ }1092
+}1093
+1094
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]1095
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1096
+pub struct TimelineItemId(String);1097
+1098
+impl TimelineItemId {1099
+ pub(crate) fn new(value: String) -> Self {1100
+ Self(value)1101
+ }1102
+1103
+ #[must_use]1104
+ pub fn as_str(&self) -> &str {1105
+ &self.01106
+ }1107
+}1108
+1109
+#[derive(Clone, Debug, Eq, PartialEq)]1110
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1111
+#[non_exhaustive]1112
+pub struct TimelineMessage {1113
+ pub item_id: TimelineItemId,1114
+ pub event_id: Option<OwnedEventId>,1115
+ pub transaction_id: Option<OwnedTransactionId>,1116
+ pub sender_id: OwnedUserId,1117
+ pub sender_name: String,1118
+ pub body: String,1119
+ pub formatted_body: Option<String>,1120
+ pub mentions: Option<MessageMentions>,1121
+ pub thread_root: Option<OwnedEventId>,1122
+ pub thread_summary: Option<ThreadSummary>,1123
+ pub timestamp_ms: u64,1124
+ pub own: bool,1125
+ pub send_state: SendState,1126
+ pub edited: bool,1127
+ pub unable_to_decrypt: bool,1128
+ pub media: Option<MediaDescriptor>,1129
+ pub reply_to: Option<ReplyReference>,1130
+ pub reactions: Vec<Reaction>,1131
+ pub read_by: Vec<OwnedUserId>,1132
+}1133
+1134
+#[derive(Clone, Debug, Eq, PartialEq)]1135
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1136
+#[non_exhaustive]1137
+pub struct ThreadSummary {1138
+ pub reply_count: u32,1139
+ pub latest_event_id: Option<OwnedEventId>,1140
+ pub latest_event_timestamp_ms: Option<u64>,1141
+}1142
+1143
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1144
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1145
+#[non_exhaustive]1146
+pub enum SendState {1147
+ Sent,1148
+ Queued,1149
+ Sending,1150
+ Failed { retriable: bool },1151
+}1152
+1153
+#[derive(Clone, Debug, Eq, PartialEq)]1154
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1155
+pub struct ReplyReference {1156
+ pub event_id: OwnedEventId,1157
+ pub sender_id: Option<OwnedUserId>,1158
+ pub sender_name: Option<String>,1159
+}1160
+1161
+#[derive(Clone, Debug, Eq, PartialEq)]1162
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1163
+#[non_exhaustive]1164
+pub struct Reaction {1165
+ pub key: String,1166
+ pub count: usize,1167
+ pub own: bool,1168
+ pub senders: Vec<OwnedUserId>,1169
+}1170
+1171
+#[derive(Clone, Debug, Eq, PartialEq)]1172
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1173
+#[non_exhaustive]1174
+pub struct MediaDescriptor {1175
+ pub id: MediaId,1176
+ pub kind: MediaKind,1177
+ pub filename: String,1178
+ pub caption: Option<String>,1179
+ pub mimetype: Option<String>,1180
+ pub size_bytes: Option<u64>,1181
+ pub width: Option<u64>,1182
+ pub height: Option<u64>,1183
+}1184
+1185
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]1186
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1187
+pub struct MediaId(String);1188
+1189
+impl MediaId {1190
+ pub(crate) fn new(value: String) -> Self {1191
+ Self(value)1192
+ }1193
+1194
+ #[must_use]1195
+ pub fn as_str(&self) -> &str {1196
+ &self.01197
+ }1198
+}1199
+1200
+impl std::fmt::Display for MediaId {1201
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {1202
+ self.0.fmt(formatter)1203
+ }1204
+}1205
+1206
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]1207
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1208
+pub enum MediaSize {1209
+ Thumbnail,1210
+ Full,1211
+}1212
+1213
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1214
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1215
+#[non_exhaustive]1216
+pub enum MediaKind {1217
+ Image,1218
+ Video,1219
+ Audio,1220
+ File,1221
+ Sticker,1222
+}1223
+1224
+#[derive(Clone, Debug, Eq, PartialEq)]1225
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1226
+pub struct TypingState {1227
+ pub room_id: OwnedRoomId,1228
+ pub users: Vec<OwnedUserId>,1229
+}1230
+1231
+#[derive(Clone, Debug, Eq, PartialEq)]1232
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1233
+pub enum HistoryState {1234
+ Idle,1235
+ Loading,1236
+ StartReached,1237
+ Failed(String),1238
+}1239
+1240
+#[derive(Clone, Debug, Eq, PartialEq)]1241
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1242
+#[non_exhaustive]1243
+pub struct EncryptionState {1244
+ pub device_id: OwnedDeviceId,1245
+ pub device_verified: bool,1246
+ pub recovery: RecoveryState,1247
+ pub key_backup: KeyBackupState,1248
+}1249
+1250
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1251
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1252
+pub enum KeyBackupState {1253
+ Unknown,1254
+ Creating,1255
+ Enabling,1256
+ Resuming,1257
+ Downloading,1258
+ Enabled,1259
+ Disabling,1260
+}1261
+1262
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1263
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1264
+pub enum RecoveryState {1265
+ Unknown,1266
+ Disabled,1267
+ Incomplete,1268
+ Enabled,1269
+}1270
+1271
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1272
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1273
+pub enum VerificationAction {1274
+ Confirm,1275
+ Reject,1276
+ Cancel,1277
+}1278
+1279
+#[derive(Clone, Debug, Eq, PartialEq)]1280
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1281
+#[non_exhaustive]1282
+pub enum VerificationTarget {1283
+ OwnIdentity,1284
+ User(OwnedUserId),1285
+ Device {1286
+ user_id: OwnedUserId,1287
+ device_id: OwnedDeviceId,1288
+ },1289
+}1290
+1291
+#[derive(Clone, Debug, Eq, PartialEq)]1292
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1293
+pub enum VerificationState {1294
+ Waiting,1295
+ Comparison {1296
+ emojis: Vec<(String, String)>,1297
+ decimals: (u16, u16, u16),1298
+ },1299
+ Confirmed,1300
+ Done,1301
+ Cancelled(String),1302
+ Failed(VerificationFailure),1303
+}1304
+1305
+#[derive(Clone, Debug, Eq, PartialEq)]1306
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1307
+#[non_exhaustive]1308
+pub enum VerificationFailure {1309
+ RequestAcceptance(String),1310
+ SasStartup(String),1311
+ SasAcceptance(String),1312
+ RequestStreamEnded,1313
+ SasStreamEnded,1314
+}1315
+1316
+#[derive(Clone, Debug, Eq, PartialEq)]1317
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1318
+#[non_exhaustive]1319
+pub struct CommandFailure {1320
+ pub id: CommandId,1321
+ pub command: CommandKind,1322
+ pub kind: CommandErrorKind,1323
+ pub message: String,1324
+}1325
+1326
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1327
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1328
+#[non_exhaustive]1329
+pub enum CommandErrorKind {1330
+ Unauthenticated,1331
+ RoomUnknown,1332
+ RoomNotOpen,1333
+ TimelineNotOpen,1334
+ RoomBusy,1335
+ QueueFull,1336
+ InteractiveAuthRequired,1337
+ EmptyBody,1338
+ MediaUnknown,1339
+ MediaTooLarge,1340
+ MediaUnavailable,1341
+ VerificationInactive,1342
+ VerificationUnsupported,1343
+ NoCrossSigningIdentity,1344
+ RecoveryRejected,1345
+ EventReceiverClosed,1346
+ Network,1347
+ Server,1348
+ Store,1349
+ Timeline,1350
+ Encryption,1351
+ Other,1352
+}1353
+1354
+#[derive(Clone, Debug, Eq, PartialEq)]1355
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1356
+pub struct EngineFailure {1357
+ pub kind: EngineErrorKind,1358
+ pub message: String,1359
+}1360
+1361
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1362
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1363
+#[non_exhaustive]1364
+pub enum EngineErrorKind {1365
+ EventReceiverClosed,1366
+ Watcher,1367
+ Other,1368
+}@@ -0,0 +1,340 @@
1
+use std::sync::atomic::{AtomicU64, Ordering};2
+3
+use matrix_sdk::{media::MediaEventContent, ruma::events::room::MediaSource};4
+use matrix_sdk_ui::timeline::{MsgLikeKind, TimelineItem as SdkTimelineItem, TimelineItemContent};5
+use ruma::events::{6
+ room::{7
+ ImageInfo, ThumbnailInfo,8
+ message::{FileInfo, MessageType, VideoInfo},9
+ },10
+ sticker::StickerEventContent,11
+};12
+13
+use crate::{MediaDescriptor, MediaId, MediaKind};14
+15
+static NEXT_MEDIA_SCOPE: AtomicU64 = AtomicU64::new(1);16
+17
+/// Per-timeline-session nonce namespacing recycled SDK unique IDs.18
+///19
+/// SDK 0.18 recycles [`SdkTimelineItem::unique_id`] across the local-to-remote20
+/// echo replacement, while transaction IDs disappear. Scoping the recycled ID21
+/// by a process-wide nonce keeps media IDs stable across replacement without22
+/// colliding across rooms, threads, or reopened sessions.23
+#[derive(Clone, Debug)]24
+pub(crate) struct MediaScope {25
+ nonce: u64,26
+}27
+28
+impl MediaScope {29
+ pub(crate) fn new() -> Self {30
+ Self {31
+ nonce: NEXT_MEDIA_SCOPE.fetch_add(1, Ordering::Relaxed),32
+ }33
+ }34
+}35
+36
+#[derive(Clone)]37
+pub(crate) struct MediaRegistration {38
+ pub(crate) id: MediaId,39
+ pub(crate) kind: MediaKind,40
+ pub(crate) full: MediaSourceInfo,41
+ pub(crate) thumbnail: Option<MediaSourceInfo>,42
+}43
+44
+#[derive(Clone)]45
+pub(crate) struct MediaSourceInfo {46
+ pub(crate) source: MediaSource,47
+ pub(crate) mimetype: Option<String>,48
+ pub(crate) declared_size: Option<u64>,49
+}50
+51
+pub(super) fn media_registration_for_item(52
+ item: &SdkTimelineItem,53
+ scope: &MediaScope,54
+) -> Option<MediaRegistration> {55
+ let event = item.as_event()?;56
+ let identity = media_identity(item, scope);57
+ let content = match event.content() {58
+ TimelineItemContent::MsgLike(content) => content,59
+ _ => return None,60
+ };61
+ match &content.kind {62
+ MsgLikeKind::Message(message) => media_registration(&identity, message.msgtype()),63
+ MsgLikeKind::Sticker(sticker) => media_sticker_registration(&identity, sticker.content()),64
+ _ => None,65
+ }66
+}67
+68
+pub(super) fn media_identity(item: &SdkTimelineItem, scope: &MediaScope) -> String {69
+ format!("{}#{}", scope.nonce, item.unique_id().0)70
+}71
+72
+pub(super) fn media_from_message(identity: &str, message: &MessageType) -> Option<MediaDescriptor> {73
+ let (kind, filename, caption, mimetype, size_bytes, width, height) = match message {74
+ MessageType::Image(content) => (75
+ MediaKind::Image,76
+ content.filename().to_owned(),77
+ content.caption().map(str::to_owned),78
+ content.info.as_ref().and_then(|info| info.mimetype.clone()),79
+ content80
+ .info81
+ .as_ref()82
+ .and_then(|info| info.size)83
+ .map(u64::from),84
+ content85
+ .info86
+ .as_ref()87
+ .and_then(|info| info.width)88
+ .map(u64::from),89
+ content90
+ .info91
+ .as_ref()92
+ .and_then(|info| info.height)93
+ .map(u64::from),94
+ ),95
+ MessageType::Video(content) => (96
+ MediaKind::Video,97
+ content.filename().to_owned(),98
+ content.caption().map(str::to_owned),99
+ content.info.as_ref().and_then(|info| info.mimetype.clone()),100
+ content101
+ .info102
+ .as_ref()103
+ .and_then(|info| info.size)104
+ .map(u64::from),105
+ content106
+ .info107
+ .as_ref()108
+ .and_then(|info| info.width)109
+ .map(u64::from),110
+ content111
+ .info112
+ .as_ref()113
+ .and_then(|info| info.height)114
+ .map(u64::from),115
+ ),116
+ MessageType::Audio(content) => (117
+ MediaKind::Audio,118
+ content.filename().to_owned(),119
+ content.caption().map(str::to_owned),120
+ content.info.as_ref().and_then(|info| info.mimetype.clone()),121
+ content122
+ .info123
+ .as_ref()124
+ .and_then(|info| info.size)125
+ .map(u64::from),126
+ None,127
+ None,128
+ ),129
+ MessageType::File(content) => (130
+ MediaKind::File,131
+ content.filename().to_owned(),132
+ content.caption().map(str::to_owned),133
+ content.info.as_ref().and_then(|info| info.mimetype.clone()),134
+ content135
+ .info136
+ .as_ref()137
+ .and_then(|info| info.size)138
+ .map(u64::from),139
+ None,140
+ None,141
+ ),142
+ _ => return None,143
+ };144
+ Some(MediaDescriptor {145
+ id: media_id(identity, "media"),146
+ kind,147
+ filename,148
+ caption,149
+ mimetype,150
+ size_bytes,151
+ width,152
+ height,153
+ })154
+}155
+156
+pub(super) fn media_descriptor_for_sticker(157
+ identity: &str,158
+ content: &StickerEventContent,159
+) -> MediaDescriptor {160
+ MediaDescriptor {161
+ id: media_id(identity, "sticker"),162
+ kind: MediaKind::Sticker,163
+ filename: content.body.clone(),164
+ caption: None,165
+ mimetype: content.info.mimetype.clone(),166
+ size_bytes: content.info.size.map(u64::from),167
+ width: content.info.width.map(u64::from),168
+ height: content.info.height.map(u64::from),169
+ }170
+}171
+172
+pub(super) fn media_id(identity: &str, suffix: &str) -> MediaId {173
+ MediaId::new(format!("{identity}#{suffix}"))174
+}175
+176
+fn media_sticker_registration(177
+ identity: &str,178
+ content: &StickerEventContent,179
+) -> Option<MediaRegistration> {180
+ Some(MediaRegistration {181
+ id: media_id(identity, "sticker"),182
+ kind: MediaKind::Sticker,183
+ full: MediaSourceInfo {184
+ source: content.source()?,185
+ mimetype: content.info.mimetype.clone(),186
+ declared_size: content.info.size.map(u64::from),187
+ },188
+ thumbnail: thumbnail_media_source(189
+ content.info.thumbnail_source.clone(),190
+ content.info.thumbnail_info.as_deref(),191
+ ),192
+ })193
+}194
+195
+fn media_registration(identity: &str, message: &MessageType) -> Option<MediaRegistration> {196
+ match message {197
+ MessageType::Image(content) => media_registration_with_info(198
+ identity,199
+ MediaKind::Image,200
+ content.source(),201
+ content.info.as_deref(),202
+ ),203
+ MessageType::Video(content) => media_registration_with_info(204
+ identity,205
+ MediaKind::Video,206
+ content.source(),207
+ content.info.as_deref(),208
+ ),209
+ MessageType::File(content) => media_registration_with_info(210
+ identity,211
+ MediaKind::File,212
+ content.source(),213
+ content.info.as_deref(),214
+ ),215
+ MessageType::Audio(content) => media_registration_with_parts(216
+ identity,217
+ MediaKind::Audio,218
+ content.source(),219
+ content.info.as_ref().and_then(|info| info.mimetype.clone()),220
+ content221
+ .info222
+ .as_ref()223
+ .and_then(|info| info.size)224
+ .map(u64::from),225
+ None,226
+ ),227
+ _ => None,228
+ }229
+}230
+231
+fn media_registration_with_info<T: MediaInfo>(232
+ identity: &str,233
+ kind: MediaKind,234
+ source: Option<MediaSource>,235
+ info: Option<&T>,236
+) -> Option<MediaRegistration> {237
+ media_registration_with_parts(238
+ identity,239
+ kind,240
+ source,241
+ info.and_then(MediaInfo::mimetype),242
+ info.and_then(MediaInfo::declared_size),243
+ info.and_then(MediaInfo::thumbnail),244
+ )245
+}246
+247
+fn media_registration_with_parts(248
+ identity: &str,249
+ kind: MediaKind,250
+ source: Option<MediaSource>,251
+ mimetype: Option<String>,252
+ declared_size: Option<u64>,253
+ thumbnail: Option<MediaSourceInfo>,254
+) -> Option<MediaRegistration> {255
+ Some(MediaRegistration {256
+ id: media_id(identity, "media"),257
+ kind,258
+ full: MediaSourceInfo {259
+ source: source?,260
+ mimetype,261
+ declared_size,262
+ },263
+ thumbnail,264
+ })265
+}266
+267
+trait MediaInfo {268
+ fn mimetype(&self) -> Option<String>;269
+ fn declared_size(&self) -> Option<u64>;270
+ fn thumbnail(&self) -> Option<MediaSourceInfo>;271
+}272
+273
+macro_rules! impl_media_info {274
+ ($type:ty) => {275
+ impl MediaInfo for $type {276
+ fn mimetype(&self) -> Option<String> {277
+ self.mimetype.clone()278
+ }279
+280
+ fn declared_size(&self) -> Option<u64> {281
+ self.size.map(u64::from)282
+ }283
+284
+ fn thumbnail(&self) -> Option<MediaSourceInfo> {285
+ thumbnail_media_source(286
+ self.thumbnail_source.clone(),287
+ self.thumbnail_info.as_deref(),288
+ )289
+ }290
+ }291
+ };292
+}293
+294
+impl_media_info!(ImageInfo);295
+impl_media_info!(VideoInfo);296
+impl_media_info!(FileInfo);297
+298
+fn thumbnail_media_source(299
+ source: Option<MediaSource>,300
+ info: Option<&ThumbnailInfo>,301
+) -> Option<MediaSourceInfo> {302
+ source.map(|source| MediaSourceInfo {303
+ source,304
+ mimetype: info.and_then(|info| info.mimetype.clone()),305
+ declared_size: info.and_then(|info| info.size).map(u64::from),306
+ })307
+}308
+309
+#[cfg(test)]310
+mod tests {311
+ use matrix_sdk::ruma::{312
+ events::room::{ImageInfo, message::ImageMessageEventContent},313
+ mxc_uri,314
+ };315
+316
+ use super::*;317
+318
+ #[test]319
+ fn image_messages_keep_media_metadata() {320
+ let mut info = ImageInfo::new();321
+ info.mimetype = Some("image/png".to_owned());322
+ info.size = Some(65_536_u32.into());323
+ info.width = Some(640_u32.into());324
+ info.height = Some(480_u32.into());325
+ let content = ImageMessageEventContent::plain(326
+ "screen.png".to_owned(),327
+ mxc_uri!("mxc://example.org/screen").to_owned(),328
+ )329
+ .info(Box::new(info));330
+331
+ let media = media_from_message("$event:example.org", &MessageType::Image(content)).unwrap();332
+ assert_eq!(media.id.as_str(), "$event:example.org#media");333
+ assert_eq!(media.kind, MediaKind::Image);334
+ assert_eq!(media.filename, "screen.png");335
+ assert_eq!(media.caption, None);336
+ assert_eq!(media.mimetype.as_deref(), Some("image/png"));337
+ assert_eq!(media.size_bytes, Some(65_536));338
+ assert_eq!((media.width, media.height), (Some(640), Some(480)));339
+ }340
+}@@ -0,0 +1,25 @@
1
+use matrix_sdk::room::RoomMember;2
+use ruma::events::room::member::MembershipState;3
+4
+use crate::{MemberMembership, MemberSummary};5
+6
+pub(crate) fn member_summary(member: RoomMember) -> MemberSummary {7
+ MemberSummary {8
+ user_id: member.user_id().to_owned(),9
+ name: member.name().to_owned(),10
+ display_name: member.display_name().map(ToOwned::to_owned),11
+ avatar: member.avatar_url().map(ToOwned::to_owned),12
+ membership: match member.membership() {13
+ MembershipState::Join => MemberMembership::Joined,14
+ MembershipState::Invite => MemberMembership::Invited,15
+ MembershipState::Leave => MemberMembership::Left,16
+ MembershipState::Knock => MemberMembership::Knocked,17
+ MembershipState::Ban => MemberMembership::Banned,18
+ value => MemberMembership::Custom(value.to_string()),19
+ },20
+ own: member.is_account_user(),21
+ name_ambiguous: member.name_ambiguous(),22
+ ignored: member.is_ignored(),23
+ service: member.is_service_member(),24
+ }25
+}@@ -0,0 +1,10 @@
1
+mod media;2
+mod member;3
+mod room;4
+mod timeline;5
+6
+pub(crate) use media::{MediaRegistration, MediaScope};7
+pub(crate) use member::member_summary;8
+pub(crate) use room::notification_mode;9
+pub(crate) use room::room_summaries;10
+pub(crate) use timeline::{normalise_timeline, normalise_timeline_diffs};@@ -0,0 +1,71 @@
1
+use futures_util::{StreamExt, stream};2
+use matrix_sdk::{Client, Room, RoomState};3
+4
+use crate::{RoomMembership, RoomNotificationMode, RoomSummary};5
+6
+pub(crate) async fn room_summaries(client: &Client) -> Vec<RoomSummary> {7
+ let mut summaries = stream::iter(client.rooms())8
+ .map(|room| async move { room_summary(&room).await })9
+ .buffer_unordered(16)10
+ .collect::<Vec<_>>()11
+ .await;12
+ summaries.sort_by(|left, right| {13
+ right14
+ .latest_event_timestamp_ms15
+ .cmp(&left.latest_event_timestamp_ms)16
+ .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))17
+ .then_with(|| left.room_id.cmp(&right.room_id))18
+ });19
+ summaries20
+}21
+22
+async fn room_summary(room: &Room) -> RoomSummary {23
+ let name = room24
+ .display_name()25
+ .await26
+ .map(|name| name.to_string())27
+ .unwrap_or_else(|_| room.room_id().to_string());28
+ let custom_notification_mode = room.user_defined_notification_mode().await;29
+ let effective_notification_mode = match custom_notification_mode {30
+ Some(mode) => Some(mode),31
+ None => room.notification_mode().await,32
+ };33
+ RoomSummary {34
+ room_id: room.room_id().to_owned(),35
+ name,36
+ membership: match room.state() {37
+ RoomState::Joined => RoomMembership::Joined,38
+ RoomState::Invited => RoomMembership::Invited,39
+ RoomState::Left => RoomMembership::Left,40
+ RoomState::Knocked => RoomMembership::Knocked,41
+ RoomState::Banned => RoomMembership::Banned,42
+ },43
+ unread_messages: room.num_unread_messages(),44
+ unread_mentions: room.num_unread_mentions(),45
+ encrypted: room.encryption_state().is_encrypted(),46
+ space: room.is_space(),47
+ direct: room.is_direct().await.unwrap_or(false),48
+ favourite: room.is_favourite(),49
+ low_priority: room.is_low_priority(),50
+ avatar: room.avatar_url(),51
+ latest_event_timestamp_ms: room52
+ .latest_event_timestamp()53
+ .map(|value| value.get().into()),54
+ notification_mode: effective_notification_mode.map(notification_mode),55
+ notification_mode_custom: custom_notification_mode.is_some(),56
+ }57
+}58
+59
+pub(crate) const fn notification_mode(60
+ mode: matrix_sdk::notification_settings::RoomNotificationMode,61
+) -> RoomNotificationMode {62
+ match mode {63
+ matrix_sdk::notification_settings::RoomNotificationMode::AllMessages => {64
+ RoomNotificationMode::AllMessages65
+ }66
+ matrix_sdk::notification_settings::RoomNotificationMode::MentionsAndKeywordsOnly => {67
+ RoomNotificationMode::MentionsAndKeywordsOnly68
+ }69
+ matrix_sdk::notification_settings::RoomNotificationMode::Mute => RoomNotificationMode::Mute,70
+ }71
+}@@ -0,0 +1,302 @@
1
+use std::sync::Arc;2
+3
+use matrix_sdk_ui::{4
+ eyeball_im::VectorDiff,5
+ timeline::{6
+ EventSendState, MsgLikeKind, TimelineDetails, TimelineEventItemId,7
+ TimelineItem as SdkTimelineItem, TimelineItemContent, TimelineItemKind,8
+ VirtualTimelineItem,9
+ },10
+};11
+use ruma::{12
+ UserId,13
+ events::room::message::{MessageFormat, MessageType},14
+};15
+16
+use crate::{17
+ MessageMentions, Reaction, ReplyReference, SendState, ThreadSummary, TimelineDiff,18
+ TimelineItem, TimelineItemId, TimelineMessage,19
+};20
+21
+use super::media::{22
+ MediaRegistration, MediaScope, media_descriptor_for_sticker, media_from_message,23
+ media_identity, media_registration_for_item,24
+};25
+26
+pub(crate) struct NormalisedTimeline {27
+ pub(crate) items: Vec<TimelineItem>,28
+ pub(crate) media: Vec<MediaRegistration>,29
+}30
+31
+pub(crate) fn normalise_timeline<'a>(32
+ items: impl Iterator<Item = &'a Arc<SdkTimelineItem>>,33
+ current_user_id: Option<&UserId>,34
+ scope: &MediaScope,35
+) -> NormalisedTimeline {36
+ let mut normalised = NormalisedTimeline {37
+ items: Vec::new(),38
+ media: Vec::new(),39
+ };40
+ for item in items {41
+ if let Some(media) = media_registration_for_item(item, scope) {42
+ normalised.media.push(media);43
+ }44
+ normalised45
+ .items46
+ .push(normalise_timeline_item(item, current_user_id, scope));47
+ }48
+ normalised49
+}50
+51
+pub(crate) fn normalise_timeline_diffs(52
+ diffs: Vec<VectorDiff<Arc<SdkTimelineItem>>>,53
+ current_user_id: Option<&UserId>,54
+ scope: &MediaScope,55
+) -> NormalisedTimelineDiffs {56
+ let mut media = Vec::new();57
+ let diffs = diffs58
+ .into_iter()59
+ .map(|diff| {60
+ let diff = diff.map(|item| {61
+ if let Some(registration) = media_registration_for_item(&item, scope) {62
+ media.push(registration);63
+ }64
+ normalise_timeline_item(&item, current_user_id, scope)65
+ });66
+ match diff {67
+ VectorDiff::Append { values } => TimelineDiff::Append {68
+ values: values.into_iter().collect(),69
+ },70
+ VectorDiff::Clear => TimelineDiff::Clear,71
+ VectorDiff::PushFront { value } => TimelineDiff::PushFront { value },72
+ VectorDiff::PushBack { value } => TimelineDiff::PushBack { value },73
+ VectorDiff::PopFront => TimelineDiff::PopFront,74
+ VectorDiff::PopBack => TimelineDiff::PopBack,75
+ VectorDiff::Insert { index, value } => TimelineDiff::Insert { index, value },76
+ VectorDiff::Set { index, value } => TimelineDiff::Set { index, value },77
+ VectorDiff::Remove { index } => TimelineDiff::Remove { index },78
+ VectorDiff::Truncate { length } => TimelineDiff::Truncate { length },79
+ VectorDiff::Reset { values } => TimelineDiff::Reset {80
+ values: values.into_iter().collect(),81
+ },82
+ }83
+ })84
+ .collect();85
+ NormalisedTimelineDiffs { diffs, media }86
+}87
+88
+pub(crate) struct NormalisedTimelineDiffs {89
+ pub(crate) diffs: Vec<TimelineDiff>,90
+ pub(crate) media: Vec<MediaRegistration>,91
+}92
+93
+fn normalise_timeline_item(94
+ item: &SdkTimelineItem,95
+ current_user_id: Option<&UserId>,96
+ scope: &MediaScope,97
+) -> TimelineItem {98
+ match item.kind() {99
+ TimelineItemKind::Event(event) => TimelineItem::Message(Box::new(timeline_message(100
+ item,101
+ event,102
+ current_user_id,103
+ scope,104
+ ))),105
+ TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp)) => {106
+ TimelineItem::DayDivider {107
+ timestamp_ms: timestamp.get().into(),108
+ }109
+ }110
+ TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => TimelineItem::ReadMarker,111
+ TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => {112
+ TimelineItem::TimelineStart113
+ }114
+ }115
+}116
+117
+fn timeline_message(118
+ item: &SdkTimelineItem,119
+ event: &matrix_sdk_ui::timeline::EventTimelineItem,120
+ current_user_id: Option<&UserId>,121
+ scope: &MediaScope,122
+) -> TimelineMessage {123
+ let (body, formatted_body, mentions, edited, unable_to_decrypt, media) = match event.content() {124
+ TimelineItemContent::MsgLike(content) => match &content.kind {125
+ MsgLikeKind::Message(message) => (126
+ message.body().to_owned(),127
+ formatted_body(message.msgtype()),128
+ message.mentions().map(message_mentions),129
+ message.is_edited(),130
+ false,131
+ media_from_message(&media_identity(item, scope), message.msgtype()),132
+ ),133
+ MsgLikeKind::Sticker(sticker) => {134
+ let content = sticker.content();135
+ (136
+ content.body.clone(),137
+ None,138
+ None,139
+ false,140
+ false,141
+ Some(media_descriptor_for_sticker(142
+ &media_identity(item, scope),143
+ content,144
+ )),145
+ )146
+ }147
+ MsgLikeKind::Redacted => ("<message redacted>".into(), None, None, false, false, None),148
+ MsgLikeKind::UnableToDecrypt(_) => (149
+ "<unable to decrypt message>".into(),150
+ None,151
+ None,152
+ false,153
+ true,154
+ None,155
+ ),156
+ MsgLikeKind::Poll(_) => ("[poll]".into(), None, None, false, false, None),157
+ MsgLikeKind::LiveLocation(_) => {158
+ ("[live location]".into(), None, None, false, false, None)159
+ }160
+ MsgLikeKind::Other(other) => (161
+ format!("[{}]", other.event_type()),162
+ None,163
+ None,164
+ false,165
+ false,166
+ None,167
+ ),168
+ },169
+ other => (170
+ format!(171
+ "[{}]",172
+ other.event_type_str().unwrap_or_else(|| "event".into())173
+ ),174
+ None,175
+ None,176
+ false,177
+ false,178
+ None,179
+ ),180
+ };181
+ let reply_to = match event.content() {182
+ TimelineItemContent::MsgLike(content) => content.in_reply_to.as_ref().map(|reply| {183
+ let event_id = reply.event_id.to_owned();184
+ let embedded_sender = match &reply.event {185
+ TimelineDetails::Ready(event) => Some((186
+ event.sender.clone(),187
+ sender_name(&event.sender, &event.sender_profile),188
+ )),189
+ _ => None,190
+ };191
+ ReplyReference {192
+ event_id,193
+ sender_id: embedded_sender.as_ref().map(|(id, _)| id.clone()),194
+ sender_name: embedded_sender.map(|(_, name)| name),195
+ }196
+ }),197
+ _ => None,198
+ };199
+ let (thread_root, thread_summary) = match event.content() {200
+ TimelineItemContent::MsgLike(content) => (201
+ content.thread_root.clone(),202
+ content.thread_summary.as_ref().map(|summary| {203
+ let latest_event = match &summary.latest_event {204
+ TimelineDetails::Ready(event) => Some(event.as_ref()),205
+ _ => None,206
+ };207
+ ThreadSummary {208
+ reply_count: summary.num_replies,209
+ latest_event_id: latest_event.and_then(|event| match &event.identifier {210
+ TimelineEventItemId::EventId(event_id) => Some(event_id.clone()),211
+ TimelineEventItemId::TransactionId(_) => None,212
+ }),213
+ latest_event_timestamp_ms: latest_event214
+ .map(|event| event.timestamp.get().into()),215
+ }216
+ }),217
+ ),218
+ _ => (None, None),219
+ };220
+ let reactions = event221
+ .content()222
+ .reactions()223
+ .into_iter()224
+ .flat_map(|reactions| reactions.iter())225
+ .map(|(key, senders)| Reaction {226
+ key: key.clone(),227
+ count: senders.len(),228
+ own: current_user_id.is_some_and(|user_id| senders.contains_key(user_id)),229
+ senders: senders.keys().cloned().collect(),230
+ })231
+ .collect();232
+ let mut read_by: Vec<_> = event233
+ .read_receipts()234
+ .keys()235
+ .filter(|user_id| *user_id != event.sender())236
+ .cloned()237
+ .collect();238
+ read_by.sort();239
+240
+ TimelineMessage {241
+ item_id: TimelineItemId::new(item.unique_id().0.clone()),242
+ event_id: event.event_id().map(ToOwned::to_owned),243
+ transaction_id: event.transaction_id().map(ToOwned::to_owned),244
+ sender_id: event.sender().to_owned(),245
+ sender_name: sender_name(event.sender(), event.sender_profile()),246
+ body,247
+ formatted_body,248
+ mentions,249
+ thread_root,250
+ thread_summary,251
+ timestamp_ms: event.timestamp().get().into(),252
+ own: event.is_own(),253
+ send_state: match event.send_state() {254
+ None | Some(EventSendState::Sent { .. }) => SendState::Sent,255
+ Some(EventSendState::NotSentYet { progress: Some(_) }) => SendState::Sending,256
+ Some(EventSendState::NotSentYet { progress: None }) => SendState::Queued,257
+ Some(EventSendState::SendingFailed { is_recoverable, .. }) => SendState::Failed {258
+ retriable: *is_recoverable,259
+ },260
+ },261
+ edited,262
+ unable_to_decrypt,263
+ media,264
+ reply_to,265
+ reactions,266
+ read_by,267
+ }268
+}269
+270
+fn sender_name(271
+ sender: &UserId,272
+ sender_profile: &TimelineDetails<matrix_sdk_ui::timeline::Profile>,273
+) -> String {274
+ match sender_profile {275
+ TimelineDetails::Ready(profile) => profile276
+ .display_name277
+ .clone()278
+ .unwrap_or_else(|| sender.to_string()),279
+ _ => sender.to_string(),280
+ }281
+}282
+283
+fn formatted_body(message: &MessageType) -> Option<String> {284
+ let formatted = match message {285
+ MessageType::Text(content) => content.formatted.as_ref(),286
+ MessageType::Emote(content) => content.formatted.as_ref(),287
+ MessageType::Notice(content) => content.formatted.as_ref(),288
+ MessageType::Audio(content) => content.formatted_caption(),289
+ MessageType::File(content) => content.formatted_caption(),290
+ MessageType::Image(content) => content.formatted_caption(),291
+ MessageType::Video(content) => content.formatted_caption(),292
+ _ => None,293
+ }?;294
+ (formatted.format == MessageFormat::Html).then(|| formatted.body.clone())295
+}296
+297
+fn message_mentions(mentions: &ruma::events::Mentions) -> MessageMentions {298
+ MessageMentions {299
+ user_ids: mentions.user_ids.iter().cloned().collect(),300
+ room: mentions.room,301
+ }302
+}@@ -0,0 +1,1060 @@
1
+use std::sync::{2
+ Arc,3
+ atomic::{AtomicU64, Ordering},4
+};5
+use std::{6
+ fmt,7
+ pin::Pin,8
+ task::{Context, Poll},9
+};10
+11
+use matrix_sdk::{Client, RoomMemberships};12
+use matrix_sdk_ui::{13
+ notification_client::{14
+ NotificationClient, NotificationProcessSetup, NotificationStatus, RawNotificationEvent,15
+ },16
+ sync_service::SyncService,17
+};18
+use ruma::{19
+ EventId, RoomId,20
+ events::{21
+ AnyGlobalAccountDataEventContent, AnyRoomAccountDataEvent, GlobalAccountDataEventType,22
+ RoomAccountDataEventType,23
+ },24
+ serde::Raw,25
+};26
+use thiserror::Error;27
+use tokio::sync::{OnceCell, mpsc, oneshot, watch};28
+29
+use crate::{30
+ AccountCommand, Command, CommandId, ConnectionState, EncryptionState, Event, InteractiveAuth,31
+ InteractiveAuthOutcome, MemberFilter, MemberSummary, NotificationCommand, OwnDevice,32
+ PowerLevelChanges, PushNotification, PushNotificationEvent, PushNotificationStatus,33
+ RoomListFilter, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary,34
+ encryption::encryption_state,35
+ model::{PushNotificationRoom, PushNotificationSender},36
+ normalise::{member_summary, room_summaries},37
+};38
+39
+#[derive(Clone)]40
+pub struct ClientHandle {41
+ commands: mpsc::Sender<DriverRequest>,42
+ next_id: Arc<AtomicU64>,43
+ client: Client,44
+ connection: watch::Receiver<ConnectionState>,45
+ notification_client: Arc<OnceCell<NotificationClient>>,46
+ sync_service: Arc<SyncService>,47
+}48
+49
+impl fmt::Debug for ClientHandle {50
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {51
+ formatter52
+ .debug_struct("ClientHandle")53
+ .finish_non_exhaustive()54
+ }55
+}56
+57
+impl ClientHandle {58
+ /// Current credentials after a refresh. Persist this snapshot atomically59
+ /// when `Event::SessionTokensChanged` arrives; it contains secrets.60
+ #[cfg(feature = "sqlite")]61
+ pub fn session_record(&self) -> Result<crate::SessionRecord, crate::AccountError> {62
+ crate::account::session_record(&self.client)63
+ }64
+65
+ /// Queue a command and return its correlation ID, not its result. Drain66
+ /// `EventStream` concurrently and match one terminal result event by ID.67
+ /// A successful timeline send only means the SDK queue accepted it.68
+ pub async fn send(&self, command: Command) -> Result<CommandId, SendCommandError> {69
+ let id = self.next_command_id();70
+ self.commands71
+ .send(DriverRequest::Command(CommandEnvelope { id, command }))72
+ .await73
+ .map_err(|_| SendCommandError::Closed)?;74
+ Ok(id)75
+ }76
+77
+ pub async fn delete_devices(78
+ &self,79
+ device_ids: Vec<ruma::OwnedDeviceId>,80
+ auth: Option<InteractiveAuth>,81
+ ) -> Result<InteractiveAuthOutcome, CommandResultError> {82
+ let (reply, result) = oneshot::channel();83
+ self.commands84
+ .send(DriverRequest::DeleteDevices {85
+ id: self.next_command_id(),86
+ device_ids,87
+ auth,88
+ reply,89
+ })90
+ .await91
+ .map_err(|_| SendCommandError::Closed)?;92
+ result.await.map_err(|_| CommandResultError::Closed)?93
+ }94
+95
+ pub async fn rename_device(96
+ &self,97
+ device_id: ruma::OwnedDeviceId,98
+ display_name: impl Into<String>,99
+ ) -> Result<CommandId, SendCommandError> {100
+ self.send(Command::Account(AccountCommand::RenameDevice {101
+ device_id,102
+ display_name: display_name.into(),103
+ }))104
+ .await105
+ }106
+107
+ pub async fn set_room_notification_mode(108
+ &self,109
+ room_id: ruma::OwnedRoomId,110
+ mode: RoomNotificationMode,111
+ ) -> Result<CommandId, SendCommandError> {112
+ self.send(Command::Notification(NotificationCommand::SetRoomMode {113
+ room_id,114
+ mode,115
+ }))116
+ .await117
+ }118
+119
+ pub async fn reset_room_notification_mode(120
+ &self,121
+ room_id: ruma::OwnedRoomId,122
+ ) -> Result<CommandId, SendCommandError> {123
+ self.send(Command::Notification(NotificationCommand::ResetRoomMode(124
+ room_id,125
+ )))126
+ .await127
+ }128
+129
+ pub async fn set_default_notification_mode(130
+ &self,131
+ encrypted: bool,132
+ one_to_one: bool,133
+ mode: RoomNotificationMode,134
+ ) -> Result<CommandId, SendCommandError> {135
+ self.send(Command::Notification(NotificationCommand::SetDefaultMode {136
+ encrypted,137
+ one_to_one,138
+ mode,139
+ }))140
+ .await141
+ }142
+143
+ pub async fn add_notification_keyword(144
+ &self,145
+ keyword: impl Into<String>,146
+ ) -> Result<CommandId, SendCommandError> {147
+ self.send(Command::Notification(NotificationCommand::AddKeyword(148
+ keyword.into(),149
+ )))150
+ .await151
+ }152
+153
+ pub async fn remove_notification_keyword(154
+ &self,155
+ keyword: impl Into<String>,156
+ ) -> Result<CommandId, SendCommandError> {157
+ self.send(Command::Notification(NotificationCommand::RemoveKeyword(158
+ keyword.into(),159
+ )))160
+ .await161
+ }162
+163
+ pub async fn open_room(164
+ &self,165
+ room_id: ruma::OwnedRoomId,166
+ ) -> Result<CommandId, SendCommandError> {167
+ self.send(Command::Room(crate::RoomCommand::Open(room_id)))168
+ .await169
+ }170
+171
+ pub async fn close_room(172
+ &self,173
+ room_id: ruma::OwnedRoomId,174
+ ) -> Result<CommandId, SendCommandError> {175
+ self.send(Command::Room(crate::RoomCommand::Close(room_id)))176
+ .await177
+ }178
+179
+ pub async fn open_thread(180
+ &self,181
+ room_id: ruma::OwnedRoomId,182
+ root_event_id: ruma::OwnedEventId,183
+ ) -> Result<CommandId, SendCommandError> {184
+ self.send(Command::Timeline(crate::TimelineCommand::OpenThread {185
+ room_id,186
+ root_event_id,187
+ }))188
+ .await189
+ }190
+191
+ pub async fn close_thread(192
+ &self,193
+ room_id: ruma::OwnedRoomId,194
+ root_event_id: ruma::OwnedEventId,195
+ ) -> Result<CommandId, SendCommandError> {196
+ self.send(Command::Timeline(crate::TimelineCommand::CloseThread {197
+ room_id,198
+ root_event_id,199
+ }))200
+ .await201
+ }202
+203
+ pub async fn join_room(204
+ &self,205
+ room: ruma::OwnedRoomOrAliasId,206
+ via: Vec<ruma::OwnedServerName>,207
+ ) -> Result<ruma::OwnedRoomId, CommandResultError> {208
+ self.room_result(crate::RoomCommand::Join { room, via })209
+ .await210
+ }211
+212
+ pub async fn leave_room(213
+ &self,214
+ room_id: ruma::OwnedRoomId,215
+ ) -> Result<CommandId, SendCommandError> {216
+ self.send(Command::Room(crate::RoomCommand::Leave(room_id)))217
+ .await218
+ }219
+220
+ pub async fn forget_room(221
+ &self,222
+ room_id: ruma::OwnedRoomId,223
+ ) -> Result<CommandId, SendCommandError> {224
+ self.send(Command::Room(crate::RoomCommand::Forget(room_id)))225
+ .await226
+ }227
+228
+ pub async fn accept_invite(229
+ &self,230
+ room_id: ruma::OwnedRoomId,231
+ ) -> Result<CommandId, SendCommandError> {232
+ self.send(Command::Room(crate::RoomCommand::AcceptInvite(room_id)))233
+ .await234
+ }235
+236
+ pub async fn reject_invite(237
+ &self,238
+ room_id: ruma::OwnedRoomId,239
+ ) -> Result<CommandId, SendCommandError> {240
+ self.send(Command::Room(crate::RoomCommand::RejectInvite(room_id)))241
+ .await242
+ }243
+244
+ pub async fn invite_user(245
+ &self,246
+ room_id: ruma::OwnedRoomId,247
+ user_id: ruma::OwnedUserId,248
+ ) -> Result<CommandId, SendCommandError> {249
+ self.send(Command::Room(crate::RoomCommand::InviteUser {250
+ room_id,251
+ user_id,252
+ }))253
+ .await254
+ }255
+256
+ pub async fn kick_user(257
+ &self,258
+ room_id: ruma::OwnedRoomId,259
+ user_id: ruma::OwnedUserId,260
+ reason: Option<String>,261
+ ) -> Result<CommandId, SendCommandError> {262
+ self.send(Command::Room(crate::RoomCommand::KickUser {263
+ room_id,264
+ user_id,265
+ reason,266
+ }))267
+ .await268
+ }269
+270
+ pub async fn ban_user(271
+ &self,272
+ room_id: ruma::OwnedRoomId,273
+ user_id: ruma::OwnedUserId,274
+ reason: Option<String>,275
+ ) -> Result<CommandId, SendCommandError> {276
+ self.send(Command::Room(crate::RoomCommand::BanUser {277
+ room_id,278
+ user_id,279
+ reason,280
+ }))281
+ .await282
+ }283
+284
+ pub async fn unban_user(285
+ &self,286
+ room_id: ruma::OwnedRoomId,287
+ user_id: ruma::OwnedUserId,288
+ reason: Option<String>,289
+ ) -> Result<CommandId, SendCommandError> {290
+ self.send(Command::Room(crate::RoomCommand::UnbanUser {291
+ room_id,292
+ user_id,293
+ reason,294
+ }))295
+ .await296
+ }297
+298
+ pub async fn set_power_levels(299
+ &self,300
+ room_id: ruma::OwnedRoomId,301
+ changes: PowerLevelChanges,302
+ ) -> Result<CommandId, SendCommandError> {303
+ self.send(Command::Room(crate::RoomCommand::SetPowerLevels {304
+ room_id,305
+ changes,306
+ }))307
+ .await308
+ }309
+310
+ pub async fn set_user_power_level(311
+ &self,312
+ room_id: ruma::OwnedRoomId,313
+ user_id: ruma::OwnedUserId,314
+ level: i64,315
+ ) -> Result<CommandId, SendCommandError> {316
+ self.send(Command::Room(crate::RoomCommand::SetUserPowerLevel {317
+ room_id,318
+ user_id,319
+ level,320
+ }))321
+ .await322
+ }323
+324
+ pub async fn create_room(325
+ &self,326
+ options: crate::CreateRoomOptions,327
+ ) -> Result<ruma::OwnedRoomId, CommandResultError> {328
+ self.room_result(crate::RoomCommand::Create(options)).await329
+ }330
+331
+ pub async fn create_direct(332
+ &self,333
+ user_id: ruma::OwnedUserId,334
+ ) -> Result<ruma::OwnedRoomId, CommandResultError> {335
+ self.room_result(crate::RoomCommand::CreateDirect(user_id))336
+ .await337
+ }338
+339
+ async fn room_result(340
+ &self,341
+ command: crate::RoomCommand,342
+ ) -> Result<ruma::OwnedRoomId, CommandResultError> {343
+ let (reply, result) = oneshot::channel();344
+ self.commands345
+ .send(DriverRequest::RoomResult {346
+ id: self.next_command_id(),347
+ command,348
+ reply,349
+ })350
+ .await351
+ .map_err(|_| SendCommandError::Closed)?;352
+ result.await.map_err(|_| CommandResultError::Closed)?353
+ }354
+355
+ pub async fn cancel_send(356
+ &self,357
+ room_id: ruma::OwnedRoomId,358
+ transaction_id: ruma::OwnedTransactionId,359
+ ) -> Result<CommandId, SendCommandError> {360
+ self.send(Command::Room(crate::RoomCommand::CancelSend {361
+ room_id,362
+ transaction_id,363
+ }))364
+ .await365
+ }366
+367
+ pub async fn retry_send(368
+ &self,369
+ room_id: ruma::OwnedRoomId,370
+ transaction_id: ruma::OwnedTransactionId,371
+ ) -> Result<CommandId, SendCommandError> {372
+ self.send(Command::Room(crate::RoomCommand::RetrySend {373
+ room_id,374
+ transaction_id,375
+ }))376
+ .await377
+ }378
+379
+ pub async fn upload_media(380
+ &self,381
+ timeline_id: crate::TimelineId,382
+ filename: String,383
+ mimetype: String,384
+ bytes: Vec<u8>,385
+ caption: Option<String>,386
+ ) -> Result<CommandId, SendCommandError> {387
+ self.send(Command::Media(crate::MediaCommand::Upload {388
+ timeline_id,389
+ filename,390
+ mimetype,391
+ bytes,392
+ caption,393
+ }))394
+ .await395
+ }396
+397
+ pub async fn upload_attachment(398
+ &self,399
+ upload: crate::AttachmentUpload,400
+ ) -> Result<CommandId, SendCommandError> {401
+ self.send(Command::Media(crate::MediaCommand::UploadAttachment(402
+ upload,403
+ )))404
+ .await405
+ }406
+407
+ pub async fn create_key_backup(&self) -> Result<CommandId, SendCommandError> {408
+ self.send(Command::Encryption(409
+ crate::EncryptionCommand::CreateKeyBackup,410
+ ))411
+ .await412
+ }413
+414
+ pub async fn enable_key_backup(&self) -> Result<CommandId, SendCommandError> {415
+ self.send(Command::Encryption(416
+ crate::EncryptionCommand::EnableKeyBackup,417
+ ))418
+ .await419
+ }420
+421
+ pub async fn disable_key_backup(422
+ &self,423
+ delete_from_server: bool,424
+ ) -> Result<CommandId, SendCommandError> {425
+ self.send(Command::Encryption(426
+ crate::EncryptionCommand::DisableKeyBackup { delete_from_server },427
+ ))428
+ .await429
+ }430
+431
+ pub async fn recover_key_backup(432
+ &self,433
+ recovery_key: impl Into<String>,434
+ ) -> Result<CommandId, SendCommandError> {435
+ self.send(Command::Encryption(436
+ crate::EncryptionCommand::RecoverKeyBackup(recovery_key.into()),437
+ ))438
+ .await439
+ }440
+441
+ pub async fn start_verification(442
+ &self,443
+ target: crate::VerificationTarget,444
+ ) -> Result<CommandId, SendCommandError> {445
+ self.send(Command::Encryption(446
+ crate::EncryptionCommand::StartVerification(target),447
+ ))448
+ .await449
+ }450
+451
+ pub fn try_send(&self, command: Command) -> Result<CommandId, SendCommandError> {452
+ let id = self.next_command_id();453
+ self.commands454
+ .try_send(DriverRequest::Command(CommandEnvelope { id, command }))455
+ .map_err(|error| match error {456
+ mpsc::error::TrySendError::Full(_) => SendCommandError::Full,457
+ mpsc::error::TrySendError::Closed(_) => SendCommandError::Closed,458
+ })?;459
+ Ok(id)460
+ }461
+462
+ fn next_command_id(&self) -> CommandId {463
+ CommandId::new(self.next_id.fetch_add(1, Ordering::Relaxed))464
+ }465
+466
+ pub async fn rooms(&self) -> Vec<RoomSummary> {467
+ room_summaries(&self.client).await468
+ }469
+470
+ pub async fn rooms_filtered(&self, filter: &RoomListFilter) -> Vec<RoomSummary> {471
+ let mut room_filter = filter.clone();472
+ let search = room_filter473
+ .search474
+ .take()475
+ .map(|search| search.to_lowercase());476
+477
+ self.rooms()478
+ .await479
+ .into_iter()480
+ .filter(|room| {481
+ room_filter.matches(room)482
+ && search.as_ref().is_none_or(|search| {483
+ room.name.to_lowercase().contains(search)484
+ || room.room_id.as_str().to_lowercase().contains(search)485
+ })486
+ })487
+ .collect()488
+ }489
+490
+ pub async fn devices(&self) -> Result<Vec<OwnDevice>, ClientQueryError> {491
+ let current_device_id = self.client.device_id();492
+ Ok(self493
+ .client494
+ .devices()495
+ .await496
+ .map_err(|source| ClientQueryError::Sdk(source.into()))?497
+ .devices498
+ .into_iter()499
+ .map(|device| OwnDevice {500
+ current: current_device_id == Some(device.device_id.as_ref()),501
+ device_id: device.device_id,502
+ display_name: device.display_name,503
+ last_seen_ip: device.last_seen_ip,504
+ last_seen_timestamp_ms: device.last_seen_ts.map(|timestamp| timestamp.get().into()),505
+ })506
+ .collect())507
+ }508
+509
+ pub async fn room_notification_settings(510
+ &self,511
+ room_id: &RoomId,512
+ ) -> Result<Option<RoomNotificationSettings>, ClientQueryError> {513
+ let room = self514
+ .client515
+ .get_room(room_id)516
+ .ok_or_else(|| ClientQueryError::RoomUnknown(room_id.to_owned()))?;517
+ let custom = room.user_defined_notification_mode().await;518
+ let mode = match custom {519
+ Some(mode) => mode,520
+ None => match room.notification_mode().await {521
+ Some(mode) => mode,522
+ None => return Ok(None),523
+ },524
+ };525
+ Ok(Some(RoomNotificationSettings {526
+ mode: crate::normalise::notification_mode(mode),527
+ custom: custom.is_some(),528
+ }))529
+ }530
+531
+ pub async fn notification_keywords(&self) -> Vec<String> {532
+ self.client533
+ .notification_settings()534
+ .await535
+ .enabled_keywords()536
+ .await537
+ .into_iter()538
+ .collect()539
+ }540
+541
+ pub async fn resolve_push_notification(542
+ &self,543
+ room_id: &RoomId,544
+ event_id: &EventId,545
+ ) -> Result<PushNotificationStatus, ClientQueryError> {546
+ let client = self547
+ .notification_client548
+ .get_or_try_init(|| async {549
+ NotificationClient::new(550
+ self.client.clone(),551
+ NotificationProcessSetup::SingleProcess {552
+ sync_service: self.sync_service.clone(),553
+ },554
+ )555
+ .await556
+ })557
+ .await558
+ .map_err(ClientQueryError::Notification)?;559
+ let status = client560
+ .get_notification(room_id, event_id)561
+ .await562
+ .map_err(ClientQueryError::Notification)?;563
+ Ok(normalise_push_notification(room_id, event_id, status))564
+ }565
+566
+ pub async fn members(567
+ &self,568
+ room_id: &RoomId,569
+ filter: MemberFilter,570
+ ) -> Result<Vec<MemberSummary>, ClientQueryError> {571
+ let room = self572
+ .client573
+ .get_room(room_id)574
+ .ok_or_else(|| ClientQueryError::RoomUnknown(room_id.to_owned()))?;575
+ let memberships = match filter {576
+ MemberFilter::Active => RoomMemberships::ACTIVE,577
+ MemberFilter::Joined => RoomMemberships::JOIN,578
+ MemberFilter::Invited => RoomMemberships::INVITE,579
+ // An empty mask is the SDK's unfiltered wildcard: it matches every580
+ // membership, including custom states that `all()` would exclude.581
+ MemberFilter::All => RoomMemberships::empty(),582
+ };583
+ Ok(room584
+ .members(memberships)585
+ .await586
+ .map_err(ClientQueryError::Sdk)?587
+ .into_iter()588
+ .map(member_summary)589
+ .collect())590
+ }591
+592
+ pub async fn room_power_levels(593
+ &self,594
+ room_id: &RoomId,595
+ ) -> Result<RoomPowerLevels, ClientQueryError> {596
+ let room = self597
+ .client598
+ .get_room(room_id)599
+ .ok_or_else(|| ClientQueryError::RoomUnknown(room_id.to_owned()))?;600
+ let levels = room601
+ .power_levels()602
+ .await603
+ .map_err(|source| ClientQueryError::Sdk(source.into()))?;604
+ Ok(RoomPowerLevels {605
+ ban: levels.ban.into(),606
+ invite: levels.invite.into(),607
+ kick: levels.kick.into(),608
+ redact: levels.redact.into(),609
+ events_default: levels.events_default.into(),610
+ state_default: levels.state_default.into(),611
+ users_default: levels.users_default.into(),612
+ room_notification: levels.notifications.room.into(),613
+ users: levels614
+ .users615
+ .into_iter()616
+ .map(|(user_id, level)| (user_id, level.into()))617
+ .collect(),618
+ events: levels619
+ .events620
+ .into_iter()621
+ .map(|(event_type, level)| (event_type.to_string(), level.into()))622
+ .collect(),623
+ })624
+ }625
+626
+ pub async fn pending_send_ids(627
+ &self,628
+ room_id: &RoomId,629
+ ) -> Result<Vec<ruma::OwnedTransactionId>, ClientQueryError> {630
+ let room = self631
+ .client632
+ .get_room(room_id)633
+ .ok_or_else(|| ClientQueryError::RoomUnknown(room_id.to_owned()))?;634
+ let (echoes, _) = room635
+ .send_queue()636
+ .subscribe()637
+ .await638
+ .map_err(ClientQueryError::SendQueue)?;639
+ Ok(echoes640
+ .into_iter()641
+ .filter(|echo| {642
+ matches!(643
+ echo.content,644
+ matrix_sdk::send_queue::LocalEchoContent::Event { .. }645
+ )646
+ })647
+ .map(|echo| echo.transaction_id)648
+ .collect())649
+ }650
+651
+ pub async fn global_account_data(652
+ &self,653
+ event_type: GlobalAccountDataEventType,654
+ ) -> Result<Option<Raw<AnyGlobalAccountDataEventContent>>, ClientQueryError> {655
+ self.client656
+ .account()657
+ .account_data_raw(event_type)658
+ .await659
+ .map_err(ClientQueryError::Sdk)660
+ }661
+662
+ pub async fn set_global_account_data(663
+ &self,664
+ event_type: GlobalAccountDataEventType,665
+ content: Raw<AnyGlobalAccountDataEventContent>,666
+ ) -> Result<(), ClientQueryError> {667
+ let (reply, result) = oneshot::channel();668
+ self.commands669
+ .send(DriverRequest::SetGlobalAccountData {670
+ event_type,671
+ content,672
+ reply,673
+ })674
+ .await675
+ .map_err(|_| ClientQueryError::EngineStopped)?;676
+ result.await.map_err(|_| ClientQueryError::EngineStopped)?677
+ }678
+679
+ pub async fn room_account_data(680
+ &self,681
+ room_id: &RoomId,682
+ event_type: RoomAccountDataEventType,683
+ ) -> Result<Option<Raw<AnyRoomAccountDataEvent>>, ClientQueryError> {684
+ let room = self685
+ .client686
+ .get_room(room_id)687
+ .ok_or_else(|| ClientQueryError::RoomUnknown(room_id.to_owned()))?;688
+ room.account_data(event_type)689
+ .await690
+ .map_err(ClientQueryError::Sdk)691
+ }692
+693
+ pub async fn set_room_account_data(694
+ &self,695
+ room_id: &RoomId,696
+ event_type: RoomAccountDataEventType,697
+ content: Raw<ruma::events::AnyRoomAccountDataEventContent>,698
+ ) -> Result<(), ClientQueryError> {699
+ let (reply, result) = oneshot::channel();700
+ self.commands701
+ .send(DriverRequest::SetRoomAccountData {702
+ room_id: room_id.to_owned(),703
+ event_type,704
+ content,705
+ reply,706
+ })707
+ .await708
+ .map_err(|_| ClientQueryError::EngineStopped)?;709
+ result.await.map_err(|_| ClientQueryError::EngineStopped)?710
+ }711
+712
+ #[must_use]713
+ pub fn connection_state(&self) -> watch::Receiver<ConnectionState> {714
+ self.connection.clone()715
+ }716
+717
+ #[must_use]718
+ pub fn encryption_state(&self) -> Option<EncryptionState> {719
+ let device_id = self.client.device_id()?.to_owned();720
+ let encryption = self.client.encryption();721
+ Some(encryption_state(722
+ device_id,723
+ encryption.recovery().state(),724
+ encryption.verification_state().get(),725
+ encryption.backups().state(),726
+ ))727
+ }728
+}729
+730
+#[derive(Debug, Eq, PartialEq)]731
+pub(crate) struct CommandEnvelope {732
+ pub(crate) id: CommandId,733
+ pub(crate) command: Command,734
+}735
+736
+#[derive(Debug)]737
+pub(crate) enum DriverRequest {738
+ Command(CommandEnvelope),739
+ DeleteDevices {740
+ id: CommandId,741
+ device_ids: Vec<ruma::OwnedDeviceId>,742
+ auth: Option<InteractiveAuth>,743
+ reply: oneshot::Sender<Result<InteractiveAuthOutcome, CommandResultError>>,744
+ },745
+ RoomResult {746
+ id: CommandId,747
+ command: crate::RoomCommand,748
+ reply: oneshot::Sender<Result<ruma::OwnedRoomId, CommandResultError>>,749
+ },750
+ SetGlobalAccountData {751
+ event_type: GlobalAccountDataEventType,752
+ content: Raw<AnyGlobalAccountDataEventContent>,753
+ reply: oneshot::Sender<Result<(), ClientQueryError>>,754
+ },755
+ SetRoomAccountData {756
+ room_id: ruma::OwnedRoomId,757
+ event_type: RoomAccountDataEventType,758
+ content: Raw<ruma::events::AnyRoomAccountDataEventContent>,759
+ reply: oneshot::Sender<Result<(), ClientQueryError>>,760
+ },761
+}762
+763
+#[derive(Debug)]764
+pub struct EventStream {765
+ events: mpsc::Receiver<Event>,766
+}767
+768
+impl EventStream {769
+ pub async fn recv(&mut self) -> Option<Event> {770
+ self.events.recv().await771
+ }772
+773
+ pub fn try_recv(&mut self) -> Result<Event, mpsc::error::TryRecvError> {774
+ self.events.try_recv()775
+ }776
+}777
+778
+impl futures_util::Stream for EventStream {779
+ type Item = Event;780
+781
+ fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {782
+ self.events.poll_recv(context)783
+ }784
+}785
+786
+#[derive(Debug)]787
+pub(crate) struct DriverPort {788
+ commands: mpsc::Receiver<DriverRequest>,789
+ events: mpsc::Sender<Event>,790
+ connection: watch::Sender<ConnectionState>,791
+}792
+793
+#[derive(Clone, Debug)]794
+pub(crate) struct EventSender(mpsc::Sender<Event>);795
+796
+impl EventSender {797
+ pub(crate) async fn send(&self, event: Event) -> Result<(), SendEventError> {798
+ self.0.send(event).await.map_err(|_| SendEventError::Closed)799
+ }800
+801
+ /// Resolves once every [`EventStream`] receiver has been dropped, so the802
+ /// engine can notice a lost event consumer inside a `select!`.803
+ pub(crate) async fn closed(&self) {804
+ self.0.closed().await;805
+ }806
+}807
+808
+impl DriverPort {809
+ pub(crate) fn event_sender(&self) -> EventSender {810
+ EventSender(self.events.clone())811
+ }812
+813
+ pub(crate) fn connection_sender(&self) -> watch::Sender<ConnectionState> {814
+ self.connection.clone()815
+ }816
+817
+ pub async fn recv(&mut self) -> Option<DriverRequest> {818
+ self.commands.recv().await819
+ }820
+821
+ pub async fn send(&self, event: Event) -> Result<(), SendEventError> {822
+ self.event_sender().send(event).await823
+ }824
+}825
+826
+#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]827
+pub enum SendCommandError {828
+ #[error("the command channel is full")]829
+ Full,830
+ #[error("the engine has stopped")]831
+ Closed,832
+}833
+834
+#[derive(Debug, Error)]835
+pub enum CommandResultError {836
+ #[error(transparent)]837
+ Send(#[from] SendCommandError),838
+ #[error("the engine stopped before the operation completed")]839
+ Closed,840
+ #[error("{0:?}")]841
+ Failed(crate::CommandFailure),842
+}843
+844
+#[derive(Debug, Error)]845
+pub enum ClientQueryError {846
+ #[error("room {0} is not available in the local store")]847
+ RoomUnknown(ruma::OwnedRoomId),848
+ #[error("Matrix query failed")]849
+ Sdk(#[source] matrix_sdk::Error),850
+ #[error("reading the send queue failed")]851
+ SendQueue(#[source] matrix_sdk::send_queue::RoomSendQueueError),852
+ #[error("resolving the push notification failed")]853
+ Notification(#[source] matrix_sdk_ui::notification_client::Error),854
+ #[error("the engine stopped before the operation completed")]855
+ EngineStopped,856
+}857
+858
+#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]859
+pub(crate) enum SendEventError {860
+ #[error("the event receiver has closed")]861
+ Closed,862
+}863
+864
+#[must_use]865
+pub(crate) fn transport_pair(866
+ client: Client,867
+ sync_service: Arc<SyncService>,868
+ command_capacity: usize,869
+ event_capacity: usize,870
+) -> (ClientHandle, DriverPort, EventStream) {871
+ let (command_tx, command_rx) = mpsc::channel(command_capacity);872
+ let (event_tx, event_rx) = mpsc::channel(event_capacity);873
+ let (connection_tx, connection_rx) = watch::channel(ConnectionState::Idle);874
+ (875
+ ClientHandle {876
+ commands: command_tx,877
+ next_id: Arc::new(AtomicU64::new(1)),878
+ client,879
+ connection: connection_rx,880
+ notification_client: Arc::new(OnceCell::new()),881
+ sync_service,882
+ },883
+ DriverPort {884
+ commands: command_rx,885
+ events: event_tx,886
+ connection: connection_tx,887
+ },888
+ EventStream { events: event_rx },889
+ )890
+}891
+892
+fn normalise_push_notification(893
+ room_id: &RoomId,894
+ event_id: &EventId,895
+ status: NotificationStatus,896
+) -> PushNotificationStatus {897
+ match status {898
+ NotificationStatus::Event(item) => {899
+ let event = match &item.raw_event {900
+ RawNotificationEvent::Timeline(event) => {901
+ PushNotificationEvent::Timeline(event.clone())902
+ }903
+ RawNotificationEvent::Invite(event) => PushNotificationEvent::Invite(event.clone()),904
+ };905
+ PushNotificationStatus::Event(Box::new(PushNotification {906
+ event_id: event_id.to_owned(),907
+ event,908
+ sender: PushNotificationSender {909
+ user_id: item.event.sender().to_owned(),910
+ display_name: item.sender_display_name,911
+ avatar_url: item.sender_avatar_url.map(Into::into),912
+ name_ambiguous: item.is_sender_name_ambiguous,913
+ },914
+ room: PushNotificationRoom {915
+ room_id: room_id.to_owned(),916
+ name: item.room_computed_display_name,917
+ avatar_url: item.room_avatar_url.map(Into::into),918
+ canonical_alias: item919
+ .room_canonical_alias920
+ .as_deref()921
+ .and_then(|alias| alias.parse().ok()),922
+ topic: item.room_topic,923
+ encrypted: item.is_room_encrypted,924
+ direct: item.room_is_dm,925
+ joined_members_count: item.joined_members_count,926
+ service_members: item927
+ .service_members928
+ .into_iter()929
+ .filter_map(|user_id| user_id.parse().ok())930
+ .collect(),931
+ active_service_members_count: item.active_service_members_count,932
+ space: item.is_space,933
+ },934
+ noisy: item.is_noisy,935
+ has_mention: item.has_mention,936
+ thread_id: item.thread_id,937
+ }))938
+ }939
+ NotificationStatus::EventNotFound => PushNotificationStatus::NotFound,940
+ NotificationStatus::EventFilteredOut => PushNotificationStatus::FilteredOut,941
+ NotificationStatus::EventRedacted => PushNotificationStatus::Redacted,942
+ }943
+}944
+945
+#[cfg(test)]946
+mod tests {947
+ use matrix_sdk::{Client, SessionMeta, SessionTokens, authentication::matrix::MatrixSession};948
+949
+ use crate::{ConnectionState, LifecycleCommand};950
+951
+ use super::*;952
+953
+ async fn sync_service(client: Client) -> Arc<SyncService> {954
+ Arc::new(SyncService::builder(client).build().await.unwrap())955
+ }956
+957
+ async fn test_client() -> Client {958
+ let client = Client::builder()959
+ .homeserver_url("http://localhost")960
+ .build()961
+ .await962
+ .unwrap();963
+ client964
+ .restore_session(MatrixSession {965
+ meta: SessionMeta {966
+ user_id: "@test:localhost".parse().unwrap(),967
+ device_id: "DEVICE".into(),968
+ },969
+ tokens: SessionTokens {970
+ access_token: "access_token".to_owned(),971
+ refresh_token: None,972
+ },973
+ })974
+ .await975
+ .unwrap();976
+ client977
+ }978
+979
+ #[tokio::test]980
+ async fn transports_are_isolated() {981
+ let first_client = test_client().await;982
+ let second_client = test_client().await;983
+ let (first_handle, mut first_driver, mut first_events) =984
+ transport_pair(first_client.clone(), sync_service(first_client).await, 2, 2);985
+ let (_second_handle, mut second_driver, mut second_events) = transport_pair(986
+ second_client.clone(),987
+ sync_service(second_client).await,988
+ 2,989
+ 2,990
+ );991
+992
+ let id = first_handle993
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))994
+ .await995
+ .unwrap();996
+ let request = first_driver.recv().await.unwrap();997
+ assert!(matches!(998
+ request,999
+ DriverRequest::Command(CommandEnvelope {1000
+ id: received_id,1001
+ command: Command::Lifecycle(LifecycleCommand::Shutdown),1002
+ }) if received_id == id1003
+ ));1004
+ assert!(second_driver.commands.try_recv().is_err());1005
+1006
+ first_driver1007
+ .send(Event::ConnectionChanged(ConnectionState::Online))1008
+ .await1009
+ .unwrap();1010
+ assert_eq!(1011
+ first_events.recv().await,1012
+ Some(Event::ConnectionChanged(ConnectionState::Online))1013
+ );1014
+ assert!(second_events.try_recv().is_err());1015
+ }1016
+1017
+ #[tokio::test]1018
+ async fn bounded_command_channel_reports_pressure() {1019
+ let client = test_client().await;1020
+ let (handle, _driver, _events) =1021
+ transport_pair(client.clone(), sync_service(client).await, 1, 1);1022
+1023
+ handle1024
+ .try_send(Command::Lifecycle(LifecycleCommand::Shutdown))1025
+ .unwrap();1026
+ assert_eq!(1027
+ handle.try_send(Command::Lifecycle(LifecycleCommand::Shutdown)),1028
+ Err(SendCommandError::Full)1029
+ );1030
+ }1031
+1032
+ #[tokio::test]1033
+ async fn cloned_handles_share_command_id_sequence() {1034
+ let client = test_client().await;1035
+ let (handle, mut driver, _events) =1036
+ transport_pair(client.clone(), sync_service(client).await, 1, 1);1037
+ let cloned = handle.clone();1038
+ let first = handle1039
+ .try_send(Command::Lifecycle(LifecycleCommand::Shutdown))1040
+ .unwrap();1041
+ assert_eq!(first.get(), 1);1042
+ assert_eq!(1043
+ cloned.try_send(Command::Lifecycle(LifecycleCommand::Shutdown)),1044
+ Err(SendCommandError::Full)1045
+ );1046
+ driver.recv().await.unwrap();1047
+1048
+ let third = cloned1049
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))1050
+ .await1051
+ .unwrap();1052
+ assert_eq!(third.get(), 3);1053
+ driver.recv().await.unwrap();1054
+1055
+ let fourth = handle1056
+ .try_send(Command::Lifecycle(LifecycleCommand::Shutdown))1057
+ .unwrap();1058
+ assert_eq!(fourth.get(), 4);1059
+ }1060
+}@@ -0,0 +1,234 @@
1
+use std::fmt;2
+3
+use ruma::{UserId, api::client::uiaa, serde::JsonObject};4
+#[cfg(feature = "serde")]5
+use serde::{Deserialize, Serialize};6
+7
+#[derive(Eq, PartialEq)]8
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]9
+#[non_exhaustive]10
+pub enum InteractiveAuth {11
+ Password {12
+ password: String,13
+ session: Option<String>,14
+ },15
+ ReCaptcha {16
+ response: String,17
+ session: Option<String>,18
+ },19
+ Dummy {20
+ session: Option<String>,21
+ },22
+ FallbackAcknowledgement {23
+ session: String,24
+ },25
+ OAuth {26
+ session: Option<String>,27
+ },28
+ Stage {29
+ auth_type: String,30
+ session: Option<String>,31
+ data: JsonObject,32
+ },33
+}34
+35
+impl InteractiveAuth {36
+ #[must_use]37
+ pub fn password(password: impl Into<String>, session: Option<String>) -> Self {38
+ Self::Password {39
+ password: password.into(),40
+ session,41
+ }42
+ }43
+44
+ #[must_use]45
+ pub fn recaptcha(response: impl Into<String>, session: Option<String>) -> Self {46
+ Self::ReCaptcha {47
+ response: response.into(),48
+ session,49
+ }50
+ }51
+52
+ #[must_use]53
+ pub fn dummy(session: Option<String>) -> Self {54
+ Self::Dummy { session }55
+ }56
+57
+ #[must_use]58
+ pub fn fallback_acknowledgement(session: impl Into<String>) -> Self {59
+ Self::FallbackAcknowledgement {60
+ session: session.into(),61
+ }62
+ }63
+64
+ #[must_use]65
+ pub fn oauth(session: Option<String>) -> Self {66
+ Self::OAuth { session }67
+ }68
+69
+ #[must_use]70
+ pub fn stage(auth_type: impl Into<String>, session: Option<String>, data: JsonObject) -> Self {71
+ Self::Stage {72
+ auth_type: auth_type.into(),73
+ session,74
+ data,75
+ }76
+ }77
+78
+ pub(crate) fn into_sdk(self, user_id: &UserId) -> Result<uiaa::AuthData, String> {79
+ match self {80
+ Self::Password { password, session } => {81
+ let mut password = uiaa::Password::new(user_id.to_owned().into(), password);82
+ password.session = session;83
+ Ok(uiaa::AuthData::Password(password))84
+ }85
+ Self::ReCaptcha { response, session } => {86
+ let mut recaptcha = uiaa::ReCaptcha::new(response);87
+ recaptcha.session = session;88
+ Ok(uiaa::AuthData::ReCaptcha(recaptcha))89
+ }90
+ Self::Dummy { session } => {91
+ let mut dummy = uiaa::Dummy::new();92
+ dummy.session = session;93
+ Ok(uiaa::AuthData::Dummy(dummy))94
+ }95
+ Self::FallbackAcknowledgement { session } => {96
+ Ok(uiaa::AuthData::fallback_acknowledgement(session))97
+ }98
+ Self::OAuth { session } => {99
+ let mut oauth = uiaa::OAuth::new();100
+ oauth.session = session;101
+ Ok(uiaa::AuthData::OAuth(oauth))102
+ }103
+ Self::Stage {104
+ auth_type,105
+ session,106
+ data,107
+ } => uiaa::AuthData::new(&auth_type, session, data).map_err(|error| error.to_string()),108
+ }109
+ }110
+}111
+112
+impl fmt::Debug for InteractiveAuth {113
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {114
+ match self {115
+ Self::Password {116
+ password: _,117
+ session,118
+ } => formatter119
+ .debug_struct("Password")120
+ .field("session", &session.as_ref().map(|_| "..."))121
+ .finish_non_exhaustive(),122
+ Self::ReCaptcha {123
+ response: _,124
+ session,125
+ } => formatter126
+ .debug_struct("ReCaptcha")127
+ .field("session", &session.as_ref().map(|_| "..."))128
+ .finish_non_exhaustive(),129
+ Self::Dummy { session } => formatter130
+ .debug_struct("Dummy")131
+ .field("session", &session.as_ref().map(|_| "..."))132
+ .finish(),133
+ Self::FallbackAcknowledgement { session: _ } => formatter134
+ .debug_struct("FallbackAcknowledgement")135
+ .field("session", &"...")136
+ .finish(),137
+ Self::OAuth { session } => formatter138
+ .debug_struct("OAuth")139
+ .field("session", &session.as_ref().map(|_| "..."))140
+ .finish(),141
+ Self::Stage {142
+ auth_type,143
+ session,144
+ data: _,145
+ } => formatter146
+ .debug_struct("Stage")147
+ .field("auth_type", auth_type)148
+ .field("session", &session.as_ref().map(|_| "..."))149
+ .finish_non_exhaustive(),150
+ }151
+ }152
+}153
+154
+#[derive(Clone, Eq, PartialEq)]155
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]156
+#[non_exhaustive]157
+pub struct InteractiveAuthChallenge {158
+ pub flows: Vec<InteractiveAuthFlow>,159
+ pub completed: Vec<String>,160
+ pub parameters_json: Option<String>,161
+ pub session: Option<String>,162
+ pub error: Option<InteractiveAuthFailure>,163
+}164
+165
+impl fmt::Debug for InteractiveAuthChallenge {166
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {167
+ formatter168
+ .debug_struct("InteractiveAuthChallenge")169
+ .field("flows", &self.flows)170
+ .field("completed", &self.completed)171
+ .field(172
+ "parameters_json",173
+ &self.parameters_json.as_ref().map(|_| "..."),174
+ )175
+ .field("session", &self.session.as_ref().map(|_| "..."))176
+ .field("error", &self.error)177
+ .finish()178
+ }179
+}180
+181
+impl From<&uiaa::UiaaInfo> for InteractiveAuthChallenge {182
+ fn from(info: &uiaa::UiaaInfo) -> Self {183
+ Self {184
+ flows: info185
+ .flows186
+ .iter()187
+ .map(|flow| InteractiveAuthFlow {188
+ stages: flow189
+ .stages190
+ .iter()191
+ .map(|stage| stage.as_str().to_owned())192
+ .collect(),193
+ })194
+ .collect(),195
+ completed: info196
+ .completed197
+ .iter()198
+ .map(|stage| stage.as_str().to_owned())199
+ .collect(),200
+ parameters_json: info.params.as_ref().map(|params| params.get().to_owned()),201
+ session: info.session.clone(),202
+ error: info203
+ .auth_error204
+ .as_ref()205
+ .map(|error| InteractiveAuthFailure {206
+ code: error.kind.errcode().as_str().to_owned(),207
+ message: error.message.clone(),208
+ }),209
+ }210
+ }211
+}212
+213
+#[derive(Clone, Debug, Eq, PartialEq)]214
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]215
+#[non_exhaustive]216
+pub struct InteractiveAuthFlow {217
+ pub stages: Vec<String>,218
+}219
+220
+#[derive(Clone, Debug, Eq, PartialEq)]221
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]222
+#[non_exhaustive]223
+pub struct InteractiveAuthFailure {224
+ pub code: String,225
+ pub message: String,226
+}227
+228
+#[derive(Clone, Debug, Eq, PartialEq)]229
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]230
+#[non_exhaustive]231
+pub enum InteractiveAuthOutcome {232
+ Completed,233
+ Challenge(InteractiveAuthChallenge),234
+}@@ -0,0 +1,163 @@
1
+use std::time::Duration;2
+3
+use matrix_sdk::{4
+ attachment::{5
+ AttachmentConfig, AttachmentInfo, BaseAudioInfo, BaseFileInfo, BaseImageInfo,6
+ BaseVideoInfo, Thumbnail,7
+ },8
+ ruma::events::room::message::TextMessageEventContent,9
+};10
+11
+use crate::{AttachmentUpload, CommandErrorKind, UploadDetails, error::CommandError};12
+13
+pub(crate) fn prepare_upload(14
+ mut upload: AttachmentUpload,15
+ max_media_bytes: usize,16
+) -> Result<(AttachmentUpload, mime::Mime, AttachmentConfig), CommandError> {17
+ if upload.bytes.len() > max_media_bytes {18
+ return Err(CommandError::new(19
+ CommandErrorKind::MediaTooLarge,20
+ "attachment exceeds the configured byte limit",21
+ ));22
+ }23
+ let mimetype: mime::Mime = upload24
+ .mimetype25
+ .parse()26
+ .map_err(|_| CommandError::new(CommandErrorKind::Other, "media MIME type is invalid"))?;27
+28
+ let size = ruma::UInt::try_from(upload.bytes.len()).map_err(|_| {29
+ CommandError::new(30
+ CommandErrorKind::MediaTooLarge,31
+ "attachment size is not representable",32
+ )33
+ })?;34
+ let info = match upload.details.as_ref() {35
+ Some(UploadDetails::Image {36
+ width,37
+ height,38
+ blurhash,39
+ }) if mimetype.type_() == mime::IMAGE => AttachmentInfo::Image(BaseImageInfo {40
+ width: width.map(Into::into),41
+ height: height.map(Into::into),42
+ size: Some(size),43
+ blurhash: blurhash.clone(),44
+ ..Default::default()45
+ }),46
+ Some(UploadDetails::Video {47
+ width,48
+ height,49
+ duration_ms,50
+ blurhash,51
+ }) if mimetype.type_() == mime::VIDEO => AttachmentInfo::Video(BaseVideoInfo {52
+ width: width.map(Into::into),53
+ height: height.map(Into::into),54
+ duration: duration_ms.map(Duration::from_millis),55
+ size: Some(size),56
+ blurhash: blurhash.clone(),57
+ }),58
+ Some(UploadDetails::Audio { duration_ms }) if mimetype.type_() == mime::AUDIO => {59
+ AttachmentInfo::Audio(BaseAudioInfo {60
+ duration: duration_ms.map(Duration::from_millis),61
+ size: Some(size),62
+ ..Default::default()63
+ })64
+ }65
+ Some(UploadDetails::Voice {66
+ duration_ms,67
+ waveform,68
+ }) if mimetype.type_() == mime::AUDIO => {69
+ if waveform.iter().any(|sample| *sample > 1024) {70
+ return Err(CommandError::new(71
+ CommandErrorKind::Other,72
+ "voice waveform samples must be at most 1024",73
+ ));74
+ }75
+ AttachmentInfo::Voice(BaseAudioInfo {76
+ duration: Some(Duration::from_millis(*duration_ms)),77
+ size: Some(size),78
+ waveform: Some(79
+ waveform80
+ .iter()81
+ .map(|sample| f32::from(*sample) / 1024.0)82
+ .collect(),83
+ ),84
+ })85
+ }86
+ Some(UploadDetails::File)87
+ if mimetype.type_() != mime::IMAGE88
+ && mimetype.type_() != mime::VIDEO89
+ && mimetype.type_() != mime::AUDIO =>90
+ {91
+ AttachmentInfo::File(BaseFileInfo { size: Some(size) })92
+ }93
+ None => match mimetype.type_() {94
+ mime::IMAGE => AttachmentInfo::Image(BaseImageInfo {95
+ size: Some(size),96
+ ..Default::default()97
+ }),98
+ mime::VIDEO => AttachmentInfo::Video(BaseVideoInfo {99
+ size: Some(size),100
+ ..Default::default()101
+ }),102
+ mime::AUDIO => AttachmentInfo::Audio(BaseAudioInfo {103
+ size: Some(size),104
+ ..Default::default()105
+ }),106
+ _ => AttachmentInfo::File(BaseFileInfo { size: Some(size) }),107
+ },108
+ _ => {109
+ return Err(CommandError::new(110
+ CommandErrorKind::Other,111
+ "attachment metadata does not match its MIME type",112
+ ));113
+ }114
+ };115
+ if upload.thumbnail.is_some() && mimetype.type_() == mime::AUDIO {116
+ return Err(CommandError::new(117
+ CommandErrorKind::MediaUnavailable,118
+ "audio attachments do not support thumbnails",119
+ ));120
+ }121
+ let thumbnail = upload122
+ .thumbnail123
+ .take()124
+ .map(|thumbnail| {125
+ if thumbnail.bytes.len() > max_media_bytes126
+ || thumbnail.width == 0127
+ || thumbnail.height == 0128
+ {129
+ return Err(CommandError::new(130
+ CommandErrorKind::MediaTooLarge,131
+ "thumbnail size or dimensions are invalid",132
+ ));133
+ }134
+ let content_type: mime::Mime = thumbnail.mimetype.parse().map_err(|_| {135
+ CommandError::new(CommandErrorKind::Other, "thumbnail MIME type is invalid")136
+ })?;137
+ if content_type.type_() != mime::IMAGE {138
+ return Err(CommandError::new(139
+ CommandErrorKind::MediaUnavailable,140
+ "thumbnail MIME type must be an image",141
+ ));142
+ }143
+ let size = ruma::UInt::try_from(thumbnail.bytes.len()).map_err(|_| {144
+ CommandError::new(145
+ CommandErrorKind::MediaTooLarge,146
+ "thumbnail size is not representable",147
+ )148
+ })?;149
+ Ok(Thumbnail {150
+ data: thumbnail.bytes,151
+ content_type,152
+ width: thumbnail.width.into(),153
+ height: thumbnail.height.into(),154
+ size,155
+ })156
+ })157
+ .transpose()?;158
+ let config = AttachmentConfig::new()159
+ .info(info)160
+ .thumbnail(thumbnail)161
+ .caption(upload.caption.clone().map(TextMessageEventContent::plain));162
+ Ok((upload, mimetype, config))163
+}@@ -0,0 +1,92 @@
1
+#![cfg(feature = "sqlite")]2
+use std::time::{Duration, SystemTime};3
+4
+use macltok::{5
+ Command, EngineConfig, Event, LifecycleCommand, PasswordLogin, Session, SessionRecord,6
+ StoreConfig,7
+};8
+use matrix_sdk::{ruma::api::client::sync::sync_events::v5, test_utils::mocks::MatrixMockServer};9
+10
+#[tokio::test]11
+async fn password_session_round_trips_through_an_encrypted_store() {12
+ let server = MatrixMockServer::new().await;13
+ server14
+ .mock_versions()15
+ .with_simplified_sliding_sync()16
+ .ok()17
+ .mount()18
+ .await;19
+ server.mock_login().ok().mount().await;20
+ server21
+ .mock_sliding_sync()22
+ .ok(v5::Response::new("1".to_owned()))23
+ .mount()24
+ .await;25
+ let store_path = temporary_store_path();26
+ std::fs::create_dir_all(&store_path).unwrap();27
+ let config = EngineConfig::default().with_initial_history_limit(0);28
+ let session = Session::login_password(29
+ PasswordLogin {30
+ homeserver: server.uri(),31
+ username: "example".into(),32
+ password: "wordpass".into(),33
+ device_display_name: Some("macltok test".into()),34
+ },35
+ StoreConfig::new(&store_path, "test store passphrase"),36
+ )37
+ .await38
+ .unwrap();39
+ let encoded = serde_json::to_vec(&session.record()).unwrap();40
+ let record: SessionRecord = serde_json::from_slice(&encoded).unwrap();41
+ assert_eq!(42
+ record.homeserver().trim_end_matches('/'),43
+ server.uri().trim_end_matches('/')44
+ );45
+46
+ let (engine, handle, mut events) = session.build(config.clone()).await.unwrap();47
+ let running = tokio::spawn(engine.run());48
+ receive_authenticated(&mut events).await;49
+ handle50
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))51
+ .await52
+ .unwrap();53
+ running.await.unwrap().unwrap();54
+55
+ let session = Session::restore(56
+ record,57
+ StoreConfig::new(&store_path, "test store passphrase"),58
+ )59
+ .await60
+ .unwrap();61
+ let (engine, handle, mut events) = session.build(config).await.unwrap();62
+ let running = tokio::spawn(engine.run());63
+ receive_authenticated(&mut events).await;64
+ handle65
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))66
+ .await67
+ .unwrap();68
+ running.await.unwrap().unwrap();69
+70
+ std::fs::remove_dir_all(store_path).unwrap();71
+}72
+73
+async fn receive_authenticated(events: &mut macltok::EventStream) {74
+ tokio::time::timeout(Duration::from_secs(2), async {75
+ while let Some(event) = events.recv().await {76
+ if matches!(event, Event::Authenticated { .. }) {77
+ return;78
+ }79
+ }80
+ panic!("event stream closed before authentication");81
+ })82
+ .await83
+ .expect("authentication event did not arrive");84
+}85
+86
+fn temporary_store_path() -> std::path::PathBuf {87
+ let nonce = SystemTime::now()88
+ .duration_since(SystemTime::UNIX_EPOCH)89
+ .unwrap()90
+ .as_nanos();91
+ std::env::temp_dir().join(format!("macltok-account-{nonce}"))92
+}@@ -0,0 +1,224 @@
1
+use std::time::Duration;2
+3
+use macltok::{4
+ ClientHandle, Command, CommandErrorKind, CommandId, CommandKind, Engine, EngineConfig,5
+ EngineError, Event, EventStream, LifecycleCommand, MessageContent, MessageEditContent,6
+ RichText, RoomCommand, TimelineCommand,7
+};8
+use matrix_sdk::{9
+ ruma::{RoomId, api::client::sync::sync_events::v5, event_id, room_id},10
+ test_utils::mocks::MatrixMockServer,11
+};12
+use tokio::task::JoinHandle;13
+14
+#[tokio::test]15
+async fn empty_message_bodies_report_their_command_ids() {16
+ let room_id = room_id!("!empty-bodies:localhost");17
+ let (server, handle, mut events, running) = start_engine(room_id).await;18
+ open_room(&handle, &mut events, room_id).await;19
+20
+ let commands = [21
+ TimelineCommand::Send {22
+ timeline_id: room_id.to_owned().into(),23
+ content: MessageContent::text(String::new()),24
+ },25
+ TimelineCommand::Send {26
+ timeline_id: room_id.to_owned().into(),27
+ content: MessageContent::emote(RichText::plain(" "), Default::default()),28
+ },29
+ TimelineCommand::Send {30
+ timeline_id: room_id.to_owned().into(),31
+ content: MessageContent::reply(32
+ event_id!("$reply:localhost").to_owned(),33
+ RichText::plain("\n"),34
+ Default::default(),35
+ ),36
+ },37
+ TimelineCommand::Edit {38
+ timeline_id: room_id.to_owned().into(),39
+ event_id: event_id!("$edit:localhost").to_owned(),40
+ content: MessageEditContent::text("\t"),41
+ },42
+ ];43
+44
+ for command in commands {45
+ let id = handle.send(Command::Timeline(command)).await.unwrap();46
+ let failure = receive_command_failure(&mut events, id).await;47
+ assert_eq!(failure.id, id);48
+ assert_eq!(failure.command, CommandKind::Timeline);49
+ assert_eq!(failure.kind, CommandErrorKind::EmptyBody);50
+ }51
+52
+ stop_engine(handle, &mut events, running).await;53
+ drop(server);54
+}55
+56
+#[tokio::test]57
+async fn open_resets_the_timeline_before_succeeding() {58
+ let room_id = room_id!("!open-order:localhost");59
+ let (server, handle, mut events, running) = start_engine(room_id).await;60
+61
+ open_room(&handle, &mut events, room_id).await;62
+63
+ stop_engine(handle, &mut events, running).await;64
+ drop(server);65
+}66
+67
+#[tokio::test]68
+async fn same_room_sends_complete_in_submission_order() {69
+ let room_id = room_id!("!send-order:localhost");70
+ let (server, handle, mut events, running) = start_engine(room_id).await;71
+ open_room(&handle, &mut events, room_id).await;72
+73
+ let first_id = handle74
+ .send(Command::Timeline(TimelineCommand::Send {75
+ timeline_id: room_id.to_owned().into(),76
+ content: MessageContent::text("first"),77
+ }))78
+ .await79
+ .unwrap();80
+ let second_id = handle81
+ .send(Command::Timeline(TimelineCommand::Send {82
+ timeline_id: room_id.to_owned().into(),83
+ content: MessageContent::text("second"),84
+ }))85
+ .await86
+ .unwrap();87
+88
+ assert_eq!(89
+ receive_timeline_completion(&mut events, &[first_id, second_id]).await,90
+ vec![first_id, second_id]91
+ );92
+93
+ stop_engine(handle, &mut events, running).await;94
+ drop(server);95
+}96
+97
+async fn start_engine(98
+ room_id: &RoomId,99
+) -> (100
+ MatrixMockServer,101
+ ClientHandle,102
+ EventStream,103
+ JoinHandle<Result<(), EngineError>>,104
+) {105
+ let server = MatrixMockServer::new().await;106
+ server107
+ .mock_sliding_sync()108
+ .ok(v5::Response::new("1".to_owned()))109
+ .mount()110
+ .await;111
+ let client = server.client_builder().build().await;112
+ server.mock_room_state_encryption().plain().mount().await;113
+ server.sync_joined_room(&client, room_id).await;114
+ let config = EngineConfig::default().with_initial_history_limit(0);115
+ let (engine, handle, events) = Engine::from_client(client, config).await.unwrap();116
+ let running = tokio::spawn(engine.run());117
+ (server, handle, events, running)118
+}119
+120
+async fn open_room(handle: &ClientHandle, events: &mut EventStream, room_id: &RoomId) -> CommandId {121
+ let id = handle122
+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))123
+ .await124
+ .unwrap();125
+ let mut reset_received = false;126
+127
+ tokio::time::timeout(Duration::from_secs(2), async {128
+ loop {129
+ match events.recv().await.expect("event stream closed") {130
+ Event::TimelineReset {131
+ timeline_id: reset_timeline,132
+ ..133
+ } if reset_timeline.room_id() == room_id => reset_received = true,134
+ Event::CommandSucceeded {135
+ id: succeeded_id,136
+ command: CommandKind::Room,137
+ } if succeeded_id == id => {138
+ assert!(reset_received);139
+ break;140
+ }141
+ Event::CommandFailed(failure) if failure.id == id => {142
+ panic!("opening room failed {failure:?}")143
+ }144
+ _ => {}145
+ }146
+ }147
+ })148
+ .await149
+ .expect("room did not open");150
+ id151
+}152
+153
+async fn receive_command_failure(154
+ events: &mut EventStream,155
+ expected_id: CommandId,156
+) -> macltok::CommandFailure {157
+ tokio::time::timeout(Duration::from_secs(2), async {158
+ loop {159
+ match events.recv().await.expect("event stream closed") {160
+ Event::CommandFailed(failure) if failure.id == expected_id => return failure,161
+ Event::CommandSucceeded { id, .. } if id == expected_id => {162
+ panic!("command unexpectedly succeeded")163
+ }164
+ _ => {}165
+ }166
+ }167
+ })168
+ .await169
+ .expect("command failure did not arrive")170
+}171
+172
+async fn receive_timeline_completion(173
+ events: &mut EventStream,174
+ expected_ids: &[CommandId],175
+) -> Vec<CommandId> {176
+ tokio::time::timeout(Duration::from_secs(2), async {177
+ let mut completed = Vec::new();178
+ while completed.len() < expected_ids.len() {179
+ match events.recv().await.expect("event stream closed") {180
+ Event::CommandSucceeded {181
+ id,182
+ command: CommandKind::Timeline,183
+ } if expected_ids.contains(&id) => completed.push(id),184
+ Event::CommandFailed(failure) if expected_ids.contains(&failure.id) => {185
+ panic!("timeline command failed {failure:?}")186
+ }187
+ _ => {}188
+ }189
+ }190
+ completed191
+ })192
+ .await193
+ .expect("timeline commands did not complete")194
+}195
+196
+async fn stop_engine(197
+ handle: ClientHandle,198
+ events: &mut EventStream,199
+ running: JoinHandle<Result<(), EngineError>>,200
+) {201
+ let id = handle202
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))203
+ .await204
+ .unwrap();205
+ tokio::time::timeout(Duration::from_secs(2), async {206
+ let mut succeeded = false;207
+ while let Some(event) = events.recv().await {208
+ match event {209
+ Event::CommandSucceeded {210
+ id: succeeded_id,211
+ command: CommandKind::Lifecycle,212
+ } if succeeded_id == id => succeeded = true,213
+ Event::Stopped => {214
+ assert!(succeeded);215
+ break;216
+ }217
+ _ => {}218
+ }219
+ }220
+ })221
+ .await222
+ .expect("engine did not stop");223
+ running.await.unwrap().unwrap();224
+}@@ -0,0 +1,708 @@
1
+use std::time::Duration;2
+3
+use macltok::{4
+ Command, CommandErrorKind, Engine, EngineConfig, Event, HistoryState, LifecycleCommand,5
+ MediaCommand, MessageContent, RoomCommand, TimelineCommand, TimelineDiff, TimelineId,6
+ TimelineItem,7
+};8
+use matrix_sdk::{9
+ ruma::{api::client::sync::sync_events::v5, event_id, mxc_uri, room_id},10
+ test_utils::mocks::{MatrixMockServer, RoomMessagesResponseTemplate},11
+};12
+use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};13
+14
+#[tokio::test]15
+async fn opens_a_room_and_sends_messages() {16
+ let server = MatrixMockServer::new().await;17
+ server18
+ .mock_sliding_sync()19
+ .ok(v5::Response::new("1".to_owned()))20
+ .mount()21
+ .await;22
+ let client = server.client_builder().build().await;23
+ let room_id = room_id!("!toolkit:localhost");24
+ server.mock_room_state_encryption().plain().mount().await;25
+ server.sync_joined_room(&client, room_id).await;26
+ server27
+ .mock_room_send()28
+ .body_matches_partial_json(serde_json::json!({ "body": "hello from macltok" }))29
+ .ok(event_id!("$sent:localhost"))30
+ .expect(1)31
+ .mount()32
+ .await;33
+34
+ let config = EngineConfig::default().with_initial_history_limit(0);35
+ let sync_client = client.clone();36
+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();37
+ let running = tokio::spawn(engine.run());38
+39
+ handle40
+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))41
+ .await42
+ .unwrap();43
+ receive_until(44
+ &mut events,45
+ |event| matches!(event, Event::TimelineReset { timeline_id: reset, .. } if reset.room_id() == room_id),46
+ )47
+ .await;48
+49
+ handle50
+ .send(Command::Timeline(TimelineCommand::Send {51
+ timeline_id: room_id.to_owned().into(),52
+ content: MessageContent::text("hello from macltok"),53
+ }))54
+ .await55
+ .unwrap();56
+ tokio::time::timeout(Duration::from_secs(2), async {57
+ loop {58
+ let sent = server59
+ .received_requests()60
+ .await61
+ .unwrap()62
+ .iter()63
+ .any(|request| {64
+ request.url.path().contains("/send/m.room.message/")65
+ && serde_json::from_slice::<serde_json::Value>(&request.body)66
+ .is_ok_and(|body| body["body"] == "hello from macltok")67
+ });68
+ if sent {69
+ break;70
+ }71
+ tokio::task::yield_now().await;72
+ }73
+ })74
+ .await75
+ .expect("message did not reach the homeserver");76
+77
+ server.mock_media_download().ok_image().mount().await;78
+ server.mock_authed_media_download().ok_image().mount().await;79
+ server80
+ .sync_room(81
+ &sync_client,82
+ JoinedRoomBuilder::new(room_id).add_timeline_event(83
+ EventFactory::new()84
+ .image(85
+ "image.jpg".into(),86
+ mxc_uri!("mxc://localhost/image").to_owned(),87
+ )88
+ .sender(&ALICE),89
+ ),90
+ )91
+ .await;92
+ let media_event = receive_until(&mut events, |event| {93
+ event_messages(event).any(|message| message.media.is_some())94
+ })95
+ .await;96
+ let media_id = event_messages(&media_event)97
+ .find_map(|message| message.media.as_ref().map(|media| media.id.clone()))98
+ .unwrap();99
+ handle100
+ .send(Command::Media(MediaCommand::Fetch {101
+ id: media_id.clone(),102
+ size: macltok::MediaSize::Full,103
+ }))104
+ .await105
+ .unwrap();106
+ let media = receive_until(&mut events, |event| {107
+ matches!(event, Event::MediaReady { media_id: received, .. } if received == &media_id)108
+ })109
+ .await;110
+ assert!(matches!(media, Event::MediaReady { bytes, .. } if !bytes.is_empty()));111
+112
+ handle113
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))114
+ .await115
+ .unwrap();116
+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;117
+ running.await.unwrap().unwrap();118
+}119
+120
+#[tokio::test]121
+async fn keeps_open_room_timelines_independent() {122
+ let server = MatrixMockServer::new().await;123
+ server124
+ .mock_sliding_sync()125
+ .ok(v5::Response::new("1".to_owned()))126
+ .mount()127
+ .await;128
+ let client = server.client_builder().build().await;129
+ let sync_client = client.clone();130
+ let first_id = room_id!("!first:localhost");131
+ let second_id = room_id!("!second:localhost");132
+ server.mock_room_state_encryption().plain().mount().await;133
+ server.sync_joined_room(&client, first_id).await;134
+ server.sync_joined_room(&client, second_id).await;135
+ let config = EngineConfig::default().with_initial_history_limit(0);136
+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();137
+ let running = tokio::spawn(engine.run());138
+139
+ handle140
+ .send(Command::Room(RoomCommand::Open(first_id.to_owned())))141
+ .await142
+ .unwrap();143
+ handle144
+ .send(Command::Room(RoomCommand::Open(second_id.to_owned())))145
+ .await146
+ .unwrap();147
+ receive_until(148
+ &mut events,149
+ |event| matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == first_id),150
+ )151
+ .await;152
+ receive_until(153
+ &mut events,154
+ |event| matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == second_id),155
+ )156
+ .await;157
+ wait_for_room_subscriptions(&server, &[first_id, second_id]).await;158
+159
+ let factory = EventFactory::new();160
+ server161
+ .sync_room(162
+ &sync_client,163
+ JoinedRoomBuilder::new(first_id)164
+ .add_timeline_event(factory.text_msg("first update").sender(&ALICE)),165
+ )166
+ .await;167
+ server168
+ .sync_room(169
+ &sync_client,170
+ JoinedRoomBuilder::new(second_id)171
+ .add_timeline_event(factory.text_msg("second update").sender(&ALICE)),172
+ )173
+ .await;174
+175
+ let mut first_updated = false;176
+ let mut second_updated = false;177
+ tokio::time::timeout(Duration::from_secs(2), async {178
+ while !(first_updated && second_updated) {179
+ let event = events.recv().await.expect("event stream closed");180
+ if let Event::TimelineChanged { timeline_id, .. } = &event {181
+ first_updated |= timeline_id.room_id() == first_id182
+ && event_messages(&event).any(|message| message.body == "first update");183
+ second_updated |= timeline_id.room_id() == second_id184
+ && event_messages(&event).any(|message| message.body == "second update");185
+ }186
+ }187
+ })188
+ .await189
+ .expect("both retained rooms did not update");190
+191
+ handle192
+ .send(Command::Room(RoomCommand::Close(first_id.to_owned())))193
+ .await194
+ .unwrap();195
+ server196
+ .sync_room(197
+ &sync_client,198
+ JoinedRoomBuilder::new(second_id)199
+ .add_timeline_event(factory.text_msg("still live").sender(&ALICE)),200
+ )201
+ .await;202
+ receive_until(&mut events, |event| {203
+ matches!(event, Event::TimelineChanged { timeline_id, .. } if timeline_id.room_id() == second_id)204
+ && event_messages(event).any(|message| message.body == "still live")205
+ })206
+ .await;207
+208
+ handle209
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))210
+ .await211
+ .unwrap();212
+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;213
+ running.await.unwrap().unwrap();214
+}215
+216
+#[tokio::test]217
+async fn resync_replaces_timeline_before_ack_and_preserves_following_diffs() {218
+ let server = MatrixMockServer::new().await;219
+ server220
+ .mock_sliding_sync()221
+ .ok(v5::Response::new("1".to_owned()))222
+ .mount()223
+ .await;224
+ let client = server.client_builder().build().await;225
+ let sync_client = client.clone();226
+ let room_id = room_id!("!resync:localhost");227
+ server.mock_room_state_encryption().plain().mount().await;228
+ server.sync_joined_room(&client, room_id).await;229
+ let config = EngineConfig::default().with_initial_history_limit(0);230
+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();231
+ let running = tokio::spawn(engine.run());232
+233
+ handle234
+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))235
+ .await236
+ .unwrap();237
+ receive_until(&mut events, |event| {238
+ matches!(event,239
+ Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id240
+ )241
+ })242
+ .await;243
+ server244
+ .sync_room(245
+ &sync_client,246
+ JoinedRoomBuilder::new(room_id)247
+ .add_timeline_event(EventFactory::new().text_msg("before reset").sender(&ALICE)),248
+ )249
+ .await;250
+ receive_until(&mut events, |event| {251
+ event_messages(event).any(|message| message.body == "before reset")252
+ })253
+ .await;254
+255
+ let id = handle256
+ .send(Command::Timeline(TimelineCommand::Resync {257
+ timeline_id: room_id.to_owned().into(),258
+ }))259
+ .await260
+ .unwrap();261
+ let mut reset = None;262
+ tokio::time::timeout(Duration::from_secs(2), async {263
+ loop {264
+ match events.recv().await.expect("event stream closed") {265
+ Event::TimelineReset { timeline_id, items } if timeline_id.room_id() == room_id => {266
+ reset = Some(items);267
+ }268
+ Event::CommandSucceeded { id: completed, .. } if completed == id => {269
+ assert!(reset.is_some(), "resync completed before its reset");270
+ break;271
+ }272
+ Event::CommandFailed(failure) if failure.id == id => {273
+ panic!("resync failed: {failure:?}")274
+ }275
+ _ => {}276
+ }277
+ }278
+ })279
+ .await280
+ .expect("resync did not complete");281
+ let mut items = reset.unwrap();282
+ assert!(items.iter().any(|item| matches!(item,283
+ TimelineItem::Message(message) if message.body == "before reset"284
+ )));285
+286
+ server287
+ .sync_room(288
+ &sync_client,289
+ JoinedRoomBuilder::new(room_id)290
+ .add_timeline_event(EventFactory::new().text_msg("after reset").sender(&ALICE)),291
+ )292
+ .await;293
+ tokio::time::timeout(Duration::from_secs(2), async {294
+ loop {295
+ if let Event::TimelineChanged { timeline_id, diffs } =296
+ events.recv().await.expect("event stream closed")297
+ && timeline_id.room_id() == room_id298
+ {299
+ for diff in diffs {300
+ diff.try_apply(&mut items)301
+ .expect("resynced snapshot accepts following diffs");302
+ }303
+ if items.iter().any(|item| {304
+ matches!(item,305
+ TimelineItem::Message(message) if message.body == "after reset"306
+ )307
+ }) {308
+ break;309
+ }310
+ }311
+ }312
+ })313
+ .await314
+ .expect("resynced timeline did not update");315
+316
+ handle317
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))318
+ .await319
+ .unwrap();320
+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;321
+ running.await.unwrap().unwrap();322
+}323
+324
+#[tokio::test]325
+async fn closes_and_reopens_a_room_in_command_order() {326
+ let server = MatrixMockServer::new().await;327
+ server328
+ .mock_sliding_sync()329
+ .ok(v5::Response::new("1".to_owned()))330
+ .mount()331
+ .await;332
+ let client = server.client_builder().build().await;333
+ let room_id = room_id!("!reopen:localhost");334
+ server.mock_room_state_encryption().plain().mount().await;335
+ server.sync_joined_room(&client, room_id).await;336
+ let config = EngineConfig::default().with_initial_history_limit(0);337
+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();338
+ let running = tokio::spawn(engine.run());339
+340
+ let initial_open = handle341
+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))342
+ .await343
+ .unwrap();344
+ receive_until(345
+ &mut events,346
+ |event| matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id),347
+ )348
+ .await;349
+ receive_until(350
+ &mut events,351
+ |event| matches!(event, Event::CommandSucceeded { id, .. } if id == &initial_open),352
+ )353
+ .await;354
+355
+ let close = handle356
+ .send(Command::Room(RoomCommand::Close(room_id.to_owned())))357
+ .await358
+ .unwrap();359
+ let replacement_open = handle360
+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))361
+ .await362
+ .unwrap();363
+ let second_close = handle364
+ .send(Command::Room(RoomCommand::Close(room_id.to_owned())))365
+ .await366
+ .unwrap();367
+ let final_open = handle368
+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))369
+ .await370
+ .unwrap();371
+ let expected = [close, replacement_open, second_close, final_open];372
+ let mut completed = 0;373
+ let mut resets = 0;374
+ tokio::time::timeout(Duration::from_secs(3), async {375
+ while completed < expected.len() {376
+ match events.recv().await.expect("event stream closed") {377
+ Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id => {378
+ resets += 1;379
+ assert!(resets <= 2, "a room generation reset more than once");380
+ }381
+ Event::CommandSucceeded { id, .. } if expected.contains(&id) => {382
+ assert_eq!(383
+ id, expected[completed],384
+ "room lifecycle commands completed out of order"385
+ );386
+ if completed == 1 || completed == 3 {387
+ assert_eq!(388
+ resets,389
+ completed / 2 + 1,390
+ "room open completed before timeline reset"391
+ );392
+ }393
+ completed += 1;394
+ }395
+ Event::CommandFailed(failure) if expected.contains(&failure.id) => {396
+ panic!("room lifecycle command failed: {failure:?}");397
+ }398
+ _ => {}399
+ }400
+ }401
+ })402
+ .await403
+ .expect("room lifecycle commands did not complete");404
+ handle405
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))406
+ .await407
+ .unwrap();408
+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;409
+ running.await.unwrap().unwrap();410
+}411
+412
+#[tokio::test]413
+async fn thread_command_waits_for_opening_timeline() {414
+ let server = MatrixMockServer::new().await;415
+ server416
+ .mock_sliding_sync()417
+ .ok(v5::Response::new("1".to_owned()))418
+ .mount()419
+ .await;420
+ let client = server.client_builder().build().await;421
+ let room_id = room_id!("!thread:localhost");422
+ let root = event_id!("$thread-root:localhost");423
+ server.mock_room_state_encryption().plain().mount().await;424
+ server.sync_joined_room(&client, room_id).await;425
+ let (engine, handle, mut events) = Engine::from_client(426
+ client,427
+ EngineConfig::default().with_initial_history_limit(0),428
+ )429
+ .await430
+ .unwrap();431
+ let running = tokio::spawn(engine.run());432
+ let room_open = handle433
+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))434
+ .await435
+ .unwrap();436
+ receive_until(437
+ &mut events,438
+ |event| matches!(event, Event::CommandSucceeded { id, .. } if *id == room_open),439
+ )440
+ .await;441
+442
+ let open = handle443
+ .send(Command::Timeline(TimelineCommand::OpenThread {444
+ room_id: room_id.to_owned(),445
+ root_event_id: root.to_owned(),446
+ }))447
+ .await448
+ .unwrap();449
+ let send = handle450
+ .send(Command::Timeline(TimelineCommand::Send {451
+ timeline_id: TimelineId::thread(room_id.to_owned(), root.to_owned()),452
+ content: MessageContent::text(""),453
+ }))454
+ .await455
+ .unwrap();456
+ let resync = handle457
+ .send(Command::Timeline(TimelineCommand::Resync {458
+ timeline_id: TimelineId::thread(room_id.to_owned(), root.to_owned()),459
+ }))460
+ .await461
+ .unwrap();462
+ let mut open_succeeded = false;463
+ tokio::time::timeout(Duration::from_secs(3), async {464
+ loop {465
+ match events.recv().await.expect("event stream closed") {466
+ Event::CommandSucceeded { id, .. } if id == open => open_succeeded = true,467
+ Event::CommandFailed(failure) if failure.id == send => {468
+ assert!(open_succeeded, "thread command completed before opening");469
+ assert_eq!(failure.kind, CommandErrorKind::EmptyBody);470
+ break;471
+ }472
+ Event::CommandFailed(failure) if failure.id == open => {473
+ panic!("thread open failed: {failure:?}");474
+ }475
+ _ => {}476
+ }477
+ }478
+ })479
+ .await480
+ .expect("thread commands did not complete");481
+ let mut reset_seen = false;482
+ tokio::time::timeout(Duration::from_secs(3), async {483
+ loop {484
+ match events.recv().await.expect("event stream closed") {485
+ Event::TimelineReset { timeline_id, .. }486
+ if timeline_id.thread_root().is_some_and(|event| event == root) =>487
+ {488
+ reset_seen = true489
+ }490
+ Event::CommandSucceeded { id, .. } if id == resync => {491
+ assert!(reset_seen, "thread resync completed before its reset");492
+ break;493
+ }494
+ Event::CommandFailed(failure) if failure.id == resync => {495
+ panic!("thread resync failed: {failure:?}")496
+ }497
+ _ => {}498
+ }499
+ }500
+ })501
+ .await502
+ .expect("thread resync did not complete");503
+ handle504
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))505
+ .await506
+ .unwrap();507
+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;508
+ running.await.unwrap().unwrap();509
+}510
+511
+#[tokio::test]512
+async fn pagination_reports_and_remembers_start_of_history() {513
+ let server = MatrixMockServer::new().await;514
+ server515
+ .mock_sliding_sync()516
+ .ok(v5::Response::new("1".to_owned()))517
+ .mount()518
+ .await;519
+ let client = server.client_builder().build().await;520
+ let sync_client = client.clone();521
+ let room_id = room_id!("!history:localhost");522
+ server.mock_room_state_encryption().plain().mount().await;523
+ server.sync_joined_room(&client, room_id).await;524
+ let config = EngineConfig::default().with_initial_history_limit(0);525
+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();526
+ let running = tokio::spawn(engine.run());527
+528
+ handle529
+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))530
+ .await531
+ .unwrap();532
+ receive_until(533
+ &mut events,534
+ |event| matches!(event, Event::TimelineReset { timeline_id: reset, .. } if reset.room_id() == room_id),535
+ )536
+ .await;537
+538
+ server539
+ .sync_room(540
+ &sync_client,541
+ JoinedRoomBuilder::new(room_id)542
+ .add_timeline_event(EventFactory::new().text_msg("latest").sender(&ALICE))543
+ .set_timeline_limited()544
+ .set_timeline_prev_batch("before-latest"),545
+ )546
+ .await;547
+ receive_until(&mut events, |event| {548
+ matches!(event, Event::TimelineChanged { timeline_id: changed, .. } if changed.room_id() == room_id)549
+ && event_messages(event).any(|message| message.body == "latest")550
+ })551
+ .await;552
+553
+ server554
+ .mock_room_messages()555
+ .match_from("before-latest")556
+ .ok(RoomMessagesResponseTemplate::default())557
+ .mock_once()558
+ .mount()559
+ .await;560
+ let mut reached_start = false;561
+ for _ in 0..3 {562
+ handle563
+ .send(Command::Timeline(TimelineCommand::Paginate {564
+ timeline_id: room_id.to_owned().into(),565
+ limit: 10,566
+ }))567
+ .await568
+ .unwrap();569
+ receive_history(&mut events, room_id, HistoryState::Loading).await;570
+ match receive_history_terminal(&mut events, room_id).await {571
+ HistoryState::StartReached => {572
+ reached_start = true;573
+ break;574
+ }575
+ HistoryState::Idle => {}576
+ state => panic!("unexpected pagination state {state:?}"),577
+ }578
+ }579
+ assert!(580
+ reached_start,581
+ "pagination did not reach the mocked room start"582
+ );583
+584
+ handle585
+ .send(Command::Timeline(TimelineCommand::Paginate {586
+ timeline_id: room_id.to_owned().into(),587
+ limit: 10,588
+ }))589
+ .await590
+ .unwrap();591
+ receive_history(&mut events, room_id, HistoryState::StartReached).await;592
+593
+ handle594
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))595
+ .await596
+ .unwrap();597
+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;598
+ running.await.unwrap().unwrap();599
+}600
+601
+async fn receive_history(602
+ events: &mut macltok::EventStream,603
+ room_id: &matrix_sdk::ruma::RoomId,604
+ expected: HistoryState,605
+) {606
+ receive_until(events, |event| {607
+ matches!(event, Event::HistoryChanged { timeline_id: changed, state } if changed.room_id() == room_id && state == &expected)608
+ })609
+ .await;610
+}611
+612
+fn event_messages(event: &Event) -> std::vec::IntoIter<&macltok::TimelineMessage> {613
+ let items = match event {614
+ Event::TimelineReset { items, .. } => items.iter().collect::<Vec<_>>(),615
+ Event::TimelineChanged { diffs, .. } => {616
+ diffs.iter().flat_map(diff_items).collect::<Vec<_>>()617
+ }618
+ _ => Vec::new(),619
+ };620
+ items621
+ .into_iter()622
+ .filter_map(|item| match item {623
+ TimelineItem::Message(message) => Some(message.as_ref()),624
+ _ => None,625
+ })626
+ .collect::<Vec<_>>()627
+ .into_iter()628
+}629
+630
+fn diff_items(diff: &TimelineDiff) -> Vec<&TimelineItem> {631
+ match diff {632
+ TimelineDiff::Append { values } | TimelineDiff::Reset { values } => values.iter().collect(),633
+ TimelineDiff::PushFront { value }634
+ | TimelineDiff::PushBack { value }635
+ | TimelineDiff::Insert { value, .. }636
+ | TimelineDiff::Set { value, .. } => vec![value],637
+ TimelineDiff::Clear638
+ | TimelineDiff::PopFront639
+ | TimelineDiff::PopBack640
+ | TimelineDiff::Remove { .. }641
+ | TimelineDiff::Truncate { .. } => Vec::new(),642
+ _ => Vec::new(),643
+ }644
+}645
+646
+async fn receive_history_terminal(647
+ events: &mut macltok::EventStream,648
+ room_id: &matrix_sdk::ruma::RoomId,649
+) -> HistoryState {650
+ match receive_until(events, |event| {651
+ matches!(event, Event::HistoryChanged { timeline_id: changed, state } if changed.room_id() == room_id && !matches!(state, HistoryState::Loading))652
+ })653
+ .await654
+ {655
+ Event::HistoryChanged { state, .. } => state,656
+ _ => unreachable!(),657
+ }658
+}659
+660
+async fn wait_for_room_subscriptions(661
+ server: &MatrixMockServer,662
+ room_ids: &[&matrix_sdk::ruma::RoomId],663
+) {664
+ tokio::time::timeout(Duration::from_secs(2), async {665
+ loop {666
+ let found = server667
+ .received_requests()668
+ .await669
+ .unwrap()670
+ .iter()671
+ .filter_map(|request| {672
+ serde_json::from_slice::<serde_json::Value>(&request.body).ok()673
+ })674
+ .filter_map(|body| {675
+ body.get("room_subscriptions")676
+ .and_then(|value| value.as_object())677
+ .cloned()678
+ })679
+ .any(|subscriptions| {680
+ room_ids681
+ .iter()682
+ .all(|room_id| subscriptions.contains_key(room_id.as_str()))683
+ });684
+ if found {685
+ break;686
+ }687
+ tokio::task::yield_now().await;688
+ }689
+ })690
+ .await691
+ .expect("Sliding Sync did not retain every open room subscription");692
+}693
+694
+async fn receive_until(695
+ events: &mut macltok::EventStream,696
+ predicate: impl Fn(&Event) -> bool,697
+) -> Event {698
+ tokio::time::timeout(Duration::from_secs(2), async {699
+ while let Some(event) = events.recv().await {700
+ if predicate(&event) {701
+ return event;702
+ }703
+ }704
+ panic!("event stream closed before the expected event");705
+ })706
+ .await707
+ .expect("expected event did not arrive")708
+}@@ -0,0 +1,943 @@
1
+use std::{sync::Arc, time::Duration};2
+3
+use macltok::{4
+ AttachmentUpload, ClientHandle, Command, CommandErrorKind, Engine, EngineConfig, EngineError,5
+ Event, EventStream, LifecycleCommand, MediaCommand, MediaId, MediaSize, RoomCommand,6
+ TimelineDiff, TimelineItem, UploadDetails, UploadThumbnail,7
+};8
+use matrix_sdk::{9
+ ruma::{10
+ api::client::sync::sync_events::v5,11
+ event_id,12
+ events::room::{13
+ MediaSource,14
+ message::{15
+ AudioMessageEventContent, FileInfo, FileMessageEventContent,16
+ ImageMessageEventContent, MessageType, RoomMessageEventContent,17
+ },18
+ },19
+ media::Method,20
+ mxc_uri, room_id,21
+ },22
+ test_utils::mocks::MatrixMockServer,23
+};24
+use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};25
+use serde_json::{Value, json};26
+use wiremock::{27
+ Mock, ResponseTemplate,28
+ matchers::{method, path_regex},29
+};30
+31
+struct MediaHarness {32
+ server: MatrixMockServer,33
+ client: matrix_sdk::Client,34
+ handle: ClientHandle,35
+ events: EventStream,36
+ running: tokio::task::JoinHandle<Result<(), EngineError>>,37
+ media_id: MediaId,38
+}39
+40
+#[tokio::test]41
+async fn full_size_uses_full_source() {42
+ let mut harness = start(file_with_thumbnail()).await;43
+ mount_downloads(&harness.server).await;44
+45
+ let bytes = fetch_ready(&mut harness, MediaSize::Full).await;46
+ assert_eq!(&*bytes, b"binaryjpegfullimagedata");47
+ assert!(48
+ media_paths(&harness.server)49
+ .await50
+ .iter()51
+ .any(|path| path.ends_with("/localhost/full"))52
+ );53
+54
+ stop(harness).await;55
+}56
+57
+#[tokio::test]58
+async fn simultaneous_fetches_share_one_download() {59
+ let mut harness = start(plain_image()).await;60
+ Mock::given(method("GET"))61
+ .and(path_regex("/download/localhost/image$"))62
+ .respond_with(63
+ ResponseTemplate::new(200)64
+ .set_body_bytes(b"shared image bytes".to_vec())65
+ .set_delay(Duration::from_millis(300)),66
+ )67
+ .mount(harness.server.server())68
+ .await;69
+70
+ let mut pending = Vec::new();71
+ for _ in 0..2 {72
+ pending.push(73
+ harness74
+ .handle75
+ .send(Command::Media(MediaCommand::Fetch {76
+ id: harness.media_id.clone(),77
+ size: MediaSize::Full,78
+ }))79
+ .await80
+ .unwrap(),81
+ );82
+ }83
+ let mut ready = false;84
+ tokio::time::timeout(Duration::from_secs(5), async {85
+ loop {86
+ match harness.events.recv().await.expect("event stream closed") {87
+ Event::MediaReady {88
+ media_id, bytes, ..89
+ } if media_id == harness.media_id => {90
+ assert_eq!(&*bytes, b"shared image bytes");91
+ ready = true;92
+ }93
+ Event::CommandSucceeded { id, .. } => pending.retain(|waiting| *waiting != id),94
+ Event::CommandFailed(failure) if pending.contains(&failure.id) => {95
+ panic!("fetch failed: {failure:?}");96
+ }97
+ _ => {}98
+ }99
+ if ready && pending.is_empty() {100
+ break;101
+ }102
+ }103
+ })104
+ .await105
+ .expect("both fetches did not complete");106
+ let downloads = harness107
+ .server108
+ .received_requests()109
+ .await110
+ .unwrap()111
+ .iter()112
+ .filter(|request| request.url.path().ends_with("/download/localhost/image"))113
+ .count();114
+ assert_eq!(downloads, 1, "duplicate in-flight media download");115
+ stop(harness).await;116
+}117
+118
+#[tokio::test]119
+async fn downloaded_media_over_limit_is_rejected() {120
+ let mut harness = start_with_config(121
+ plain_image(),122
+ EngineConfig::default()123
+ .with_initial_history_limit(0)124
+ .with_max_media_bytes(4),125
+ )126
+ .await;127
+ Mock::given(method("GET"))128
+ .and(path_regex("/download/localhost/image$"))129
+ .respond_with(ResponseTemplate::new(200).set_body_bytes(b"too large".to_vec()))130
+ .mount(harness.server.server())131
+ .await;132
+ let id = harness133
+ .handle134
+ .send(Command::Media(MediaCommand::Fetch {135
+ id: harness.media_id.clone(),136
+ size: MediaSize::Full,137
+ }))138
+ .await139
+ .unwrap();140
+ receive_until(&mut harness.events, |event| {141
+ matches!(event, Event::CommandFailed(failure) if failure.id == id && failure.kind == CommandErrorKind::MediaTooLarge)142
+ })143
+ .await;144
+ stop(harness).await;145
+}146
+147
+#[tokio::test]148
+async fn thumbnail_size_prefers_explicit_thumbnail() {149
+ let mut harness = start(file_with_thumbnail()).await;150
+ mount_downloads(&harness.server).await;151
+152
+ let bytes = fetch_ready(&mut harness, MediaSize::Thumbnail).await;153
+ assert_eq!(&*bytes, b"binaryjpegfullimagedata");154
+ let paths = media_paths(&harness.server).await;155
+ assert!(156
+ paths157
+ .iter()158
+ .any(|path| path.ends_with("/localhost/explicit-thumbnail"))159
+ );160
+ assert!(!paths.iter().any(|path| path.ends_with("/localhost/full")));161
+162
+ stop(harness).await;163
+}164
+165
+#[tokio::test]166
+async fn plain_image_thumbnail_uses_thumbnail_endpoint() {167
+ let mut harness = start(plain_image()).await;168
+ harness169
+ .server170
+ .mock_media_thumbnail(Method::Scale, 800, 800, false)171
+ .ok()172
+ .mount()173
+ .await;174
+ harness175
+ .server176
+ .mock_authed_media_thumbnail(Method::Scale, 800, 800, false)177
+ .ok()178
+ .mount()179
+ .await;180
+181
+ let bytes = fetch_ready(&mut harness, MediaSize::Thumbnail).await;182
+ assert_eq!(&*bytes, b"binaryjpegthumbnaildata");183
+ assert!(184
+ media_paths(&harness.server)185
+ .await186
+ .iter()187
+ .any(|path| path.contains("/thumbnail/localhost/image"))188
+ );189
+190
+ stop(harness).await;191
+}192
+193
+#[tokio::test]194
+async fn unavailable_thumbnail_reports_returned_command_id() {195
+ let mut harness = start(plain_audio()).await;196
+ let command_id = harness197
+ .handle198
+ .send(Command::Media(MediaCommand::Fetch {199
+ id: harness.media_id.clone(),200
+ size: MediaSize::Thumbnail,201
+ }))202
+ .await203
+ .unwrap();204
+205
+ let event = receive_until(&mut harness.events, |event| {206
+ matches!(207
+ event,208
+ Event::CommandFailed(failure)209
+ if failure.id == command_id && failure.kind == CommandErrorKind::MediaUnavailable210
+ )211
+ })212
+ .await;213
+ assert!(matches!(214
+ event,215
+ Event::CommandFailed(failure)216
+ if failure.id == command_id && failure.kind == CommandErrorKind::MediaUnavailable217
+ ));218
+219
+ stop(harness).await;220
+}221
+222
+#[tokio::test]223
+async fn media_upload_is_queued_and_sent() {224
+ let mut harness = start(plain_image()).await;225
+ harness226
+ .server227
+ .mock_authenticated_media_config()228
+ .ok_default()229
+ .mount()230
+ .await;231
+ harness232
+ .server233
+ .mock_upload()234
+ .expect_mime_type("image/jpeg")235
+ .ok(mxc_uri!("mxc://localhost/uploaded"))236
+ .expect(1)237
+ .mount()238
+ .await;239
+ harness240
+ .server241
+ .mock_room_send()242
+ .ok(event_id!("$uploaded:localhost"))243
+ .expect(1)244
+ .mount()245
+ .await;246
+247
+ let id = harness248
+ .handle249
+ .upload_media(250
+ room_id!("!media:localhost").to_owned().into(),251
+ "photo.jpg".to_owned(),252
+ "image/jpeg".to_owned(),253
+ b"photo data".to_vec(),254
+ Some("holiday".to_owned()),255
+ )256
+ .await257
+ .unwrap();258
+ receive_until(259
+ &mut harness.events,260
+ |event| matches!(event, Event::CommandSucceeded { id: completed, .. } if *completed == id),261
+ )262
+ .await;263
+264
+ tokio::time::timeout(Duration::from_secs(5), async {265
+ loop {266
+ let requests = harness.server.received_requests().await.unwrap();267
+ if requests.iter().any(|request| {268
+ request.url.path().contains("/send/m.room.message/")269
+ && String::from_utf8_lossy(&request.body).contains("mxc://localhost/uploaded")270
+ }) {271
+ break;272
+ }273
+ tokio::task::yield_now().await;274
+ }275
+ })276
+ .await277
+ .expect("queued media was not sent");278
+279
+ stop(harness).await;280
+}281
+282
+#[tokio::test]283
+async fn queued_offline_attachment_exposes_fetchable_local_media() {284
+ let mut harness = start(plain_image()).await;285
+ harness.client.send_queue().set_enabled(false).await;286
+ let original = b"offline image bytes".to_vec();287
+ let mut upload = AttachmentUpload::new(288
+ room_id!("!media:localhost").to_owned(),289
+ "pending.png",290
+ "image/png",291
+ original.clone(),292
+ );293
+ upload.details = Some(UploadDetails::Image {294
+ width: Some(32),295
+ height: Some(16),296
+ blurhash: None,297
+ });298
+ harness.handle.upload_attachment(upload).await.unwrap();299
+ let event = receive_until(&mut harness.events, |event| {300
+ event_messages(event).any(|message| {301
+ message.own302
+ && message.transaction_id.is_some()303
+ && message304
+ .media305
+ .as_ref()306
+ .is_some_and(|media| media.filename == "pending.png")307
+ })308
+ })309
+ .await;310
+ let media = event_messages(&event)311
+ .find(|message| message.own && message.media.is_some())312
+ .and_then(|message| message.media.as_ref())313
+ .unwrap();314
+ assert_eq!((media.width, media.height), (Some(32), Some(16)));315
+ assert_eq!(media.size_bytes, Some(original.len() as u64));316
+ let media_id = media.id.clone();317
+318
+ let fetch_id = harness319
+ .handle320
+ .send(Command::Media(MediaCommand::Fetch {321
+ id: media_id.clone(),322
+ size: MediaSize::Full,323
+ }))324
+ .await325
+ .unwrap();326
+ let ready = receive_until(327
+ &mut harness.events,328
+ |event| matches!(event, Event::MediaReady { media_id: id, .. } if id == &media_id),329
+ )330
+ .await;331
+ assert!(matches!(ready, Event::MediaReady { bytes, .. } if *bytes == original));332
+ receive_until(333
+ &mut harness.events,334
+ |event| matches!(event, Event::CommandSucceeded { id, .. } if *id == fetch_id),335
+ )336
+ .await;337
+ stop(harness).await;338
+}339
+340
+#[tokio::test]341
+async fn image_upload_uses_caller_supplied_metadata_and_thumbnail() {342
+ let mut harness = start(plain_image()).await;343
+ harness344
+ .server345
+ .mock_authenticated_media_config()346
+ .ok_default()347
+ .mount()348
+ .await;349
+ harness350
+ .server351
+ .mock_upload()352
+ .expect_mime_type("image/png")353
+ .respond_with(|request: &wiremock::Request| {354
+ let uri = if request.body == b"thumbnail bytes" {355
+ "mxc://localhost/image-thumb"356
+ } else {357
+ "mxc://localhost/image-upload"358
+ };359
+ ResponseTemplate::new(200).set_body_json(json!({ "content_uri": uri }))360
+ })361
+ .expect(2)362
+ .mount()363
+ .await;364
+ harness365
+ .server366
+ .mock_room_send()367
+ .ok(event_id!("$image-upload:localhost"))368
+ .expect(1)369
+ .mount()370
+ .await;371
+372
+ let original = b"image bytes".to_vec();373
+ let thumbnail_bytes = b"thumbnail bytes".to_vec();374
+ let mut upload = AttachmentUpload::new(375
+ room_id!("!media:localhost").to_owned(),376
+ "large.png",377
+ "image/png",378
+ original.clone(),379
+ );380
+ upload.details = Some(UploadDetails::Image {381
+ width: Some(900),382
+ height: Some(450),383
+ blurhash: None,384
+ });385
+ upload.thumbnail = Some(UploadThumbnail {386
+ bytes: thumbnail_bytes.clone(),387
+ mimetype: "image/png".to_owned(),388
+ width: 800,389
+ height: 400,390
+ });391
+ let id = harness.handle.upload_attachment(upload).await.unwrap();392
+ receive_until(393
+ &mut harness.events,394
+ |event| matches!(event, Event::CommandSucceeded { id: completed, .. } if *completed == id),395
+ )396
+ .await;397
+398
+ let content = uploaded_content(&harness.server).await;399
+ assert_eq!(content["msgtype"], "m.image");400
+ assert_eq!(content["info"]["w"], 900);401
+ assert_eq!(content["info"]["h"], 450);402
+ assert_eq!(content["info"]["size"], original.len());403
+ assert_eq!(content["info"]["thumbnail_info"]["w"], 800);404
+ assert_eq!(content["info"]["thumbnail_info"]["h"], 400);405
+ assert_eq!(content["info"]["thumbnail_info"]["mimetype"], "image/png");406
+ assert_eq!(407
+ content["info"]["thumbnail_info"]["size"],408
+ thumbnail_bytes.len()409
+ );410
+ assert_eq!(411
+ content["info"]["thumbnail_url"],412
+ "mxc://localhost/image-thumb"413
+ );414
+ assert_eq!(content["url"], "mxc://localhost/image-upload");415
+416
+ let requests = harness.server.received_requests().await.unwrap();417
+ let uploads = requests418
+ .iter()419
+ .filter(|request| request.url.path().ends_with("/_matrix/media/v3/upload"))420
+ .collect::<Vec<_>>();421
+ assert_eq!(uploads.len(), 2);422
+ assert!(uploads.iter().any(|request| request.body == original));423
+ assert!(424
+ uploads425
+ .iter()426
+ .any(|request| request.body == thumbnail_bytes)427
+ );428
+429
+ stop(harness).await;430
+}431
+432
+#[tokio::test]433
+async fn video_upload_preserves_metadata_and_supplied_thumbnail() {434
+ let mut harness = start(plain_image()).await;435
+ harness436
+ .server437
+ .mock_authenticated_media_config()438
+ .ok_default()439
+ .mount()440
+ .await;441
+ harness442
+ .server443
+ .mock_upload()444
+ .expect_mime_type("image/png")445
+ .ok(mxc_uri!("mxc://localhost/video-thumb"))446
+ .expect(1)447
+ .mount()448
+ .await;449
+ harness450
+ .server451
+ .mock_upload()452
+ .expect_mime_type("video/mp4")453
+ .ok(mxc_uri!("mxc://localhost/video-upload"))454
+ .expect(1)455
+ .mount()456
+ .await;457
+ harness458
+ .server459
+ .mock_room_send()460
+ .ok(event_id!("$video-upload:localhost"))461
+ .expect(1)462
+ .mount()463
+ .await;464
+465
+ let mut upload = AttachmentUpload::new(466
+ room_id!("!media:localhost").to_owned(),467
+ "clip.mp4",468
+ "video/mp4",469
+ b"video bytes".to_vec(),470
+ );471
+ upload.details = Some(UploadDetails::Video {472
+ width: Some(1920),473
+ height: Some(1080),474
+ duration_ms: Some(3500),475
+ blurhash: Some("blurhash".to_owned()),476
+ });477
+ upload.thumbnail = Some(UploadThumbnail {478
+ bytes: b"poster bytes".to_vec(),479
+ mimetype: "image/png".to_owned(),480
+ width: 320,481
+ height: 180,482
+ });483
+ let id = harness.handle.upload_attachment(upload).await.unwrap();484
+ receive_until(485
+ &mut harness.events,486
+ |event| matches!(event, Event::CommandSucceeded { id: completed, .. } if *completed == id),487
+ )488
+ .await;489
+490
+ let content = uploaded_content(&harness.server).await;491
+ assert_eq!(content["msgtype"], "m.video");492
+ assert_eq!(content["info"]["w"], 1920);493
+ assert_eq!(content["info"]["h"], 1080);494
+ assert_eq!(content["info"]["duration"], 3500);495
+ assert_eq!(content["info"]["size"], 11);496
+ assert_eq!(content["info"]["thumbnail_info"]["w"], 320);497
+ assert_eq!(content["info"]["thumbnail_info"]["h"], 180);498
+ assert_eq!(content["info"]["thumbnail_info"]["size"], 12);499
+ assert_eq!(500
+ content["info"]["thumbnail_url"],501
+ "mxc://localhost/video-thumb"502
+ );503
+ assert_eq!(content["url"], "mxc://localhost/video-upload");504
+505
+ let requests = harness.server.received_requests().await.unwrap();506
+ assert!(requests.iter().any(|request| {507
+ request.url.path().ends_with("/_matrix/media/v3/upload") && request.body == b"poster bytes"508
+ }));509
+510
+ stop(harness).await;511
+}512
+513
+#[tokio::test]514
+async fn voice_upload_emits_voice_details_and_correlated_progress() {515
+ let mut harness = start(plain_image()).await;516
+ harness517
+ .server518
+ .mock_authenticated_media_config()519
+ .ok_default()520
+ .mount()521
+ .await;522
+ harness523
+ .server524
+ .mock_upload()525
+ .expect_mime_type("audio/ogg")526
+ .respond_with(527
+ ResponseTemplate::new(200)528
+ .set_body_json(json!({ "content_uri": "mxc://localhost/voice-upload" }))529
+ .set_delay(Duration::from_millis(500)),530
+ )531
+ .expect(1)532
+ .mount()533
+ .await;534
+ harness535
+ .server536
+ .mock_room_send()537
+ .ok(event_id!("$voice-upload:localhost"))538
+ .expect(1)539
+ .mount()540
+ .await;541
+542
+ let data = b"voice bytes".to_vec();543
+ let mut upload = AttachmentUpload::new(544
+ room_id!("!media:localhost").to_owned(),545
+ "voice.ogg",546
+ "audio/ogg",547
+ data.clone(),548
+ );549
+ upload.details = Some(UploadDetails::Voice {550
+ duration_ms: 4200,551
+ waveform: vec![0, 512, 1024],552
+ });553
+ let id = harness.handle.upload_attachment(upload).await.unwrap();554
+ receive_until(555
+ &mut harness.events,556
+ |event| matches!(event, Event::CommandSucceeded { id: completed, .. } if *completed == id),557
+ )558
+ .await;559
+560
+ let pending = harness561
+ .handle562
+ .pending_send_ids(room_id!("!media:localhost"))563
+ .await564
+ .unwrap();565
+ assert_eq!(pending.len(), 1);566
+ let transaction_id = pending[0].clone();567
+ let progress = receive_until(&mut harness.events, |event| {568
+ matches!(event, Event::MediaUploadProgress { transaction_id: seen, complete: true, .. } if seen == &transaction_id)569
+ })570
+ .await;571
+ assert!(matches!(progress, Event::MediaUploadProgress {572
+ room_id,573
+ transaction_id: seen,574
+ uploaded_bytes,575
+ total_bytes,576
+ complete: true,577
+ } if room_id == room_id!("!media:localhost") && seen == transaction_id && uploaded_bytes == data.len() as u64 && total_bytes == data.len() as u64));578
+579
+ let content = uploaded_content(&harness.server).await;580
+ assert_eq!(content["msgtype"], "m.audio");581
+ assert_eq!(content["info"]["duration"], 4200);582
+ assert_eq!(content["info"]["size"], data.len());583
+ assert_eq!(content["org.matrix.msc1767.audio"]["duration"], 4200);584
+ assert_eq!(585
+ content["org.matrix.msc1767.audio"]["waveform"],586
+ json!([0, 512, 1024])587
+ );588
+ assert_eq!(content["org.matrix.msc3245.voice"], json!({}));589
+590
+ stop(harness).await;591
+}592
+593
+#[tokio::test]594
+async fn mismatched_upload_metadata_and_invalid_waveform_fail_without_uploading() {595
+ let mut harness = start(plain_image()).await;596
+ let mut mismatched = AttachmentUpload::new(597
+ room_id!("!media:localhost").to_owned(),598
+ "clip.mp4",599
+ "video/mp4",600
+ b"video bytes".to_vec(),601
+ );602
+ mismatched.details = Some(UploadDetails::Image {603
+ width: Some(32),604
+ height: Some(32),605
+ blurhash: None,606
+ });607
+ let id = harness.handle.upload_attachment(mismatched).await.unwrap();608
+ let failure = receive_until(609
+ &mut harness.events,610
+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),611
+ )612
+ .await;613
+ assert!(614
+ matches!(failure, Event::CommandFailed(failure) if failure.kind == CommandErrorKind::Other)615
+ );616
+617
+ let mut voice = AttachmentUpload::new(618
+ room_id!("!media:localhost").to_owned(),619
+ "voice.ogg",620
+ "audio/ogg",621
+ b"voice bytes".to_vec(),622
+ );623
+ voice.details = Some(UploadDetails::Voice {624
+ duration_ms: 1000,625
+ waveform: vec![1025],626
+ });627
+ let id = harness.handle.upload_attachment(voice).await.unwrap();628
+ let failure = receive_until(629
+ &mut harness.events,630
+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),631
+ )632
+ .await;633
+ assert!(634
+ matches!(failure, Event::CommandFailed(failure) if failure.kind == CommandErrorKind::Other)635
+ );636
+637
+ assert!(638
+ !harness639
+ .server640
+ .received_requests()641
+ .await642
+ .unwrap()643
+ .iter()644
+ .any(|request| { request.url.path().ends_with("/_matrix/media/v3/upload") })645
+ );646
+ stop(harness).await;647
+}648
+649
+#[tokio::test]650
+async fn upload_over_media_limit_fails_without_uploading() {651
+ let mut harness = start_with_config(652
+ plain_image(),653
+ EngineConfig::default()654
+ .with_initial_history_limit(0)655
+ .with_max_media_bytes(4),656
+ )657
+ .await;658
+ let upload = AttachmentUpload::new(659
+ room_id!("!media:localhost").to_owned(),660
+ "large.bin",661
+ "application/octet-stream",662
+ b"too large".to_vec(),663
+ );664
+ let id = harness.handle.upload_attachment(upload).await.unwrap();665
+ let failure = receive_until(666
+ &mut harness.events,667
+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),668
+ )669
+ .await;670
+ assert!(matches!(671
+ failure,672
+ Event::CommandFailed(failure) if failure.kind == CommandErrorKind::MediaTooLarge673
+ ));674
+ assert!(675
+ !harness676
+ .server677
+ .received_requests()678
+ .await679
+ .unwrap()680
+ .iter()681
+ .any(|request| { request.url.path().ends_with("/_matrix/media/v3/upload") })682
+ );683
+ stop(harness).await;684
+}685
+686
+#[tokio::test]687
+async fn audio_thumbnail_fails_without_uploading() {688
+ let mut harness = start(plain_image()).await;689
+ let mut upload = AttachmentUpload::new(690
+ room_id!("!media:localhost").to_owned(),691
+ "voice.ogg",692
+ "audio/ogg",693
+ b"voice bytes".to_vec(),694
+ );695
+ upload.thumbnail = Some(UploadThumbnail {696
+ bytes: b"thumbnail".to_vec(),697
+ mimetype: "image/png".to_owned(),698
+ width: 80,699
+ height: 80,700
+ });701
+ let id = harness.handle.upload_attachment(upload).await.unwrap();702
+ let failure = receive_until(703
+ &mut harness.events,704
+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),705
+ )706
+ .await;707
+ assert!(matches!(708
+ failure,709
+ Event::CommandFailed(failure) if failure.kind == CommandErrorKind::MediaUnavailable710
+ ));711
+ assert!(712
+ !harness713
+ .server714
+ .received_requests()715
+ .await716
+ .unwrap()717
+ .iter()718
+ .any(|request| { request.url.path().ends_with("/_matrix/media/v3/upload") })719
+ );720
+ stop(harness).await;721
+}722
+723
+#[tokio::test]724
+async fn invalid_upload_mime_reports_a_command_failure() {725
+ let mut harness = start(plain_image()).await;726
+ let id = harness727
+ .handle728
+ .upload_media(729
+ room_id!("!media:localhost").to_owned().into(),730
+ "photo.jpg".to_owned(),731
+ "not a MIME type".to_owned(),732
+ b"photo data".to_vec(),733
+ None,734
+ )735
+ .await736
+ .unwrap();737
+ let event = receive_until(738
+ &mut harness.events,739
+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),740
+ )741
+ .await;742
+ assert!(743
+ matches!(event, Event::CommandFailed(failure) if failure.kind == CommandErrorKind::Other)744
+ );745
+746
+ stop(harness).await;747
+}748
+749
+async fn start(content: RoomMessageEventContent) -> MediaHarness {750
+ start_with_config(751
+ content,752
+ EngineConfig::default().with_initial_history_limit(0),753
+ )754
+ .await755
+}756
+757
+async fn start_with_config(content: RoomMessageEventContent, config: EngineConfig) -> MediaHarness {758
+ let server = MatrixMockServer::new().await;759
+ server760
+ .mock_sliding_sync()761
+ .ok(v5::Response::new("1".to_owned()))762
+ .mount()763
+ .await;764
+ let client = server.client_builder().build().await;765
+ let room_id = room_id!("!media:localhost");766
+ server.mock_room_state_encryption().plain().mount().await;767
+ server.sync_joined_room(&client, room_id).await;768
+ let sync_client = client.clone();769
+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();770
+ let running = tokio::spawn(engine.run());771
+772
+ handle773
+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))774
+ .await775
+ .unwrap();776
+ receive_until(777
+ &mut events,778
+ |event| matches!(event, Event::TimelineReset { timeline_id: reset, .. } if reset.room_id() == room_id),779
+ )780
+ .await;781
+782
+ server783
+ .sync_room(784
+ &sync_client,785
+ JoinedRoomBuilder::new(room_id)786
+ .add_timeline_event(EventFactory::new().event(content).sender(&ALICE)),787
+ )788
+ .await;789
+ let event = receive_until(&mut events, |event| {790
+ event_messages(event).any(|message| message.media.is_some())791
+ })792
+ .await;793
+ let media_id = event_messages(&event)794
+ .find_map(|message| message.media.as_ref().map(|media| media.id.clone()))795
+ .unwrap();796
+797
+ MediaHarness {798
+ server,799
+ client: sync_client,800
+ handle,801
+ events,802
+ running,803
+ media_id,804
+ }805
+}806
+807
+async fn mount_downloads(server: &MatrixMockServer) {808
+ server.mock_media_download().ok_image().mount().await;809
+ server.mock_authed_media_download().ok_image().mount().await;810
+}811
+812
+async fn fetch_ready(harness: &mut MediaHarness, size: MediaSize) -> Arc<[u8]> {813
+ harness814
+ .handle815
+ .send(Command::Media(MediaCommand::Fetch {816
+ id: harness.media_id.clone(),817
+ size,818
+ }))819
+ .await820
+ .unwrap();821
+ match receive_until(&mut harness.events, |event| {822
+ matches!(823
+ event,824
+ Event::MediaReady { media_id, size: received, .. }825
+ if media_id == &harness.media_id && received == &size826
+ )827
+ })828
+ .await829
+ {830
+ Event::MediaReady { bytes, .. } => bytes,831
+ _ => unreachable!(),832
+ }833
+}834
+835
+async fn stop(mut harness: MediaHarness) {836
+ harness837
+ .handle838
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))839
+ .await840
+ .unwrap();841
+ receive_until(&mut harness.events, |event| matches!(event, Event::Stopped)).await;842
+ harness.running.await.unwrap().unwrap();843
+}844
+845
+async fn media_paths(server: &MatrixMockServer) -> Vec<String> {846
+ server847
+ .received_requests()848
+ .await849
+ .unwrap()850
+ .into_iter()851
+ .map(|request| request.url.path().to_owned())852
+ .filter(|path| path.contains("/media/"))853
+ .collect()854
+}855
+856
+async fn uploaded_content(server: &MatrixMockServer) -> Value {857
+ tokio::time::timeout(Duration::from_secs(5), async {858
+ loop {859
+ if let Some(request) = server860
+ .received_requests()861
+ .await862
+ .unwrap()863
+ .iter()864
+ .find(|request| request.url.path().contains("/send/m.room.message/"))865
+ {866
+ return serde_json::from_slice(&request.body).unwrap();867
+ }868
+ tokio::task::yield_now().await;869
+ }870
+ })871
+ .await872
+ .expect("queued media was not sent")873
+}874
+875
+fn file_with_thumbnail() -> RoomMessageEventContent {876
+ let mut info = FileInfo::new();877
+ info.thumbnail_source = Some(MediaSource::Plain(878
+ mxc_uri!("mxc://localhost/explicit-thumbnail").to_owned(),879
+ ));880
+ RoomMessageEventContent::new(MessageType::File(881
+ FileMessageEventContent::plain(882
+ "file.bin".to_owned(),883
+ mxc_uri!("mxc://localhost/full").to_owned(),884
+ )885
+ .info(Box::new(info)),886
+ ))887
+}888
+889
+fn plain_image() -> RoomMessageEventContent {890
+ RoomMessageEventContent::new(MessageType::Image(ImageMessageEventContent::plain(891
+ "image.jpg".to_owned(),892
+ mxc_uri!("mxc://localhost/image").to_owned(),893
+ )))894
+}895
+896
+fn plain_audio() -> RoomMessageEventContent {897
+ RoomMessageEventContent::new(MessageType::Audio(AudioMessageEventContent::plain(898
+ "audio.ogg".to_owned(),899
+ mxc_uri!("mxc://localhost/audio").to_owned(),900
+ )))901
+}902
+903
+fn event_messages(event: &Event) -> std::vec::IntoIter<&macltok::TimelineMessage> {904
+ let items = match event {905
+ Event::TimelineReset { items, .. } => items.iter().collect::<Vec<_>>(),906
+ Event::TimelineChanged { diffs, .. } => {907
+ diffs.iter().flat_map(diff_items).collect::<Vec<_>>()908
+ }909
+ _ => Vec::new(),910
+ };911
+ items912
+ .into_iter()913
+ .filter_map(|item| match item {914
+ TimelineItem::Message(message) => Some(message.as_ref()),915
+ _ => None,916
+ })917
+ .collect::<Vec<_>>()918
+ .into_iter()919
+}920
+921
+fn diff_items(diff: &TimelineDiff) -> Vec<&TimelineItem> {922
+ match diff {923
+ TimelineDiff::Append { values } | TimelineDiff::Reset { values } => values.iter().collect(),924
+ TimelineDiff::PushFront { value }925
+ | TimelineDiff::PushBack { value }926
+ | TimelineDiff::Insert { value, .. }927
+ | TimelineDiff::Set { value, .. } => vec![value],928
+ _ => Vec::new(),929
+ }930
+}931
+932
+async fn receive_until(events: &mut EventStream, predicate: impl Fn(&Event) -> bool) -> Event {933
+ tokio::time::timeout(Duration::from_secs(5), async {934
+ while let Some(event) = events.recv().await {935
+ if predicate(&event) {936
+ return event;937
+ }938
+ }939
+ panic!("event stream closed before the expected event");940
+ })941
+ .await942
+ .expect("expected event did not arrive")943
+}@@ -0,0 +1,200 @@
1
+use macltok::{ClientQueryError, Engine, EngineConfig, MemberFilter, MemberMembership};2
+use matrix_sdk::{3
+ ruma::{4
+ api::client::sync::sync_events::v5, events::room::member::MembershipState, room_id, user_id,5
+ },6
+ test_utils::mocks::MatrixMockServer,7
+};8
+use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};9
+10
+#[tokio::test]11
+async fn members_filters_and_normalises_synced_room_members() {12
+ let server = MatrixMockServer::new().await;13
+ server14
+ .mock_sliding_sync()15
+ .ok(v5::Response::new("1".to_owned()))16
+ .mount()17
+ .await;18
+ let client = server.client_builder().build().await;19
+ let room_id = room_id!("!members:localhost");20
+ server.mock_room_state_encryption().plain().mount().await;21
+22
+ let factory = EventFactory::new().room(room_id);23
+ let own = factory24
+ .member(user_id!("@example:localhost"))25
+ .display_name("Example")26
+ .into_raw_sync_state();27
+ let joined = factory28
+ .member(user_id!("@bob:localhost"))29
+ .display_name("Bobby")30
+ .into_raw_sync_state();31
+ let invited = factory32
+ .member(user_id!("@carol:localhost"))33
+ .membership(MembershipState::Invite)34
+ .sender(&ALICE)35
+ .state_key(user_id!("@carol:localhost"))36
+ .display_name("Carol")37
+ .into_raw_sync_state();38
+39
+ let own_member = factory40
+ .member(user_id!("@example:localhost"))41
+ .display_name("Example")42
+ .into_raw();43
+ let joined_member = factory44
+ .member(user_id!("@bob:localhost"))45
+ .display_name("Bobby")46
+ .into_raw();47
+ let invited_member = factory48
+ .member(user_id!("@carol:localhost"))49
+ .membership(MembershipState::Invite)50
+ .sender(&ALICE)51
+ .state_key(user_id!("@carol:localhost"))52
+ .display_name("Carol")53
+ .into_raw();54
+55
+ server56
+ .sync_room(57
+ &client,58
+ JoinedRoomBuilder::new(room_id)59
+ .add_state_event(own.clone())60
+ .add_state_event(joined.clone())61
+ .add_state_event(invited.clone()),62
+ )63
+ .await;64
+ server65
+ .mock_get_members()66
+ .ok(vec![own_member, joined_member, invited_member])67
+ .mock_once()68
+ .mount()69
+ .await;70
+ let (engine, handle, _events) = Engine::from_client(client, EngineConfig::default())71
+ .await72
+ .unwrap();73
+ let joined_members = handle.members(room_id, MemberFilter::Joined).await.unwrap();74
+ assert_eq!(joined_members.len(), 2);75
+ let own_member = joined_members76
+ .iter()77
+ .find(|member| member.own)78
+ .expect("own member missing");79
+ assert_eq!(own_member.user_id, user_id!("@example:localhost"));80
+ assert_eq!(own_member.name, "Example");81
+ assert_eq!(own_member.display_name.as_deref(), Some("Example"));82
+ assert_eq!(own_member.membership, MemberMembership::Joined);83
+ assert!(own_member.own);84
+85
+ let bob = joined_members86
+ .iter()87
+ .find(|member| member.user_id == user_id!("@bob:localhost"))88
+ .expect("joined member missing");89
+ assert_eq!(bob.name, "Bobby");90
+ assert_eq!(bob.display_name.as_deref(), Some("Bobby"));91
+ assert_eq!(bob.membership, MemberMembership::Joined);92
+ assert!(!bob.own);93
+94
+ let invited_members = handle95
+ .members(room_id, MemberFilter::Invited)96
+ .await97
+ .unwrap();98
+ assert_eq!(invited_members.len(), 1);99
+ let carol = &invited_members[0];100
+ assert_eq!(carol.user_id, user_id!("@carol:localhost"));101
+ assert_eq!(carol.name, "Carol");102
+ assert_eq!(carol.display_name.as_deref(), Some("Carol"));103
+ assert_eq!(carol.membership, MemberMembership::Invited);104
+ assert!(!carol.own);105
+106
+ drop(engine);107
+}108
+109
+#[tokio::test]110
+async fn members_all_includes_custom_membership_states() {111
+ let server = MatrixMockServer::new().await;112
+ server113
+ .mock_sliding_sync()114
+ .ok(v5::Response::new("1".to_owned()))115
+ .mount()116
+ .await;117
+ let client = server.client_builder().build().await;118
+ let room_id = room_id!("!members-custom:localhost");119
+ server.mock_room_state_encryption().plain().mount().await;120
+121
+ let factory = EventFactory::new().room(room_id);122
+ let joined = factory123
+ .member(user_id!("@bob:localhost"))124
+ .display_name("Bobby")125
+ .into_raw_sync_state();126
+ let custom = factory127
+ .member(user_id!("@dave:localhost"))128
+ .membership(MembershipState::from("flying"))129
+ .sender(user_id!("@bob:localhost"))130
+ .state_key(user_id!("@dave:localhost"))131
+ .display_name("Dave")132
+ .into_raw_sync_state();133
+134
+ let joined_member = factory135
+ .member(user_id!("@bob:localhost"))136
+ .display_name("Bobby")137
+ .into_raw();138
+ let custom_member = factory139
+ .member(user_id!("@dave:localhost"))140
+ .membership(MembershipState::from("flying"))141
+ .sender(user_id!("@bob:localhost"))142
+ .state_key(user_id!("@dave:localhost"))143
+ .display_name("Dave")144
+ .into_raw();145
+146
+ server147
+ .sync_room(148
+ &client,149
+ JoinedRoomBuilder::new(room_id)150
+ .add_state_event(joined)151
+ .add_state_event(custom),152
+ )153
+ .await;154
+ server155
+ .mock_get_members()156
+ .ok(vec![joined_member, custom_member])157
+ .mock_once()158
+ .mount()159
+ .await;160
+ let (engine, handle, _events) = Engine::from_client(client, EngineConfig::default())161
+ .await162
+ .unwrap();163
+164
+ // `All` maps to the SDK's unfiltered empty mask, so custom states are included.165
+ let all_members = handle.members(room_id, MemberFilter::All).await.unwrap();166
+ assert_eq!(all_members.len(), 2);167
+ let dave = all_members168
+ .iter()169
+ .find(|member| member.user_id == user_id!("@dave:localhost"))170
+ .expect("custom member missing");171
+ assert!(172
+ matches!(&dave.membership, MemberMembership::Custom(state) if state == "flying"),173
+ "unexpected membership: {:?}",174
+ dave.membership175
+ );176
+177
+ // Narrow filters still exclude the custom state.178
+ let joined_members = handle.members(room_id, MemberFilter::Joined).await.unwrap();179
+ assert_eq!(joined_members.len(), 1);180
+ assert_eq!(joined_members[0].user_id, user_id!("@bob:localhost"));181
+182
+ drop(engine);183
+}184
+185
+#[tokio::test]186
+async fn members_rejects_unknown_room() {187
+ let server = MatrixMockServer::new().await;188
+ let client = server.client_builder().build().await;189
+ let (engine, handle, _events) = Engine::from_client(client, EngineConfig::default())190
+ .await191
+ .unwrap();192
+ let room_id = room_id!("!missing:localhost");193
+194
+ assert!(matches!(195
+ handle.members(room_id, MemberFilter::All).await,196
+ Err(ClientQueryError::RoomUnknown(missing)) if missing == room_id197
+ ));198
+199
+ drop(engine);200
+}@@ -0,0 +1,207 @@
1
+use std::time::Duration;2
+3
+use macltok::{4
+ Command, CommandErrorKind, CommandKind, Engine, EngineConfig, Event, LifecycleCommand,5
+ MediaCommand, MediaSize, MessageContent, RoomCommand, TimelineCommand, TimelineDiff,6
+ TimelineItem,7
+};8
+use matrix_sdk::{9
+ ruma::{api::client::sync::sync_events::v5, mxc_uri, room_id},10
+ test_utils::mocks::MatrixMockServer,11
+};12
+use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};13
+use wiremock::ResponseTemplate;14
+15
+#[tokio::test]16
+async fn delayed_media_fetch_does_not_block_timeline_command() {17
+ let server = MatrixMockServer::new().await;18
+ server19
+ .mock_sliding_sync()20
+ .ok(v5::Response::new("1".to_owned()))21
+ .mount()22
+ .await;23
+ let client = server.client_builder().build().await;24
+ let sync_client = client.clone();25
+ let room_id = room_id!("!nonblocking:localhost");26
+ server.mock_room_state_encryption().plain().mount().await;27
+ server.sync_joined_room(&client, room_id).await;28
+ let config = EngineConfig::default().with_initial_history_limit(0);29
+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();30
+ let running = tokio::spawn(engine.run());31
+32
+ handle33
+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))34
+ .await35
+ .unwrap();36
+ receive_until(37
+ &mut events,38
+ |event| matches!(event, Event::TimelineReset { timeline_id: reset, .. } if reset.room_id() == room_id),39
+ )40
+ .await;41
+42
+ server43
+ .sync_room(44
+ &sync_client,45
+ JoinedRoomBuilder::new(room_id).add_timeline_event(46
+ EventFactory::new()47
+ .image(48
+ "image.jpg".into(),49
+ mxc_uri!("mxc://localhost/image").to_owned(),50
+ )51
+ .sender(&ALICE),52
+ ),53
+ )54
+ .await;55
+ let media_event = receive_until(&mut events, |event| {56
+ event_messages(event).any(|message| message.media.is_some())57
+ })58
+ .await;59
+ let media_id = event_messages(&media_event)60
+ .find_map(|message| message.media.as_ref().map(|media| media.id.clone()))61
+ .unwrap();62
+63
+ let delayed_image = || {64
+ ResponseTemplate::new(200)65
+ .set_body_raw(b"binaryjpegfullimagedata", "image/jpeg")66
+ .set_delay(Duration::from_millis(500))67
+ };68
+ server69
+ .mock_media_download()70
+ .respond_with(delayed_image())71
+ .mount()72
+ .await;73
+ server74
+ .mock_authed_media_download()75
+ .respond_with(delayed_image())76
+ .mount()77
+ .await;78
+79
+ let media_command_id = handle80
+ .send(Command::Media(MediaCommand::Fetch {81
+ id: media_id.clone(),82
+ size: MediaSize::Full,83
+ }))84
+ .await85
+ .unwrap();86
+ wait_for_media_request(&server).await;87
+ let timeline_command_id = handle88
+ .send(Command::Timeline(TimelineCommand::Send {89
+ timeline_id: room_id.to_owned().into(),90
+ content: MessageContent::text(String::new()),91
+ }))92
+ .await93
+ .unwrap();94
+95
+ tokio::time::timeout(Duration::from_secs(2), async {96
+ loop {97
+ match events.recv().await.expect("event stream closed") {98
+ Event::CommandFailed(failure) if failure.id == timeline_command_id => {99
+ assert_eq!(failure.command, CommandKind::Timeline);100
+ assert_eq!(failure.kind, CommandErrorKind::EmptyBody);101
+ break;102
+ }103
+ Event::CommandSucceeded { id, .. } if id == media_command_id => {104
+ panic!("media command completed before the timeline command")105
+ }106
+ Event::CommandFailed(failure) if failure.id == media_command_id => {107
+ panic!("media command failed before the timeline command {failure:?}")108
+ }109
+ Event::MediaReady {110
+ media_id: ready_id, ..111
+ } if ready_id == media_id => {112
+ panic!("media became ready before the timeline command completed")113
+ }114
+ _ => {}115
+ }116
+ }117
+ })118
+ .await119
+ .expect("timeline command was blocked by media fetch");120
+121
+ receive_until(&mut events, |event| {122
+ matches!(123
+ event,124
+ Event::CommandSucceeded {125
+ id,126
+ command: CommandKind::Media,127
+ } if id == &media_command_id128
+ )129
+ })130
+ .await;131
+132
+ handle133
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))134
+ .await135
+ .unwrap();136
+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;137
+ running.await.unwrap().unwrap();138
+}139
+140
+async fn wait_for_media_request(server: &MatrixMockServer) {141
+ tokio::time::timeout(Duration::from_secs(2), async {142
+ loop {143
+ if server144
+ .received_requests()145
+ .await146
+ .unwrap()147
+ .iter()148
+ .any(|request| request.url.path().ends_with("/download/localhost/image"))149
+ {150
+ break;151
+ }152
+ tokio::task::yield_now().await;153
+ }154
+ })155
+ .await156
+ .expect("media request did not reach the homeserver");157
+}158
+159
+fn event_messages(event: &Event) -> std::vec::IntoIter<&macltok::TimelineMessage> {160
+ let items = match event {161
+ Event::TimelineReset { items, .. } => items.iter().collect::<Vec<_>>(),162
+ Event::TimelineChanged { diffs, .. } => {163
+ diffs.iter().flat_map(diff_items).collect::<Vec<_>>()164
+ }165
+ _ => Vec::new(),166
+ };167
+ items168
+ .into_iter()169
+ .filter_map(|item| match item {170
+ TimelineItem::Message(message) => Some(message.as_ref()),171
+ _ => None,172
+ })173
+ .collect::<Vec<_>>()174
+ .into_iter()175
+}176
+177
+fn diff_items(diff: &TimelineDiff) -> Vec<&TimelineItem> {178
+ match diff {179
+ TimelineDiff::Append { values } | TimelineDiff::Reset { values } => values.iter().collect(),180
+ TimelineDiff::PushFront { value }181
+ | TimelineDiff::PushBack { value }182
+ | TimelineDiff::Insert { value, .. }183
+ | TimelineDiff::Set { value, .. } => vec![value],184
+ TimelineDiff::Clear185
+ | TimelineDiff::PopFront186
+ | TimelineDiff::PopBack187
+ | TimelineDiff::Remove { .. }188
+ | TimelineDiff::Truncate { .. } => Vec::new(),189
+ _ => Vec::new(),190
+ }191
+}192
+193
+async fn receive_until(194
+ events: &mut macltok::EventStream,195
+ predicate: impl Fn(&Event) -> bool,196
+) -> Event {197
+ tokio::time::timeout(Duration::from_secs(2), async {198
+ while let Some(event) = events.recv().await {199
+ if predicate(&event) {200
+ return event;201
+ }202
+ }203
+ panic!("event stream closed before the expected event")204
+ })205
+ .await206
+ .expect("expected event did not arrive")207
+}@@ -0,0 +1,251 @@
1
+use std::time::Duration;2
+3
+use macltok::{4
+ ClientHandle, Command, CommandErrorKind, CommandId, CommandKind, Engine, EngineConfig, Event,5
+ EventStream, LifecycleCommand, MessageContent, RoomCommand, TimelineCommand, TimelineItem,6
+};7
+use matrix_sdk::{8
+ Client,9
+ ruma::{OwnedTransactionId, RoomId, api::client::sync::sync_events::v5, event_id, room_id},10
+ test_utils::mocks::MatrixMockServer,11
+};12
+13
+#[tokio::test]14
+async fn cancel_send_uses_transaction_id_from_public_timeline() {15
+ let (server, client, handle, mut events, running) = setup().await;16
+ let room_id = room_id!("!queue-controls:localhost");17
+ client.send_queue().set_enabled(false).await;18
+19
+ let transaction_id = queue_message(&handle, &mut events, room_id, "discard me").await;20
+ assert!(21
+ handle22
+ .pending_send_ids(room_id)23
+ .await24
+ .unwrap()25
+ .contains(&transaction_id)26
+ );27
+ let id = handle28
+ .cancel_send(room_id.to_owned(), transaction_id.clone())29
+ .await30
+ .unwrap();31
+ expect_success(&mut events, id, CommandKind::Room).await;32
+33
+ let (echoes, _) = client34
+ .get_room(room_id)35
+ .unwrap()36
+ .send_queue()37
+ .subscribe()38
+ .await39
+ .unwrap();40
+ assert!(41
+ !echoes42
+ .iter()43
+ .any(|echo| echo.transaction_id == transaction_id)44
+ );45
+46
+ let id = handle47
+ .cancel_send(room_id.to_owned(), transaction_id)48
+ .await49
+ .unwrap();50
+ let failure = receive_until(51
+ &mut events,52
+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),53
+ )54
+ .await;55
+ assert!(56
+ matches!(failure, Event::CommandFailed(failure) if failure.kind == CommandErrorKind::Other)57
+ );58
+59
+ stop(handle, &mut events, running).await;60
+ drop(server);61
+}62
+63
+#[tokio::test]64
+async fn cancel_queued_attachment_by_pending_send_id() {65
+ let (server, client, handle, mut events, running) = setup().await;66
+ let room_id = room_id!("!queue-controls:localhost");67
+ client.send_queue().set_enabled(false).await;68
+69
+ let id = handle70
+ .upload_media(71
+ room_id.to_owned().into(),72
+ "note.txt".to_owned(),73
+ "text/plain".to_owned(),74
+ b"attachment".to_vec(),75
+ None,76
+ )77
+ .await78
+ .unwrap();79
+ expect_success(&mut events, id, CommandKind::Media).await;80
+81
+ let pending = handle.pending_send_ids(room_id).await.unwrap();82
+ assert_eq!(pending.len(), 1);83
+ let id = handle84
+ .cancel_send(room_id.to_owned(), pending[0].clone())85
+ .await86
+ .unwrap();87
+ expect_success(&mut events, id, CommandKind::Room).await;88
+ assert!(handle.pending_send_ids(room_id).await.unwrap().is_empty());89
+90
+ stop(handle, &mut events, running).await;91
+ drop(server);92
+}93
+94
+#[tokio::test]95
+async fn retry_send_wakes_paused_queue() {96
+ let (server, client, handle, mut events, running) = setup().await;97
+ let room_id = room_id!("!queue-controls:localhost");98
+ client.send_queue().set_enabled(false).await;99
+ server100
+ .mock_room_send()101
+ .body_matches_partial_json(serde_json::json!({ "body": "retry me" }))102
+ .ok(event_id!("$retried:localhost"))103
+ .expect(1)104
+ .mount()105
+ .await;106
+107
+ let transaction_id = queue_message(&handle, &mut events, room_id, "retry me").await;108
+ let id = handle109
+ .retry_send(room_id.to_owned(), transaction_id)110
+ .await111
+ .unwrap();112
+ expect_success(&mut events, id, CommandKind::Room).await;113
+ assert!(client.send_queue().is_enabled());114
+115
+ tokio::time::timeout(Duration::from_secs(5), async {116
+ loop {117
+ let (echoes, _) = client118
+ .get_room(room_id)119
+ .unwrap()120
+ .send_queue()121
+ .subscribe()122
+ .await123
+ .unwrap();124
+ if echoes.is_empty() {125
+ break;126
+ }127
+ tokio::task::yield_now().await;128
+ }129
+ })130
+ .await131
+ .expect("retried message was not sent");132
+133
+ stop(handle, &mut events, running).await;134
+ drop(server);135
+}136
+137
+async fn setup() -> (138
+ MatrixMockServer,139
+ Client,140
+ ClientHandle,141
+ EventStream,142
+ tokio::task::JoinHandle<Result<(), macltok::EngineError>>,143
+) {144
+ let server = MatrixMockServer::new().await;145
+ server146
+ .mock_sliding_sync()147
+ .ok(v5::Response::new("1".to_owned()))148
+ .mount()149
+ .await;150
+ let client = server.client_builder().build().await;151
+ let room_id = room_id!("!queue-controls:localhost");152
+ server.mock_room_state_encryption().plain().mount().await;153
+ server.sync_joined_room(&client, room_id).await;154
+ let (engine, handle, events) = Engine::from_client(155
+ client.clone(),156
+ EngineConfig::default().with_initial_history_limit(0),157
+ )158
+ .await159
+ .unwrap();160
+ let running = tokio::spawn(engine.run());161
+ let mut events = events;162
+ let id = handle163
+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))164
+ .await165
+ .unwrap();166
+ expect_success(&mut events, id, CommandKind::Room).await;167
+ (server, client, handle, events, running)168
+}169
+170
+async fn queue_message(171
+ handle: &ClientHandle,172
+ events: &mut EventStream,173
+ room_id: &RoomId,174
+ body: &str,175
+) -> OwnedTransactionId {176
+ let id = handle177
+ .send(Command::Timeline(TimelineCommand::Send {178
+ timeline_id: room_id.to_owned().into(),179
+ content: MessageContent::text(body),180
+ }))181
+ .await182
+ .unwrap();183
+ let mut succeeded = false;184
+ let mut items = Vec::new();185
+ tokio::time::timeout(Duration::from_secs(5), async {186
+ loop {187
+ match events.recv().await.expect("event stream closed") {188
+ Event::TimelineChanged { diffs, .. } => {189
+ for diff in diffs {190
+ diff.try_apply(&mut items).unwrap();191
+ }192
+ let transaction = items.iter().find_map(|item| match item {193
+ TimelineItem::Message(message) if message.body == body => {194
+ message.transaction_id.clone()195
+ }196
+ _ => None,197
+ });198
+ if let Some(transaction) = transaction {199
+ if !succeeded {200
+ expect_success(events, id, CommandKind::Timeline).await;201
+ }202
+ return transaction;203
+ }204
+ }205
+ Event::CommandSucceeded {206
+ id: completed,207
+ command: CommandKind::Timeline,208
+ } if completed == id => succeeded = true,209
+ Event::CommandFailed(failure) if failure.id == id => {210
+ panic!("send failed {failure:?}")211
+ }212
+ _ => {}213
+ }214
+ }215
+ })216
+ .await217
+ .expect("local echo did not appear")218
+}219
+220
+async fn expect_success(events: &mut EventStream, id: CommandId, command: CommandKind) {221
+ receive_until(events, |event| {222
+ matches!(event, Event::CommandSucceeded { id: completed, command: kind } if *completed == id && *kind == command)223
+ })224
+ .await;225
+}226
+227
+async fn receive_until(events: &mut EventStream, predicate: impl Fn(&Event) -> bool) -> Event {228
+ tokio::time::timeout(Duration::from_secs(5), async {229
+ loop {230
+ let event = events.recv().await.expect("event stream closed");231
+ if predicate(&event) {232
+ return event;233
+ }234
+ }235
+ })236
+ .await237
+ .expect("expected event did not arrive")238
+}239
+240
+async fn stop(241
+ handle: ClientHandle,242
+ events: &mut EventStream,243
+ running: tokio::task::JoinHandle<Result<(), macltok::EngineError>>,244
+) {245
+ let id = handle246
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))247
+ .await248
+ .unwrap();249
+ expect_success(events, id, CommandKind::Lifecycle).await;250
+ running.await.unwrap().unwrap();251
+}@@ -0,0 +1,147 @@
1
+use std::time::Duration;2
+3
+use macltok::{4
+ Command, ConnectionState, Engine, EngineConfig, EngineError, Event, LifecycleCommand,5
+ RoomMembership,6
+};7
+use matrix_sdk::{8
+ ruma::{api::client::sync::sync_events::v5, room_id},9
+ test_utils::mocks::MatrixMockServer,10
+};11
+use tokio::sync::watch;12
+13
+#[tokio::test]14
+async fn rooms_changed_invalidates_room_summaries() {15
+ let server = MatrixMockServer::new().await;16
+ server17
+ .mock_sliding_sync()18
+ .ok(v5::Response::new("1".to_owned()))19
+ .mount()20
+ .await;21
+ let client = server.client_builder().build().await;22
+ let room_id = room_id!("!state:localhost");23
+ server.mock_room_state_encryption().plain().mount().await;24
+ server.sync_joined_room(&client, room_id).await;25
+ let config = EngineConfig::default().with_initial_history_limit(0);26
+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();27
+ let running = tokio::spawn(engine.run());28
+29
+ receive_until(&mut events, |event| matches!(event, Event::RoomsChanged)).await;30
+ let rooms = handle.rooms().await;31
+ let summary = rooms32
+ .iter()33
+ .find(|summary| summary.room_id == room_id)34
+ .expect("synced room was absent from the room summaries");35
+ assert_eq!(summary.membership, RoomMembership::Joined);36
+ assert!(!summary.name.is_empty());37
+38
+ handle39
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))40
+ .await41
+ .unwrap();42
+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;43
+ running.await.unwrap().unwrap();44
+}45
+46
+#[tokio::test]47
+async fn connection_watch_tracks_the_engine_lifecycle() {48
+ let server = MatrixMockServer::new().await;49
+ server50
+ .mock_sliding_sync()51
+ .ok(v5::Response::new("1".to_owned()))52
+ .mount()53
+ .await;54
+ let client = server.client_builder().build().await;55
+ let config = EngineConfig::default().with_initial_history_limit(0);56
+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();57
+ let mut connection = handle.connection_state();58
+ let connection_peer = connection.clone();59
+ assert_eq!(*connection.borrow(), ConnectionState::Idle);60
+ assert_eq!(*connection_peer.borrow(), ConnectionState::Idle);61
+62
+ let running = tokio::spawn(engine.run());63
+ receive_until(&mut events, |event| {64
+ matches!(event, Event::ConnectionChanged(ConnectionState::Connecting))65
+ })66
+ .await;67
+ assert_ne!(*connection.borrow(), ConnectionState::Idle);68
+ receive_until(&mut events, |event| {69
+ matches!(event, Event::ConnectionChanged(ConnectionState::Online))70
+ })71
+ .await;72
+ wait_for_connection(&mut connection, ConnectionState::Online).await;73
+ assert_eq!(*connection_peer.borrow(), ConnectionState::Online);74
+75
+ handle76
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))77
+ .await78
+ .unwrap();79
+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;80
+ running.await.unwrap().unwrap();81
+ wait_for_connection(&mut connection, ConnectionState::Stopped).await;82
+ assert_eq!(*connection_peer.borrow(), ConnectionState::Stopped);83
+}84
+85
+#[tokio::test]86
+async fn closing_the_event_stream_stops_an_idle_engine() {87
+ let server = MatrixMockServer::new().await;88
+ server89
+ .mock_sliding_sync()90
+ .ok(v5::Response::new("1".to_owned()))91
+ .mount()92
+ .await;93
+ let client = server.client_builder().build().await;94
+ let (engine, handle, mut events) = Engine::from_client(95
+ client,96
+ EngineConfig::default().with_initial_history_limit(0),97
+ )98
+ .await99
+ .unwrap();100
+ let running = tokio::spawn(engine.run());101
+ receive_until(&mut events, |event| {102
+ matches!(event, Event::ConnectionChanged(ConnectionState::Online))103
+ })104
+ .await;105
+ drop(events);106
+ let result = tokio::time::timeout(Duration::from_secs(2), running)107
+ .await108
+ .expect("engine did not stop after its event receiver closed")109
+ .unwrap();110
+ assert!(matches!(result, Err(EngineError::EventReceiverClosed)));111
+ drop(handle);112
+}113
+114
+async fn wait_for_connection(115
+ connection: &mut watch::Receiver<ConnectionState>,116
+ expected: ConnectionState,117
+) {118
+ tokio::time::timeout(Duration::from_secs(2), async {119
+ loop {120
+ if *connection.borrow_and_update() == expected {121
+ return;122
+ }123
+ connection124
+ .changed()125
+ .await126
+ .expect("connection watch closed before the expected state");127
+ }128
+ })129
+ .await130
+ .expect("expected connection state did not arrive");131
+}132
+133
+async fn receive_until(134
+ events: &mut macltok::EventStream,135
+ predicate: impl Fn(&Event) -> bool,136
+) -> Event {137
+ tokio::time::timeout(Duration::from_secs(2), async {138
+ while let Some(event) = events.recv().await {139
+ if predicate(&event) {140
+ return event;141
+ }142
+ }143
+ panic!("event stream closed before the expected event");144
+ })145
+ .await146
+ .expect("expected event did not arrive")147
+}@@ -0,0 +1,401 @@
1
+use std::time::Duration;2
+3
+use macltok::{4
+ ClientHandle, ClientQueryError, Command, CommandErrorKind, CommandId, CommandKind,5
+ CreateRoomOptions, Engine, EngineConfig, EngineError, Event, EventStream, LifecycleCommand,6
+ RoomCommand, RoomPreset, RoomVisibility,7
+};8
+use matrix_sdk::{9
+ ruma::{10
+ api::client::sync::sync_events::v5,11
+ events::{GlobalAccountDataEventType, RoomAccountDataEventType},12
+ room_id,13
+ serde::Raw,14
+ server_name, user_id,15
+ },16
+ test_utils::mocks::MatrixMockServer,17
+};18
+use matrix_sdk_test::{InvitedRoomBuilder, JoinedRoomBuilder};19
+use serde_json::json;20
+use tokio::task::JoinHandle;21
+use wiremock::{22
+ Mock, ResponseTemplate,23
+ matchers::{body_partial_json, method, path, path_regex, query_param},24
+};25
+26
+#[tokio::test]27
+async fn joining_by_room_id_forwards_via_servers() {28
+ let room_id = room_id!("!join:localhost");29
+ let (server, handle, mut events, running) = start_engine().await;30
+ Mock::given(method("POST"))31
+ .and(path(format!("/_matrix/client/v3/join/{room_id}")))32
+ .and(query_param("via", "relay.example"))33
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "room_id": room_id })))34
+ .expect(1)35
+ .mount(server.server())36
+ .await;37
+38
+ let joined_room = handle39
+ .join_room(40
+ room_id.to_owned().into(),41
+ vec![server_name!("relay.example").to_owned()],42
+ )43
+ .await44
+ .unwrap();45
+ assert_eq!(joined_room, room_id);46
+ stop_engine(handle, &mut events, running).await;47
+}48
+49
+#[tokio::test]50
+async fn leave_and_forget_report_correlated_success() {51
+ let room_id = room_id!("!leave:localhost");52
+ let (server, client) = configured_client().await;53
+ server.sync_joined_room(&client, room_id).await;54
+ server.mock_room_leave().ok(room_id).expect(1).mount().await;55
+ server.mock_room_forget().ok().expect(1).mount().await;56
+ let (handle, mut events, running) = run_client(client).await;57
+58
+ let leave_id = handle.leave_room(room_id.to_owned()).await.unwrap();59
+ receive_room_success(&mut events, leave_id).await;60
+ let forget_id = handle61
+ .send(Command::Room(RoomCommand::Forget(room_id.to_owned())))62
+ .await63
+ .unwrap();64
+ receive_room_success(&mut events, forget_id).await;65
+ assert_ne!(leave_id, forget_id);66
+ stop_engine(handle, &mut events, running).await;67
+}68
+69
+#[tokio::test]70
+async fn accepting_and_rejecting_invites_use_the_expected_endpoints() {71
+ let accepted = room_id!("!accepted:localhost");72
+ let rejected = room_id!("!rejected:localhost");73
+ let (server, client) = configured_client().await;74
+ server75
+ .sync_room(&client, InvitedRoomBuilder::new(accepted))76
+ .await;77
+ server78
+ .sync_room(&client, InvitedRoomBuilder::new(rejected))79
+ .await;80
+ server.mock_room_join(accepted).ok().expect(1).mount().await;81
+ server82
+ .mock_room_leave()83
+ .ok(rejected)84
+ .expect(1)85
+ .mount()86
+ .await;87
+ let (handle, mut events, running) = run_client(client).await;88
+89
+ let accept_id = handle.accept_invite(accepted.to_owned()).await.unwrap();90
+ receive_room_success(&mut events, accept_id).await;91
+ let reject_id = handle.reject_invite(rejected.to_owned()).await.unwrap();92
+ receive_room_success(&mut events, reject_id).await;93
+ stop_engine(handle, &mut events, running).await;94
+}95
+96
+#[tokio::test]97
+async fn creating_a_room_forwards_options() {98
+ let (server, handle, mut events, running) = start_engine().await;99
+ Mock::given(method("POST"))100
+ .and(path("/_matrix/client/v3/createRoom"))101
+ .and(body_partial_json(json!({102
+ "name": "Planning",103
+ "topic": "Next release",104
+ "room_alias_name": "planning",105
+ "invite": ["@partner:localhost"],106
+ "visibility": "public",107
+ "preset": "public_chat"108
+ })))109
+ .respond_with(110
+ ResponseTemplate::new(200).set_body_json(json!({ "room_id": "!created:localhost" })),111
+ )112
+ .expect(1)113
+ .mount(server.server())114
+ .await;115
+116
+ let created_room = handle117
+ .create_room(CreateRoomOptions {118
+ name: Some("Planning".to_owned()),119
+ topic: Some("Next release".to_owned()),120
+ alias_localpart: Some("planning".to_owned()),121
+ invite: vec![user_id!("@partner:localhost").to_owned()],122
+ visibility: RoomVisibility::Public,123
+ preset: Some(RoomPreset::PublicChat),124
+ ..Default::default()125
+ })126
+ .await127
+ .unwrap();128
+ assert_eq!(created_room, room_id!("!created:localhost").to_owned());129
+ stop_engine(handle, &mut events, running).await;130
+}131
+132
+#[tokio::test]133
+async fn creating_a_direct_room_invites_the_other_user() {134
+ let (server, handle, mut events, running) = start_engine().await;135
+ Mock::given(method("POST"))136
+ .and(path("/_matrix/client/v3/createRoom"))137
+ .and(body_partial_json(json!({138
+ "invite": ["@partner:localhost"],139
+ "is_direct": true,140
+ "preset": "trusted_private_chat"141
+ })))142
+ .respond_with(143
+ ResponseTemplate::new(200).set_body_json(json!({ "room_id": "!direct:localhost" })),144
+ )145
+ .expect(1)146
+ .mount(server.server())147
+ .await;148
+ Mock::given(method("GET"))149
+ .and(path_regex(150
+ r"^/_matrix/client/v3/user/.*/account_data/m\.direct$",151
+ ))152
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))153
+ .expect(1)154
+ .mount(server.server())155
+ .await;156
+ Mock::given(method("PUT"))157
+ .and(path_regex(158
+ r"^/_matrix/client/v3/user/.*/account_data/m\.direct$",159
+ ))160
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))161
+ .expect(1)162
+ .mount(server.server())163
+ .await;164
+165
+ let direct_room = handle166
+ .create_direct(user_id!("@partner:localhost").to_owned())167
+ .await168
+ .unwrap();169
+ assert_eq!(direct_room, room_id!("!direct:localhost").to_owned());170
+ stop_engine(handle, &mut events, running).await;171
+}172
+173
+#[tokio::test]174
+async fn unknown_rooms_fail_membership_commands_with_their_ids() {175
+ let (server, handle, mut events, running) = start_engine().await;176
+ let unknown = room_id!("!unknown:localhost");177
+ for command in [178
+ RoomCommand::Leave(unknown.to_owned()),179
+ RoomCommand::Forget(unknown.to_owned()),180
+ RoomCommand::AcceptInvite(unknown.to_owned()),181
+ RoomCommand::RejectInvite(unknown.to_owned()),182
+ ] {183
+ let id = handle.send(Command::Room(command)).await.unwrap();184
+ receive_room_failure(&mut events, id, CommandErrorKind::RoomUnknown).await;185
+ }186
+ stop_engine(handle, &mut events, running).await;187
+ drop(server);188
+}189
+190
+#[tokio::test]191
+async fn account_data_reads_synced_values_and_writes_raw_content() {192
+ let room_id = room_id!("!account-data:localhost");193
+ let (server, client) = configured_client().await;194
+ server195
+ .sync_room(196
+ &client,197
+ JoinedRoomBuilder::new(room_id).add_account_data(198
+ Raw::new(&json!({ "type": "m.fully_read", "content": { "event_id": "$seen:localhost" } }))199
+ .unwrap()200
+ .cast_unchecked(),201
+ ),202
+ )203
+ .await;204
+ server205
+ .mock_sync()206
+ .ok_and_run(&client, |builder| {207
+ builder.add_custom_global_account_data(json!({208
+ "type": "com.example.preferences",209
+ "content": { "theme": "dark" }210
+ }));211
+ })212
+ .await;213
+ let (engine, handle, mut events) = Engine::from_client(client, EngineConfig::default())214
+ .await215
+ .unwrap();216
+ let running = tokio::spawn(engine.run());217
+ let global_type = GlobalAccountDataEventType::from("com.example.preferences".to_owned());218
+ let room_type = RoomAccountDataEventType::FullyRead;219
+220
+ let global = handle221
+ .global_account_data(global_type.clone())222
+ .await223
+ .unwrap()224
+ .unwrap();225
+ assert_eq!(226
+ global.deserialize_as::<serde_json::Value>().unwrap()["theme"],227
+ "dark"228
+ );229
+ let room = handle230
+ .room_account_data(room_id, room_type.clone())231
+ .await232
+ .unwrap()233
+ .unwrap();234
+ assert_eq!(235
+ room.deserialize_as::<serde_json::Value>().unwrap()["content"]["event_id"],236
+ "$seen:localhost"237
+ );238
+ assert!(matches!(239
+ handle240
+ .room_account_data(room_id!("!missing:localhost"), room_type.clone())241
+ .await,242
+ Err(ClientQueryError::RoomUnknown(_))243
+ ));244
+245
+ Mock::given(method("PUT"))246
+ .and(path(247
+ "/_matrix/client/v3/user/@example:localhost/account_data/com.example.preferences",248
+ ))249
+ .and(body_partial_json(json!({ "theme": "light" })))250
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))251
+ .expect(1)252
+ .mount(server.server())253
+ .await;254
+ server255
+ .mock_set_room_account_data(room_type.clone())256
+ .ok()257
+ .expect(1)258
+ .mount()259
+ .await;260
+ handle261
+ .set_global_account_data(262
+ global_type,263
+ Raw::new(&json!({ "theme": "light" }))264
+ .unwrap()265
+ .cast_unchecked(),266
+ )267
+ .await268
+ .unwrap();269
+ handle270
+ .set_room_account_data(271
+ room_id,272
+ room_type,273
+ Raw::new(&json!({ "event_id": "$later:localhost" }))274
+ .unwrap()275
+ .cast_unchecked(),276
+ )277
+ .await278
+ .unwrap();279
+ assert!(matches!(280
+ handle281
+ .set_room_account_data(282
+ room_id!("!missing:localhost"),283
+ RoomAccountDataEventType::FullyRead,284
+ Raw::new(&json!({ "event_id": "$later:localhost" }))285
+ .unwrap()286
+ .cast_unchecked()287
+ )288
+ .await,289
+ Err(ClientQueryError::RoomUnknown(_))290
+ ));291
+ stop_engine(handle, &mut events, running).await;292
+}293
+294
+async fn configured_client() -> (MatrixMockServer, matrix_sdk::Client) {295
+ let server = MatrixMockServer::new().await;296
+ server297
+ .mock_sliding_sync()298
+ .ok(v5::Response::new("1".to_owned()))299
+ .mount()300
+ .await;301
+ let client = server.client_builder().build().await;302
+ server.mock_room_state_encryption().plain().mount().await;303
+ (server, client)304
+}305
+306
+async fn start_engine() -> (307
+ MatrixMockServer,308
+ ClientHandle,309
+ EventStream,310
+ JoinHandle<Result<(), EngineError>>,311
+) {312
+ let (server, client) = configured_client().await;313
+ let (handle, events, running) = run_client(client).await;314
+ (server, handle, events, running)315
+}316
+317
+async fn run_client(318
+ client: matrix_sdk::Client,319
+) -> (320
+ ClientHandle,321
+ EventStream,322
+ JoinHandle<Result<(), EngineError>>,323
+) {324
+ let config = EngineConfig::default().with_initial_history_limit(0);325
+ let (engine, handle, events) = Engine::from_client(client, config).await.unwrap();326
+ let running = tokio::spawn(engine.run());327
+ (handle, events, running)328
+}329
+330
+async fn receive_room_success(events: &mut EventStream, expected_id: CommandId) {331
+ tokio::time::timeout(Duration::from_secs(3), async {332
+ loop {333
+ match events.recv().await.expect("event stream closed") {334
+ Event::CommandSucceeded {335
+ id,336
+ command: CommandKind::Room,337
+ } if id == expected_id => break,338
+ Event::CommandFailed(failure) if failure.id == expected_id => {339
+ panic!("room command failed {failure:?}")340
+ }341
+ _ => {}342
+ }343
+ }344
+ })345
+ .await346
+ .expect("room command did not complete");347
+}348
+349
+async fn receive_room_failure(350
+ events: &mut EventStream,351
+ expected_id: CommandId,352
+ expected_kind: CommandErrorKind,353
+) {354
+ tokio::time::timeout(Duration::from_secs(3), async {355
+ loop {356
+ match events.recv().await.expect("event stream closed") {357
+ Event::CommandFailed(failure) if failure.id == expected_id => {358
+ assert_eq!(failure.command, CommandKind::Room);359
+ assert_eq!(failure.kind, expected_kind);360
+ break;361
+ }362
+ Event::CommandSucceeded { id, .. } if id == expected_id => {363
+ panic!("room command unexpectedly succeeded")364
+ }365
+ _ => {}366
+ }367
+ }368
+ })369
+ .await370
+ .expect("room command did not fail");371
+}372
+373
+async fn stop_engine(374
+ handle: ClientHandle,375
+ events: &mut EventStream,376
+ running: JoinHandle<Result<(), EngineError>>,377
+) {378
+ let id = handle379
+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))380
+ .await381
+ .unwrap();382
+ tokio::time::timeout(Duration::from_secs(3), async {383
+ let mut succeeded = false;384
+ while let Some(event) = events.recv().await {385
+ match event {386
+ Event::CommandSucceeded {387
+ id: completed,388
+ command: CommandKind::Lifecycle,389
+ } if completed == id => succeeded = true,390
+ Event::Stopped => {391
+ assert!(succeeded);392
+ break;393
+ }394
+ _ => {}395
+ }396
+ }397
+ })398
+ .await399
+ .expect("engine did not stop");400
+ running.await.unwrap().unwrap();401
+}