Commit 973f8fbdaf80
tarball105 files changed+33707-0
@@ -0,0 +1 @@
1
+load flake@@ -0,0 +1,2 @@
1
+[target.wasm32-unknown-unknown]2
+rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]@@ -0,0 +1,12 @@
1
+/target2
+/crates/malice-worker/target3
+/web/node_modules4
+/web/dist5
+/web/dist-device6
+/web/src/worker/pkg7
+/web/.test-dist8
+/result9
+/.direnv10
+/.env11
+/local-data/12
+/data@@ -0,0 +1,5013 @@
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.1.0",12
+ "proc-macro2",13
+ "quote",14
+ "syn 3.0.6",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.4"128
+source = "registry+https://github.com/rust-lang/crates.io-index"129
+checksum = "4b220e7e9afc9b4ebf03f061f1a3f8e09a8aeee303bfab530bff54cfdfe311d6"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.48"152
+source = "registry+https://github.com/rust-lang/crates.io-index"153
+checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"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.6",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 = "axum"256
+version = "0.8.9"257
+source = "registry+https://github.com/rust-lang/crates.io-index"258
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"259
+dependencies = [260
+ "axum-core",261
+ "axum-macros",262
+ "bytes",263
+ "form_urlencoded",264
+ "futures-util",265
+ "http",266
+ "http-body",267
+ "http-body-util",268
+ "hyper",269
+ "hyper-util",270
+ "itoa",271
+ "matchit",272
+ "memchr",273
+ "mime",274
+ "multer",275
+ "percent-encoding",276
+ "pin-project-lite",277
+ "serde_core",278
+ "serde_json",279
+ "serde_path_to_error",280
+ "serde_urlencoded",281
+ "sync_wrapper",282
+ "tokio",283
+ "tower",284
+ "tower-layer",285
+ "tower-service",286
+ "tracing",287
+]288
+289
+[[package]]290
+name = "axum-core"291
+version = "0.5.6"292
+source = "registry+https://github.com/rust-lang/crates.io-index"293
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"294
+dependencies = [295
+ "bytes",296
+ "futures-core",297
+ "http",298
+ "http-body",299
+ "http-body-util",300
+ "mime",301
+ "pin-project-lite",302
+ "sync_wrapper",303
+ "tower-layer",304
+ "tower-service",305
+ "tracing",306
+]307
+308
+[[package]]309
+name = "axum-macros"310
+version = "0.5.1"311
+source = "registry+https://github.com/rust-lang/crates.io-index"312
+checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"313
+dependencies = [314
+ "proc-macro2",315
+ "quote",316
+ "syn 2.0.119",317
+]318
+319
+[[package]]320
+name = "backon"321
+version = "1.6.0"322
+source = "registry+https://github.com/rust-lang/crates.io-index"323
+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"324
+dependencies = [325
+ "fastrand",326
+ "gloo-timers",327
+ "tokio",328
+]329
+330
+[[package]]331
+name = "base64"332
+version = "0.22.1"333
+source = "registry+https://github.com/rust-lang/crates.io-index"334
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"335
+336
+[[package]]337
+name = "base64"338
+version = "0.23.1"339
+source = "registry+https://github.com/rust-lang/crates.io-index"340
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"341
+342
+[[package]]343
+name = "base64ct"344
+version = "1.8.3"345
+source = "registry+https://github.com/rust-lang/crates.io-index"346
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"347
+348
+[[package]]349
+name = "bitflags"350
+version = "2.13.2"351
+source = "registry+https://github.com/rust-lang/crates.io-index"352
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"353
+dependencies = [354
+ "serde_core",355
+]356
+357
+[[package]]358
+name = "bitmaps"359
+version = "3.2.1"360
+source = "registry+https://github.com/rust-lang/crates.io-index"361
+checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"362
+363
+[[package]]364
+name = "blake3"365
+version = "1.8.7"366
+source = "registry+https://github.com/rust-lang/crates.io-index"367
+checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"368
+dependencies = [369
+ "arrayvec",370
+ "cc",371
+ "cfg-if",372
+ "constant_time_eq",373
+ "cpufeatures 0.3.1",374
+]375
+376
+[[package]]377
+name = "block-buffer"378
+version = "0.10.4"379
+source = "registry+https://github.com/rust-lang/crates.io-index"380
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"381
+dependencies = [382
+ "generic-array",383
+]384
+385
+[[package]]386
+name = "block-padding"387
+version = "0.3.3"388
+source = "registry+https://github.com/rust-lang/crates.io-index"389
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"390
+dependencies = [391
+ "generic-array",392
+]393
+394
+[[package]]395
+name = "bs58"396
+version = "0.5.1"397
+source = "registry+https://github.com/rust-lang/crates.io-index"398
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"399
+dependencies = [400
+ "tinyvec",401
+]402
+403
+[[package]]404
+name = "bumpalo"405
+version = "3.20.3"406
+source = "registry+https://github.com/rust-lang/crates.io-index"407
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"408
+409
+[[package]]410
+name = "byteorder"411
+version = "1.5.0"412
+source = "registry+https://github.com/rust-lang/crates.io-index"413
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"414
+415
+[[package]]416
+name = "bytes"417
+version = "1.12.1"418
+source = "registry+https://github.com/rust-lang/crates.io-index"419
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"420
+421
+[[package]]422
+name = "bytesize"423
+version = "2.7.0"424
+source = "registry+https://github.com/rust-lang/crates.io-index"425
+checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b"426
+427
+[[package]]428
+name = "cast"429
+version = "0.3.0"430
+source = "registry+https://github.com/rust-lang/crates.io-index"431
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"432
+433
+[[package]]434
+name = "cbc"435
+version = "0.1.2"436
+source = "registry+https://github.com/rust-lang/crates.io-index"437
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"438
+dependencies = [439
+ "cipher",440
+]441
+442
+[[package]]443
+name = "cc"444
+version = "1.4.7"445
+source = "registry+https://github.com/rust-lang/crates.io-index"446
+checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"447
+dependencies = [448
+ "find-msvc-tools",449
+ "jobserver",450
+ "libc",451
+ "shlex",452
+]453
+454
+[[package]]455
+name = "cfg-if"456
+version = "1.0.5"457
+source = "registry+https://github.com/rust-lang/crates.io-index"458
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"459
+460
+[[package]]461
+name = "cfg_aliases"462
+version = "0.2.2"463
+source = "registry+https://github.com/rust-lang/crates.io-index"464
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"465
+466
+[[package]]467
+name = "chacha20"468
+version = "0.9.1"469
+source = "registry+https://github.com/rust-lang/crates.io-index"470
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"471
+dependencies = [472
+ "cfg-if",473
+ "cipher",474
+ "cpufeatures 0.2.17",475
+]476
+477
+[[package]]478
+name = "chacha20"479
+version = "0.10.2"480
+source = "registry+https://github.com/rust-lang/crates.io-index"481
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"482
+dependencies = [483
+ "cfg-if",484
+ "cpufeatures 0.3.1",485
+ "rand_core 0.10.1",486
+]487
+488
+[[package]]489
+name = "chacha20poly1305"490
+version = "0.10.1"491
+source = "registry+https://github.com/rust-lang/crates.io-index"492
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"493
+dependencies = [494
+ "aead",495
+ "chacha20 0.9.1",496
+ "cipher",497
+ "poly1305",498
+ "zeroize",499
+]500
+501
+[[package]]502
+name = "chrono"503
+version = "0.4.45"504
+source = "registry+https://github.com/rust-lang/crates.io-index"505
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"506
+dependencies = [507
+ "iana-time-zone",508
+ "js-sys",509
+ "num-traits",510
+ "serde",511
+ "wasm-bindgen",512
+ "windows-link",513
+]514
+515
+[[package]]516
+name = "cipher"517
+version = "0.4.4"518
+source = "registry+https://github.com/rust-lang/crates.io-index"519
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"520
+dependencies = [521
+ "crypto-common",522
+ "inout",523
+ "zeroize",524
+]525
+526
+[[package]]527
+name = "cmake"528
+version = "0.1.58"529
+source = "registry+https://github.com/rust-lang/crates.io-index"530
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"531
+dependencies = [532
+ "cc",533
+]534
+535
+[[package]]536
+name = "combine"537
+version = "4.6.8"538
+source = "registry+https://github.com/rust-lang/crates.io-index"539
+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"540
+dependencies = [541
+ "bytes",542
+ "memchr",543
+]544
+545
+[[package]]546
+name = "compression-codecs"547
+version = "0.4.43"548
+source = "registry+https://github.com/rust-lang/crates.io-index"549
+checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"550
+dependencies = [551
+ "compression-core",552
+ "flate2",553
+ "memchr",554
+]555
+556
+[[package]]557
+name = "compression-core"558
+version = "0.4.33"559
+source = "registry+https://github.com/rust-lang/crates.io-index"560
+checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"561
+562
+[[package]]563
+name = "concurrent-queue"564
+version = "2.5.0"565
+source = "registry+https://github.com/rust-lang/crates.io-index"566
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"567
+dependencies = [568
+ "crossbeam-utils",569
+]570
+571
+[[package]]572
+name = "console"573
+version = "0.16.6"574
+source = "registry+https://github.com/rust-lang/crates.io-index"575
+checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"576
+dependencies = [577
+ "encode_unicode",578
+ "libc",579
+ "windows-sys 0.61.2",580
+]581
+582
+[[package]]583
+name = "const-oid"584
+version = "0.9.6"585
+source = "registry+https://github.com/rust-lang/crates.io-index"586
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"587
+588
+[[package]]589
+name = "const_panic"590
+version = "0.2.17"591
+source = "registry+https://github.com/rust-lang/crates.io-index"592
+checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3"593
+dependencies = [594
+ "typewit",595
+]596
+597
+[[package]]598
+name = "constant_time_eq"599
+version = "0.4.2"600
+source = "registry+https://github.com/rust-lang/crates.io-index"601
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"602
+603
+[[package]]604
+name = "core-foundation"605
+version = "0.10.1"606
+source = "registry+https://github.com/rust-lang/crates.io-index"607
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"608
+dependencies = [609
+ "core-foundation-sys",610
+ "libc",611
+]612
+613
+[[package]]614
+name = "core-foundation-sys"615
+version = "0.8.7"616
+source = "registry+https://github.com/rust-lang/crates.io-index"617
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"618
+619
+[[package]]620
+name = "core_detect"621
+version = "1.0.0"622
+source = "registry+https://github.com/rust-lang/crates.io-index"623
+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"624
+625
+[[package]]626
+name = "cpufeatures"627
+version = "0.2.17"628
+source = "registry+https://github.com/rust-lang/crates.io-index"629
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"630
+dependencies = [631
+ "libc",632
+]633
+634
+[[package]]635
+name = "cpufeatures"636
+version = "0.3.1"637
+source = "registry+https://github.com/rust-lang/crates.io-index"638
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"639
+dependencies = [640
+ "libc",641
+]642
+643
+[[package]]644
+name = "crc32fast"645
+version = "1.5.2"646
+source = "registry+https://github.com/rust-lang/crates.io-index"647
+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"648
+dependencies = [649
+ "cfg-if",650
+]651
+652
+[[package]]653
+name = "crossbeam-utils"654
+version = "0.8.23"655
+source = "registry+https://github.com/rust-lang/crates.io-index"656
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"657
+658
+[[package]]659
+name = "crypto-common"660
+version = "0.1.7"661
+source = "registry+https://github.com/rust-lang/crates.io-index"662
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"663
+dependencies = [664
+ "generic-array",665
+ "rand_core 0.6.4",666
+ "typenum",667
+]668
+669
+[[package]]670
+name = "ctor"671
+version = "0.2.9"672
+source = "registry+https://github.com/rust-lang/crates.io-index"673
+checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"674
+dependencies = [675
+ "quote",676
+ "syn 2.0.119",677
+]678
+679
+[[package]]680
+name = "ctr"681
+version = "0.9.2"682
+source = "registry+https://github.com/rust-lang/crates.io-index"683
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"684
+dependencies = [685
+ "cipher",686
+]687
+688
+[[package]]689
+name = "curve25519-dalek"690
+version = "4.1.3"691
+source = "registry+https://github.com/rust-lang/crates.io-index"692
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"693
+dependencies = [694
+ "cfg-if",695
+ "cpufeatures 0.2.17",696
+ "curve25519-dalek-derive",697
+ "digest",698
+ "fiat-crypto",699
+ "rustc_version",700
+ "serde",701
+ "subtle",702
+ "zeroize",703
+]704
+705
+[[package]]706
+name = "curve25519-dalek-derive"707
+version = "0.1.1"708
+source = "registry+https://github.com/rust-lang/crates.io-index"709
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"710
+dependencies = [711
+ "proc-macro2",712
+ "quote",713
+ "syn 2.0.119",714
+]715
+716
+[[package]]717
+name = "date_header"718
+version = "1.0.5"719
+source = "registry+https://github.com/rust-lang/crates.io-index"720
+checksum = "0c03c416ed1a30fbb027ef484ba6ab6f80e1eada675e1a2b92fd673c045a1f1d"721
+722
+[[package]]723
+name = "deadpool"724
+version = "0.12.3"725
+source = "registry+https://github.com/rust-lang/crates.io-index"726
+checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"727
+dependencies = [728
+ "deadpool-runtime 0.1.4",729
+ "lazy_static",730
+ "num_cpus",731
+ "tokio",732
+]733
+734
+[[package]]735
+name = "deadpool"736
+version = "0.13.1"737
+source = "registry+https://github.com/rust-lang/crates.io-index"738
+checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e"739
+dependencies = [740
+ "deadpool-runtime 0.3.1",741
+ "tokio",742
+]743
+744
+[[package]]745
+name = "deadpool-runtime"746
+version = "0.1.4"747
+source = "registry+https://github.com/rust-lang/crates.io-index"748
+checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"749
+750
+[[package]]751
+name = "deadpool-runtime"752
+version = "0.3.1"753
+source = "registry+https://github.com/rust-lang/crates.io-index"754
+checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae"755
+dependencies = [756
+ "tokio",757
+]758
+759
+[[package]]760
+name = "deadpool-sync"761
+version = "0.2.0"762
+source = "registry+https://github.com/rust-lang/crates.io-index"763
+checksum = "e385cc95d3d582c328b36d1ff90feac061102b001894b555e6b465a2e0eaabbf"764
+dependencies = [765
+ "deadpool-runtime 0.3.1",766
+]767
+768
+[[package]]769
+name = "decancer"770
+version = "3.3.3"771
+source = "registry+https://github.com/rust-lang/crates.io-index"772
+checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"773
+774
+[[package]]775
+name = "delegate-display"776
+version = "3.0.1"777
+source = "registry+https://github.com/rust-lang/crates.io-index"778
+checksum = "54e3a499943fd5180aeffcb708164ffb22125ceeccb05491b5297981d37d6249"779
+dependencies = [780
+ "impartial-ord",781
+ "itoa",782
+ "macroific 3.1.0",783
+ "proc-macro2",784
+ "quote",785
+ "syn 3.0.6",786
+]787
+788
+[[package]]789
+name = "der"790
+version = "0.7.10"791
+source = "registry+https://github.com/rust-lang/crates.io-index"792
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"793
+dependencies = [794
+ "const-oid",795
+ "zeroize",796
+]797
+798
+[[package]]799
+name = "deranged"800
+version = "0.5.8"801
+source = "registry+https://github.com/rust-lang/crates.io-index"802
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"803
+804
+[[package]]805
+name = "derivative"806
+version = "2.2.0"807
+source = "registry+https://github.com/rust-lang/crates.io-index"808
+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"809
+dependencies = [810
+ "proc-macro2",811
+ "quote",812
+ "syn 1.0.109",813
+]814
+815
+[[package]]816
+name = "derive_more"817
+version = "1.0.0"818
+source = "registry+https://github.com/rust-lang/crates.io-index"819
+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"820
+dependencies = [821
+ "derive_more-impl 1.0.0",822
+]823
+824
+[[package]]825
+name = "derive_more"826
+version = "2.1.1"827
+source = "registry+https://github.com/rust-lang/crates.io-index"828
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"829
+dependencies = [830
+ "derive_more-impl 2.1.1",831
+]832
+833
+[[package]]834
+name = "derive_more-impl"835
+version = "1.0.0"836
+source = "registry+https://github.com/rust-lang/crates.io-index"837
+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"838
+dependencies = [839
+ "proc-macro2",840
+ "quote",841
+ "syn 2.0.119",842
+]843
+844
+[[package]]845
+name = "derive_more-impl"846
+version = "2.1.1"847
+source = "registry+https://github.com/rust-lang/crates.io-index"848
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"849
+dependencies = [850
+ "proc-macro2",851
+ "quote",852
+ "rustc_version",853
+ "syn 2.0.119",854
+ "unicode-xid",855
+]856
+857
+[[package]]858
+name = "digest"859
+version = "0.10.7"860
+source = "registry+https://github.com/rust-lang/crates.io-index"861
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"862
+dependencies = [863
+ "block-buffer",864
+ "crypto-common",865
+ "subtle",866
+]867
+868
+[[package]]869
+name = "displaydoc"870
+version = "0.2.7"871
+source = "registry+https://github.com/rust-lang/crates.io-index"872
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"873
+dependencies = [874
+ "proc-macro2",875
+ "quote",876
+ "syn 3.0.6",877
+]878
+879
+[[package]]880
+name = "dunce"881
+version = "1.0.5"882
+source = "registry+https://github.com/rust-lang/crates.io-index"883
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"884
+885
+[[package]]886
+name = "ed25519"887
+version = "2.2.3"888
+source = "registry+https://github.com/rust-lang/crates.io-index"889
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"890
+dependencies = [891
+ "pkcs8",892
+ "serde",893
+ "signature",894
+]895
+896
+[[package]]897
+name = "ed25519-dalek"898
+version = "2.2.0"899
+source = "registry+https://github.com/rust-lang/crates.io-index"900
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"901
+dependencies = [902
+ "curve25519-dalek",903
+ "ed25519",904
+ "rand_core 0.6.4",905
+ "serde",906
+ "sha2",907
+ "subtle",908
+ "zeroize",909
+]910
+911
+[[package]]912
+name = "either"913
+version = "1.18.0"914
+source = "registry+https://github.com/rust-lang/crates.io-index"915
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"916
+917
+[[package]]918
+name = "emojis"919
+version = "0.8.2"920
+source = "registry+https://github.com/rust-lang/crates.io-index"921
+checksum = "0a4d5d50b0b58df5173d8ff1192b4d1422ceae5d981b30d4b6f8ed1d673a2bc4"922
+dependencies = [923
+ "phf",924
+]925
+926
+[[package]]927
+name = "encode_unicode"928
+version = "1.0.0"929
+source = "registry+https://github.com/rust-lang/crates.io-index"930
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"931
+932
+[[package]]933
+name = "encoding_rs"934
+version = "0.8.41"935
+source = "registry+https://github.com/rust-lang/crates.io-index"936
+checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a"937
+dependencies = [938
+ "cfg-if",939
+ "core_detect",940
+ "multiversion",941
+ "multiversion_no_op",942
+ "rustversion",943
+ "scopeguard",944
+ "simdutf8",945
+]946
+947
+[[package]]948
+name = "equivalent"949
+version = "1.0.2"950
+source = "registry+https://github.com/rust-lang/crates.io-index"951
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"952
+953
+[[package]]954
+name = "errno"955
+version = "0.3.14"956
+source = "registry+https://github.com/rust-lang/crates.io-index"957
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"958
+dependencies = [959
+ "libc",960
+ "windows-sys 0.61.2",961
+]962
+963
+[[package]]964
+name = "event-listener"965
+version = "5.4.2"966
+source = "registry+https://github.com/rust-lang/crates.io-index"967
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"968
+dependencies = [969
+ "parking",970
+ "pin-project-lite",971
+]972
+973
+[[package]]974
+name = "event-listener-strategy"975
+version = "0.5.4"976
+source = "registry+https://github.com/rust-lang/crates.io-index"977
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"978
+dependencies = [979
+ "event-listener",980
+ "pin-project-lite",981
+]982
+983
+[[package]]984
+name = "eyeball"985
+version = "0.8.8"986
+source = "registry+https://github.com/rust-lang/crates.io-index"987
+checksum = "d93bd0ebf93d61d6332d3c09a96e97975968a44e19a64c947bde06e6baff383f"988
+dependencies = [989
+ "futures-core",990
+ "readlock",991
+ "readlock-tokio",992
+ "tokio",993
+ "tokio-util",994
+ "tracing",995
+]996
+997
+[[package]]998
+name = "eyeball-im"999
+version = "0.8.0"1000
+source = "registry+https://github.com/rust-lang/crates.io-index"1001
+checksum = "4790c03df183c2b46665c1a58118c04fd3e3976ec2fe16a0aa00e00c9eea7754"1002
+dependencies = [1003
+ "futures-core",1004
+ "imbl",1005
+ "tokio",1006
+ "tracing",1007
+]1008
+1009
+[[package]]1010
+name = "eyeball-im-util"1011
+version = "0.10.0"1012
+source = "registry+https://github.com/rust-lang/crates.io-index"1013
+checksum = "809fa2e8e59c25a41f5b2440aa937f1e84df6ae48b707e65f69ce259b1c44bda"1014
+dependencies = [1015
+ "arrayvec",1016
+ "eyeball-im",1017
+ "futures-core",1018
+ "imbl",1019
+ "pin-project-lite",1020
+ "smallvec",1021
+]1022
+1023
+[[package]]1024
+name = "fallible-iterator"1025
+version = "0.3.0"1026
+source = "registry+https://github.com/rust-lang/crates.io-index"1027
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"1028
+1029
+[[package]]1030
+name = "fallible-streaming-iterator"1031
+version = "0.1.9"1032
+source = "registry+https://github.com/rust-lang/crates.io-index"1033
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"1034
+1035
+[[package]]1036
+name = "fancy_constructor"1037
+version = "2.1.1"1038
+source = "registry+https://github.com/rust-lang/crates.io-index"1039
+checksum = "4a8211ab12c36b63c269e17873331dc1da196a3edb7a5c79015677ef972da34a"1040
+dependencies = [1041
+ "macroific 3.1.0",1042
+ "proc-macro2",1043
+ "quote",1044
+ "syn 3.0.6",1045
+]1046
+1047
+[[package]]1048
+name = "fastrand"1049
+version = "2.5.0"1050
+source = "registry+https://github.com/rust-lang/crates.io-index"1051
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"1052
+1053
+[[package]]1054
+name = "fiat-crypto"1055
+version = "0.2.9"1056
+source = "registry+https://github.com/rust-lang/crates.io-index"1057
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"1058
+1059
+[[package]]1060
+name = "find-msvc-tools"1061
+version = "0.1.13"1062
+source = "registry+https://github.com/rust-lang/crates.io-index"1063
+checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"1064
+1065
+[[package]]1066
+name = "flate2"1067
+version = "1.1.10"1068
+source = "registry+https://github.com/rust-lang/crates.io-index"1069
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"1070
+dependencies = [1071
+ "crc32fast",1072
+ "miniz_oxide",1073
+ "zlib-rs",1074
+]1075
+1076
+[[package]]1077
+name = "fnv"1078
+version = "1.0.7"1079
+source = "registry+https://github.com/rust-lang/crates.io-index"1080
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"1081
+1082
+[[package]]1083
+name = "foldhash"1084
+version = "0.1.5"1085
+source = "registry+https://github.com/rust-lang/crates.io-index"1086
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"1087
+1088
+[[package]]1089
+name = "form_urlencoded"1090
+version = "1.2.2"1091
+source = "registry+https://github.com/rust-lang/crates.io-index"1092
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"1093
+dependencies = [1094
+ "percent-encoding",1095
+]1096
+1097
+[[package]]1098
+name = "fs_extra"1099
+version = "1.3.0"1100
+source = "registry+https://github.com/rust-lang/crates.io-index"1101
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"1102
+1103
+[[package]]1104
+name = "futures"1105
+version = "0.3.34"1106
+source = "registry+https://github.com/rust-lang/crates.io-index"1107
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"1108
+dependencies = [1109
+ "futures-channel",1110
+ "futures-core",1111
+ "futures-executor",1112
+ "futures-io",1113
+ "futures-sink",1114
+ "futures-task",1115
+ "futures-util",1116
+]1117
+1118
+[[package]]1119
+name = "futures-channel"1120
+version = "0.3.34"1121
+source = "registry+https://github.com/rust-lang/crates.io-index"1122
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"1123
+dependencies = [1124
+ "futures-core",1125
+ "futures-sink",1126
+]1127
+1128
+[[package]]1129
+name = "futures-core"1130
+version = "0.3.34"1131
+source = "registry+https://github.com/rust-lang/crates.io-index"1132
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"1133
+1134
+[[package]]1135
+name = "futures-executor"1136
+version = "0.3.34"1137
+source = "registry+https://github.com/rust-lang/crates.io-index"1138
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"1139
+dependencies = [1140
+ "futures-core",1141
+ "futures-task",1142
+ "futures-util",1143
+]1144
+1145
+[[package]]1146
+name = "futures-io"1147
+version = "0.3.34"1148
+source = "registry+https://github.com/rust-lang/crates.io-index"1149
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"1150
+1151
+[[package]]1152
+name = "futures-macro"1153
+version = "0.3.34"1154
+source = "registry+https://github.com/rust-lang/crates.io-index"1155
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"1156
+dependencies = [1157
+ "proc-macro2",1158
+ "quote",1159
+ "syn 3.0.6",1160
+]1161
+1162
+[[package]]1163
+name = "futures-sink"1164
+version = "0.3.34"1165
+source = "registry+https://github.com/rust-lang/crates.io-index"1166
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"1167
+1168
+[[package]]1169
+name = "futures-task"1170
+version = "0.3.34"1171
+source = "registry+https://github.com/rust-lang/crates.io-index"1172
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"1173
+1174
+[[package]]1175
+name = "futures-util"1176
+version = "0.3.34"1177
+source = "registry+https://github.com/rust-lang/crates.io-index"1178
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"1179
+dependencies = [1180
+ "futures-channel",1181
+ "futures-core",1182
+ "futures-io",1183
+ "futures-macro",1184
+ "futures-sink",1185
+ "futures-task",1186
+ "memchr",1187
+ "pin-project-lite",1188
+ "slab",1189
+]1190
+1191
+[[package]]1192
+name = "fuzzy-matcher"1193
+version = "0.3.7"1194
+source = "registry+https://github.com/rust-lang/crates.io-index"1195
+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"1196
+dependencies = [1197
+ "thread_local",1198
+]1199
+1200
+[[package]]1201
+name = "generator"1202
+version = "0.8.10"1203
+source = "registry+https://github.com/rust-lang/crates.io-index"1204
+checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551"1205
+dependencies = [1206
+ "cc",1207
+ "cfg-if",1208
+ "libc",1209
+ "log",1210
+ "rustversion",1211
+ "windows-link",1212
+ "windows-result",1213
+]1214
+1215
+[[package]]1216
+name = "generic-array"1217
+version = "0.14.7"1218
+source = "registry+https://github.com/rust-lang/crates.io-index"1219
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"1220
+dependencies = [1221
+ "typenum",1222
+ "version_check",1223
+]1224
+1225
+[[package]]1226
+name = "getrandom"1227
+version = "0.2.17"1228
+source = "registry+https://github.com/rust-lang/crates.io-index"1229
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"1230
+dependencies = [1231
+ "cfg-if",1232
+ "js-sys",1233
+ "libc",1234
+ "wasi",1235
+ "wasm-bindgen",1236
+]1237
+1238
+[[package]]1239
+name = "getrandom"1240
+version = "0.3.4"1241
+source = "registry+https://github.com/rust-lang/crates.io-index"1242
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"1243
+dependencies = [1244
+ "cfg-if",1245
+ "js-sys",1246
+ "libc",1247
+ "r-efi 5.3.0",1248
+ "wasip2",1249
+ "wasm-bindgen",1250
+]1251
+1252
+[[package]]1253
+name = "getrandom"1254
+version = "0.4.3"1255
+source = "registry+https://github.com/rust-lang/crates.io-index"1256
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"1257
+dependencies = [1258
+ "cfg-if",1259
+ "js-sys",1260
+ "libc",1261
+ "r-efi 6.0.0",1262
+ "rand_core 0.10.1",1263
+ "wasm-bindgen",1264
+]1265
+1266
+[[package]]1267
+name = "gloo-timers"1268
+version = "0.3.0"1269
+source = "registry+https://github.com/rust-lang/crates.io-index"1270
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"1271
+dependencies = [1272
+ "futures-channel",1273
+ "futures-core",1274
+ "js-sys",1275
+ "wasm-bindgen",1276
+]1277
+1278
+[[package]]1279
+name = "gloo-utils"1280
+version = "0.2.0"1281
+source = "registry+https://github.com/rust-lang/crates.io-index"1282
+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"1283
+dependencies = [1284
+ "js-sys",1285
+ "serde",1286
+ "serde_json",1287
+ "wasm-bindgen",1288
+ "web-sys",1289
+]1290
+1291
+[[package]]1292
+name = "growable-bloom-filter"1293
+version = "2.1.1"1294
+source = "registry+https://github.com/rust-lang/crates.io-index"1295
+checksum = "d174ccb4ba660d431329e7f0797870d0a4281e36353ec4b4a3c5eab6c2cfb6f1"1296
+dependencies = [1297
+ "serde",1298
+ "serde_bytes",1299
+ "serde_derive",1300
+ "xxhash-rust",1301
+]1302
+1303
+[[package]]1304
+name = "h2"1305
+version = "0.4.19"1306
+source = "registry+https://github.com/rust-lang/crates.io-index"1307
+checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"1308
+dependencies = [1309
+ "atomic-waker",1310
+ "bytes",1311
+ "fnv",1312
+ "futures-core",1313
+ "futures-sink",1314
+ "http",1315
+ "indexmap",1316
+ "slab",1317
+ "tokio",1318
+ "tokio-util",1319
+ "tracing",1320
+]1321
+1322
+[[package]]1323
+name = "hashbrown"1324
+version = "0.15.5"1325
+source = "registry+https://github.com/rust-lang/crates.io-index"1326
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"1327
+dependencies = [1328
+ "foldhash",1329
+]1330
+1331
+[[package]]1332
+name = "hashbrown"1333
+version = "0.17.1"1334
+source = "registry+https://github.com/rust-lang/crates.io-index"1335
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"1336
+1337
+[[package]]1338
+name = "hashlink"1339
+version = "0.10.0"1340
+source = "registry+https://github.com/rust-lang/crates.io-index"1341
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"1342
+dependencies = [1343
+ "hashbrown 0.15.5",1344
+]1345
+1346
+[[package]]1347
+name = "hermit-abi"1348
+version = "0.5.3"1349
+source = "registry+https://github.com/rust-lang/crates.io-index"1350
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"1351
+1352
+[[package]]1353
+name = "hex"1354
+version = "0.4.3"1355
+source = "registry+https://github.com/rust-lang/crates.io-index"1356
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"1357
+1358
+[[package]]1359
+name = "hkdf"1360
+version = "0.12.4"1361
+source = "registry+https://github.com/rust-lang/crates.io-index"1362
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"1363
+dependencies = [1364
+ "hmac",1365
+]1366
+1367
+[[package]]1368
+name = "hmac"1369
+version = "0.12.1"1370
+source = "registry+https://github.com/rust-lang/crates.io-index"1371
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"1372
+dependencies = [1373
+ "digest",1374
+]1375
+1376
+[[package]]1377
+name = "html5ever"1378
+version = "0.39.0"1379
+source = "registry+https://github.com/rust-lang/crates.io-index"1380
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"1381
+dependencies = [1382
+ "log",1383
+ "markup5ever",1384
+]1385
+1386
+[[package]]1387
+name = "http"1388
+version = "1.5.0"1389
+source = "registry+https://github.com/rust-lang/crates.io-index"1390
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"1391
+dependencies = [1392
+ "bytes",1393
+ "itoa",1394
+]1395
+1396
+[[package]]1397
+name = "http-body"1398
+version = "1.1.0"1399
+source = "registry+https://github.com/rust-lang/crates.io-index"1400
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"1401
+dependencies = [1402
+ "bytes",1403
+ "http",1404
+]1405
+1406
+[[package]]1407
+name = "http-body-util"1408
+version = "0.1.5"1409
+source = "registry+https://github.com/rust-lang/crates.io-index"1410
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"1411
+dependencies = [1412
+ "bytes",1413
+ "futures-core",1414
+ "http",1415
+ "http-body",1416
+ "pin-project-lite",1417
+]1418
+1419
+[[package]]1420
+name = "http-range-header"1421
+version = "0.4.2"1422
+source = "registry+https://github.com/rust-lang/crates.io-index"1423
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"1424
+1425
+[[package]]1426
+name = "httparse"1427
+version = "1.10.1"1428
+source = "registry+https://github.com/rust-lang/crates.io-index"1429
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"1430
+1431
+[[package]]1432
+name = "httpdate"1433
+version = "1.0.3"1434
+source = "registry+https://github.com/rust-lang/crates.io-index"1435
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"1436
+1437
+[[package]]1438
+name = "hyper"1439
+version = "1.11.1"1440
+source = "registry+https://github.com/rust-lang/crates.io-index"1441
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"1442
+dependencies = [1443
+ "atomic-waker",1444
+ "bytes",1445
+ "futures-channel",1446
+ "futures-core",1447
+ "h2",1448
+ "http",1449
+ "http-body",1450
+ "httparse",1451
+ "httpdate",1452
+ "itoa",1453
+ "pin-project-lite",1454
+ "smallvec",1455
+ "tokio",1456
+ "want",1457
+]1458
+1459
+[[package]]1460
+name = "hyper-rustls"1461
+version = "0.27.10"1462
+source = "registry+https://github.com/rust-lang/crates.io-index"1463
+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"1464
+dependencies = [1465
+ "http",1466
+ "hyper",1467
+ "hyper-util",1468
+ "rustls",1469
+ "tokio",1470
+ "tokio-rustls",1471
+ "tower-service",1472
+]1473
+1474
+[[package]]1475
+name = "hyper-util"1476
+version = "0.1.20"1477
+source = "registry+https://github.com/rust-lang/crates.io-index"1478
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"1479
+dependencies = [1480
+ "base64 0.22.1",1481
+ "bytes",1482
+ "futures-channel",1483
+ "futures-util",1484
+ "http",1485
+ "http-body",1486
+ "hyper",1487
+ "ipnet",1488
+ "libc",1489
+ "percent-encoding",1490
+ "pin-project-lite",1491
+ "socket2",1492
+ "tokio",1493
+ "tower-service",1494
+ "tracing",1495
+]1496
+1497
+[[package]]1498
+name = "iana-time-zone"1499
+version = "0.1.65"1500
+source = "registry+https://github.com/rust-lang/crates.io-index"1501
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"1502
+dependencies = [1503
+ "android_system_properties",1504
+ "core-foundation-sys",1505
+ "iana-time-zone-haiku",1506
+ "js-sys",1507
+ "log",1508
+ "wasm-bindgen",1509
+ "windows-core",1510
+]1511
+1512
+[[package]]1513
+name = "iana-time-zone-haiku"1514
+version = "0.1.2"1515
+source = "registry+https://github.com/rust-lang/crates.io-index"1516
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"1517
+dependencies = [1518
+ "cc",1519
+]1520
+1521
+[[package]]1522
+name = "icu_collections"1523
+version = "2.3.0"1524
+source = "registry+https://github.com/rust-lang/crates.io-index"1525
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"1526
+dependencies = [1527
+ "displaydoc",1528
+ "potential_utf",1529
+ "utf8_iter",1530
+ "yoke",1531
+ "zerofrom",1532
+ "zerovec",1533
+]1534
+1535
+[[package]]1536
+name = "icu_locale_core"1537
+version = "2.3.0"1538
+source = "registry+https://github.com/rust-lang/crates.io-index"1539
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"1540
+dependencies = [1541
+ "displaydoc",1542
+ "litemap",1543
+ "tinystr",1544
+ "writeable",1545
+ "zerovec",1546
+]1547
+1548
+[[package]]1549
+name = "icu_normalizer"1550
+version = "2.3.0"1551
+source = "registry+https://github.com/rust-lang/crates.io-index"1552
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"1553
+dependencies = [1554
+ "icu_collections",1555
+ "icu_normalizer_data",1556
+ "icu_properties",1557
+ "icu_provider",1558
+ "smallvec",1559
+ "zerovec",1560
+]1561
+1562
+[[package]]1563
+name = "icu_normalizer_data"1564
+version = "2.3.0"1565
+source = "registry+https://github.com/rust-lang/crates.io-index"1566
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"1567
+1568
+[[package]]1569
+name = "icu_properties"1570
+version = "2.3.0"1571
+source = "registry+https://github.com/rust-lang/crates.io-index"1572
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"1573
+dependencies = [1574
+ "displaydoc",1575
+ "icu_collections",1576
+ "icu_locale_core",1577
+ "icu_properties_data",1578
+ "icu_provider",1579
+ "zerotrie",1580
+ "zerovec",1581
+]1582
+1583
+[[package]]1584
+name = "icu_properties_data"1585
+version = "2.3.0"1586
+source = "registry+https://github.com/rust-lang/crates.io-index"1587
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"1588
+1589
+[[package]]1590
+name = "icu_provider"1591
+version = "2.3.1"1592
+source = "registry+https://github.com/rust-lang/crates.io-index"1593
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"1594
+dependencies = [1595
+ "displaydoc",1596
+ "icu_locale_core",1597
+ "writeable",1598
+ "yoke",1599
+ "zerofrom",1600
+ "zerotrie",1601
+ "zerovec",1602
+]1603
+1604
+[[package]]1605
+name = "idna"1606
+version = "1.1.0"1607
+source = "registry+https://github.com/rust-lang/crates.io-index"1608
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"1609
+dependencies = [1610
+ "idna_adapter",1611
+ "smallvec",1612
+ "utf8_iter",1613
+]1614
+1615
+[[package]]1616
+name = "idna_adapter"1617
+version = "1.2.2"1618
+source = "registry+https://github.com/rust-lang/crates.io-index"1619
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"1620
+dependencies = [1621
+ "icu_normalizer",1622
+ "icu_properties",1623
+]1624
+1625
+[[package]]1626
+name = "imbl"1627
+version = "6.1.0"1628
+source = "registry+https://github.com/rust-lang/crates.io-index"1629
+checksum = "0fade8ae6828627ad1fa094a891eccfb25150b383047190a3648d66d06186501"1630
+dependencies = [1631
+ "archery",1632
+ "bitmaps",1633
+ "imbl-sized-chunks",1634
+ "rand_core 0.9.5",1635
+ "rand_xoshiro",1636
+ "serde",1637
+ "version_check",1638
+]1639
+1640
+[[package]]1641
+name = "imbl-sized-chunks"1642
+version = "0.1.3"1643
+source = "registry+https://github.com/rust-lang/crates.io-index"1644
+checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d"1645
+dependencies = [1646
+ "bitmaps",1647
+]1648
+1649
+[[package]]1650
+name = "impartial-ord"1651
+version = "2.0.0"1652
+source = "registry+https://github.com/rust-lang/crates.io-index"1653
+checksum = "bc680e03d1a08bdc1c01b60e0c39723430ced63d8cabd93cee0449b6ba916a25"1654
+dependencies = [1655
+ "proc-macro2",1656
+ "quote",1657
+ "syn 3.0.6",1658
+]1659
+1660
+[[package]]1661
+name = "include_dir"1662
+version = "0.7.4"1663
+source = "registry+https://github.com/rust-lang/crates.io-index"1664
+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"1665
+dependencies = [1666
+ "include_dir_macros",1667
+]1668
+1669
+[[package]]1670
+name = "include_dir_macros"1671
+version = "0.7.4"1672
+source = "registry+https://github.com/rust-lang/crates.io-index"1673
+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"1674
+dependencies = [1675
+ "proc-macro2",1676
+ "quote",1677
+]1678
+1679
+[[package]]1680
+name = "indexmap"1681
+version = "2.14.2"1682
+source = "registry+https://github.com/rust-lang/crates.io-index"1683
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"1684
+dependencies = [1685
+ "equivalent",1686
+ "hashbrown 0.17.1",1687
+ "serde",1688
+ "serde_core",1689
+]1690
+1691
+[[package]]1692
+name = "inout"1693
+version = "0.1.4"1694
+source = "registry+https://github.com/rust-lang/crates.io-index"1695
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"1696
+dependencies = [1697
+ "block-padding",1698
+ "generic-array",1699
+]1700
+1701
+[[package]]1702
+name = "insta"1703
+version = "1.48.0"1704
+source = "registry+https://github.com/rust-lang/crates.io-index"1705
+checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"1706
+dependencies = [1707
+ "console",1708
+ "once_cell",1709
+ "pest",1710
+ "pest_derive",1711
+ "serde",1712
+ "similar",1713
+ "tempfile",1714
+]1715
+1716
+[[package]]1717
+name = "ipnet"1718
+version = "2.12.2"1719
+source = "registry+https://github.com/rust-lang/crates.io-index"1720
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"1721
+1722
+[[package]]1723
+name = "itertools"1724
+version = "0.10.5"1725
+source = "registry+https://github.com/rust-lang/crates.io-index"1726
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"1727
+dependencies = [1728
+ "either",1729
+]1730
+1731
+[[package]]1732
+name = "itertools"1733
+version = "0.14.0"1734
+source = "registry+https://github.com/rust-lang/crates.io-index"1735
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"1736
+dependencies = [1737
+ "either",1738
+]1739
+1740
+[[package]]1741
+name = "itoa"1742
+version = "1.0.18"1743
+source = "registry+https://github.com/rust-lang/crates.io-index"1744
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"1745
+1746
+[[package]]1747
+name = "jni"1748
+version = "0.22.4"1749
+source = "registry+https://github.com/rust-lang/crates.io-index"1750
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"1751
+dependencies = [1752
+ "cfg-if",1753
+ "combine",1754
+ "jni-macros",1755
+ "jni-sys",1756
+ "log",1757
+ "simd_cesu8",1758
+ "thiserror 2.0.21",1759
+ "walkdir",1760
+ "windows-link",1761
+]1762
+1763
+[[package]]1764
+name = "jni-macros"1765
+version = "0.22.4"1766
+source = "registry+https://github.com/rust-lang/crates.io-index"1767
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"1768
+dependencies = [1769
+ "proc-macro2",1770
+ "quote",1771
+ "rustc_version",1772
+ "simd_cesu8",1773
+ "syn 2.0.119",1774
+]1775
+1776
+[[package]]1777
+name = "jni-sys"1778
+version = "0.4.1"1779
+source = "registry+https://github.com/rust-lang/crates.io-index"1780
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"1781
+dependencies = [1782
+ "jni-sys-macros",1783
+]1784
+1785
+[[package]]1786
+name = "jni-sys-macros"1787
+version = "0.4.1"1788
+source = "registry+https://github.com/rust-lang/crates.io-index"1789
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"1790
+dependencies = [1791
+ "quote",1792
+ "syn 2.0.119",1793
+]1794
+1795
+[[package]]1796
+name = "jobserver"1797
+version = "0.1.35"1798
+source = "registry+https://github.com/rust-lang/crates.io-index"1799
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"1800
+dependencies = [1801
+ "getrandom 0.4.3",1802
+ "libc",1803
+]1804
+1805
+[[package]]1806
+name = "js-sys"1807
+version = "0.3.105"1808
+source = "registry+https://github.com/rust-lang/crates.io-index"1809
+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"1810
+dependencies = [1811
+ "cfg-if",1812
+ "futures-util",1813
+ "wasm-bindgen",1814
+]1815
+1816
+[[package]]1817
+name = "js_int"1818
+version = "0.2.2"1819
+source = "registry+https://github.com/rust-lang/crates.io-index"1820
+checksum = "d937f95470b270ce8b8950207715d71aa8e153c0d44c6684d59397ed4949160a"1821
+dependencies = [1822
+ "serde",1823
+]1824
+1825
+[[package]]1826
+name = "js_option"1827
+version = "0.2.0"1828
+source = "registry+https://github.com/rust-lang/crates.io-index"1829
+checksum = "c7dd3e281add16813cf673bf74a32249b0aa0d1c8117519a17b3ada5e8552b3c"1830
+dependencies = [1831
+ "serde_core",1832
+]1833
+1834
+[[package]]1835
+name = "konst"1836
+version = "0.4.3"1837
+source = "registry+https://github.com/rust-lang/crates.io-index"1838
+checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d"1839
+dependencies = [1840
+ "const_panic",1841
+ "typewit",1842
+]1843
+1844
+[[package]]1845
+name = "language-tags"1846
+version = "0.3.2"1847
+source = "registry+https://github.com/rust-lang/crates.io-index"1848
+checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"1849
+1850
+[[package]]1851
+name = "lazy_static"1852
+version = "1.5.0"1853
+source = "registry+https://github.com/rust-lang/crates.io-index"1854
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"1855
+1856
+[[package]]1857
+name = "libc"1858
+version = "0.2.189"1859
+source = "registry+https://github.com/rust-lang/crates.io-index"1860
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"1861
+1862
+[[package]]1863
+name = "libm"1864
+version = "0.2.16"1865
+source = "registry+https://github.com/rust-lang/crates.io-index"1866
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"1867
+1868
+[[package]]1869
+name = "libsqlite3-sys"1870
+version = "0.35.0"1871
+source = "registry+https://github.com/rust-lang/crates.io-index"1872
+checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"1873
+dependencies = [1874
+ "cc",1875
+ "pkg-config",1876
+ "vcpkg",1877
+]1878
+1879
+[[package]]1880
+name = "linux-raw-sys"1881
+version = "0.12.1"1882
+source = "registry+https://github.com/rust-lang/crates.io-index"1883
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"1884
+1885
+[[package]]1886
+name = "listenfd"1887
+version = "1.0.2"1888
+source = "registry+https://github.com/rust-lang/crates.io-index"1889
+checksum = "b87bc54a4629b4294d0b3ef041b64c40c611097a677d9dc07b2c67739fe39dba"1890
+dependencies = [1891
+ "libc",1892
+ "uuid",1893
+ "winapi",1894
+]1895
+1896
+[[package]]1897
+name = "litemap"1898
+version = "0.8.3"1899
+source = "registry+https://github.com/rust-lang/crates.io-index"1900
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"1901
+1902
+[[package]]1903
+name = "lock_api"1904
+version = "0.4.14"1905
+source = "registry+https://github.com/rust-lang/crates.io-index"1906
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"1907
+dependencies = [1908
+ "scopeguard",1909
+]1910
+1911
+[[package]]1912
+name = "log"1913
+version = "0.4.34"1914
+source = "registry+https://github.com/rust-lang/crates.io-index"1915
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"1916
+1917
+[[package]]1918
+name = "loom"1919
+version = "0.7.2"1920
+source = "registry+https://github.com/rust-lang/crates.io-index"1921
+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"1922
+dependencies = [1923
+ "cfg-if",1924
+ "generator",1925
+ "scoped-tls",1926
+ "tracing",1927
+ "tracing-subscriber",1928
+]1929
+1930
+[[package]]1931
+name = "lru-slab"1932
+version = "0.1.3"1933
+source = "registry+https://github.com/rust-lang/crates.io-index"1934
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"1935
+1936
+[[package]]1937
+name = "macltok"1938
+version = "0.1.0"1939
+dependencies = [1940
+ "futures-util",1941
+ "getrandom 0.2.17",1942
+ "getrandom 0.3.4",1943
+ "getrandom 0.4.3",1944
+ "http",1945
+ "imbl",1946
+ "js-sys",1947
+ "matrix-sdk",1948
+ "matrix-sdk-crypto",1949
+ "matrix-sdk-store-encryption",1950
+ "matrix-sdk-test",1951
+ "matrix-sdk-ui",1952
+ "matrix_indexed_db_futures",1953
+ "mime",1954
+ "reqwest",1955
+ "ruma",1956
+ "serde",1957
+ "serde_json",1958
+ "sha2",1959
+ "thiserror 2.0.21",1960
+ "tokio",1961
+ "tracing",1962
+ "url",1963
+ "uuid",1964
+ "wasm-bindgen",1965
+ "wasm-bindgen-futures",1966
+ "web-sys",1967
+ "wiremock",1968
+]1969
+1970
+[[package]]1971
+name = "macroific"1972
+version = "2.0.0"1973
+source = "registry+https://github.com/rust-lang/crates.io-index"1974
+checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285"1975
+dependencies = [1976
+ "macroific_attr_parse 2.0.0",1977
+ "macroific_core 2.0.0",1978
+ "macroific_macro 2.0.0",1979
+]1980
+1981
+[[package]]1982
+name = "macroific"1983
+version = "3.1.0"1984
+source = "registry+https://github.com/rust-lang/crates.io-index"1985
+checksum = "cc8ab95c98294764c0f1eacebdb5b027849201b71fbc383871fe991a1de7eb83"1986
+dependencies = [1987
+ "macroific_attr_parse 3.1.0",1988
+ "macroific_core 3.1.0",1989
+ "macroific_macro 3.1.0",1990
+]1991
+1992
+[[package]]1993
+name = "macroific_attr_parse"1994
+version = "2.0.0"1995
+source = "registry+https://github.com/rust-lang/crates.io-index"1996
+checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9"1997
+dependencies = [1998
+ "proc-macro2",1999
+ "quote",2000
+ "sealed 0.6.0",2001
+ "syn 2.0.119",2002
+]2003
+2004
+[[package]]2005
+name = "macroific_attr_parse"2006
+version = "3.1.0"2007
+source = "registry+https://github.com/rust-lang/crates.io-index"2008
+checksum = "bf9b7bdb92825e6a507392b12f0570ade17480be20f4ca951b9071b40deba1d1"2009
+dependencies = [2010
+ "proc-macro2",2011
+ "quote",2012
+ "sealed 0.7.0",2013
+ "syn 3.0.6",2014
+]2015
+2016
+[[package]]2017
+name = "macroific_core"2018
+version = "2.0.0"2019
+source = "registry+https://github.com/rust-lang/crates.io-index"2020
+checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3"2021
+dependencies = [2022
+ "proc-macro2",2023
+ "quote",2024
+ "sealed 0.6.0",2025
+ "syn 2.0.119",2026
+]2027
+2028
+[[package]]2029
+name = "macroific_core"2030
+version = "3.1.0"2031
+source = "registry+https://github.com/rust-lang/crates.io-index"2032
+checksum = "edc880da2bc00d6d87f748805e242d670e248d1b9b6fcef3190503210b6cf056"2033
+dependencies = [2034
+ "proc-macro2",2035
+ "quote",2036
+ "sealed 0.7.0",2037
+ "syn 3.0.6",2038
+]2039
+2040
+[[package]]2041
+name = "macroific_macro"2042
+version = "2.0.0"2043
+source = "registry+https://github.com/rust-lang/crates.io-index"2044
+checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b"2045
+dependencies = [2046
+ "macroific_attr_parse 2.0.0",2047
+ "macroific_core 2.0.0",2048
+ "proc-macro2",2049
+ "quote",2050
+ "syn 2.0.119",2051
+]2052
+2053
+[[package]]2054
+name = "macroific_macro"2055
+version = "3.1.0"2056
+source = "registry+https://github.com/rust-lang/crates.io-index"2057
+checksum = "201faf668c264432727fb64e44407bcfcf1f5f8153bf4ec248eb6bbe4a39b144"2058
+dependencies = [2059
+ "macroific_attr_parse 3.1.0",2060
+ "macroific_core 3.1.0",2061
+ "proc-macro2",2062
+ "quote",2063
+ "syn 3.0.6",2064
+]2065
+2066
+[[package]]2067
+name = "malice"2068
+version = "0.1.0"2069
+dependencies = [2070
+ "axum",2071
+ "futures-util",2072
+ "hex",2073
+ "http-body-util",2074
+ "listenfd",2075
+ "macltok",2076
+ "malice-core",2077
+ "matrix-sdk",2078
+ "rand_core 0.6.4",2079
+ "reqwest",2080
+ "ruma",2081
+ "serde",2082
+ "serde_json",2083
+ "sha2",2084
+ "tempfile",2085
+ "tokio",2086
+ "tower",2087
+ "tower-http",2088
+ "tracing",2089
+ "tracing-subscriber",2090
+ "wiremock",2091
+]2092
+2093
+[[package]]2094
+name = "malice-core"2095
+version = "0.1.0"2096
+dependencies = [2097
+ "futures-util",2098
+ "http",2099
+ "macltok",2100
+ "ruma",2101
+ "serde",2102
+ "serde_json",2103
+]2104
+2105
+[[package]]2106
+name = "maplit"2107
+version = "1.0.2"2108
+source = "registry+https://github.com/rust-lang/crates.io-index"2109
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"2110
+2111
+[[package]]2112
+name = "markup5ever"2113
+version = "0.39.0"2114
+source = "registry+https://github.com/rust-lang/crates.io-index"2115
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"2116
+dependencies = [2117
+ "log",2118
+ "tendril",2119
+ "web_atoms",2120
+]2121
+2122
+[[package]]2123
+name = "matchers"2124
+version = "0.2.0"2125
+source = "registry+https://github.com/rust-lang/crates.io-index"2126
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"2127
+dependencies = [2128
+ "regex-automata",2129
+]2130
+2131
+[[package]]2132
+name = "matchit"2133
+version = "0.8.4"2134
+source = "registry+https://github.com/rust-lang/crates.io-index"2135
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"2136
+2137
+[[package]]2138
+name = "matrix-pickle"2139
+version = "0.2.3"2140
+source = "registry+https://github.com/rust-lang/crates.io-index"2141
+checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0"2142
+dependencies = [2143
+ "matrix-pickle-derive",2144
+ "thiserror 2.0.21",2145
+]2146
+2147
+[[package]]2148
+name = "matrix-pickle-derive"2149
+version = "0.2.3"2150
+source = "registry+https://github.com/rust-lang/crates.io-index"2151
+checksum = "414b5e4c34009f2bc3fe35dd018f25755ca38858096574841c7332f99e2c7e77"2152
+dependencies = [2153
+ "proc-macro-crate",2154
+ "proc-macro2",2155
+ "quote",2156
+ "syn 2.0.119",2157
+]2158
+2159
+[[package]]2160
+name = "matrix-sdk"2161
+version = "0.18.0"2162
+source = "registry+https://github.com/rust-lang/crates.io-index"2163
+checksum = "7083d580527511ac5d9369e03b9f2b20902e76949f1b3964051f978e4d3756ae"2164
+dependencies = [2165
+ "anymap2",2166
+ "aquamarine",2167
+ "as_variant",2168
+ "assert_matches2",2169
+ "async-channel",2170
+ "async-once-cell",2171
+ "async-stream",2172
+ "async-trait",2173
+ "backon",2174
+ "bytes",2175
+ "bytesize",2176
+ "cfg-if",2177
+ "event-listener",2178
+ "eyeball",2179
+ "eyeball-im",2180
+ "futures-core",2181
+ "futures-util",2182
+ "gloo-timers",2183
+ "http",2184
+ "imbl",2185
+ "indexmap",2186
+ "itertools 0.14.0",2187
+ "js_int",2188
+ "language-tags",2189
+ "matrix-sdk-base",2190
+ "matrix-sdk-common",2191
+ "matrix-sdk-indexeddb",2192
+ "matrix-sdk-sqlite",2193
+ "matrix-sdk-test",2194
+ "mime",2195
+ "mime2ext",2196
+ "oauth2",2197
+ "oauth2-reqwest",2198
+ "percent-encoding",2199
+ "pin-project-lite",2200
+ "reqwest",2201
+ "ruma",2202
+ "rustls",2203
+ "rustls-native-certs",2204
+ "rustls-pki-types",2205
+ "serde",2206
+ "serde_html_form",2207
+ "serde_json",2208
+ "sha2",2209
+ "tempfile",2210
+ "thiserror 2.0.21",2211
+ "tokio",2212
+ "tokio-stream",2213
+ "tokio-util",2214
+ "tracing",2215
+ "url",2216
+ "urlencoding",2217
+ "vodozemac",2218
+ "webpki-roots",2219
+ "wiremock",2220
+ "zeroize",2221
+]2222
+2223
+[[package]]2224
+name = "matrix-sdk-base"2225
+version = "0.18.0"2226
+source = "registry+https://github.com/rust-lang/crates.io-index"2227
+checksum = "e09a917eb1f7643d9d9a06b2f131e2ceb39df6910a7b830dac18bfd6db37a1f5"2228
+dependencies = [2229
+ "as_variant",2230
+ "assert_matches",2231
+ "assert_matches2",2232
+ "async-trait",2233
+ "bitflags",2234
+ "decancer",2235
+ "eyeball",2236
+ "eyeball-im",2237
+ "futures-util",2238
+ "growable-bloom-filter",2239
+ "http",2240
+ "matrix-sdk-common",2241
+ "matrix-sdk-crypto",2242
+ "matrix-sdk-store-encryption",2243
+ "matrix-sdk-test",2244
+ "regex",2245
+ "ruma",2246
+ "serde",2247
+ "serde_json",2248
+ "thiserror 2.0.21",2249
+ "tokio",2250
+ "tracing",2251
+ "unicode-normalization",2252
+]2253
+2254
+[[package]]2255
+name = "matrix-sdk-common"2256
+version = "0.18.0"2257
+source = "registry+https://github.com/rust-lang/crates.io-index"2258
+checksum = "2b9d1e0fee0f090180ef9457034adc547e80de9e6e3eb2e63aaac68a8476f836"2259
+dependencies = [2260
+ "eyeball-im",2261
+ "futures-core",2262
+ "futures-executor",2263
+ "futures-util",2264
+ "gloo-timers",2265
+ "imbl",2266
+ "ruma",2267
+ "serde",2268
+ "serde_json",2269
+ "thiserror 2.0.21",2270
+ "tokio",2271
+ "tracing",2272
+ "tracing-subscriber",2273
+ "wasm-bindgen",2274
+ "wasm-bindgen-futures",2275
+ "web-sys",2276
+]2277
+2278
+[[package]]2279
+name = "matrix-sdk-crypto"2280
+version = "0.18.0"2281
+source = "registry+https://github.com/rust-lang/crates.io-index"2282
+checksum = "c54afd2a326f51c13a6ad44ec86315a688fffeb3f1e287fb343e0e1836a3bdaf"2283
+dependencies = [2284
+ "aes",2285
+ "aquamarine",2286
+ "as_variant",2287
+ "async-trait",2288
+ "bs58",2289
+ "byteorder",2290
+ "cfg-if",2291
+ "ctr",2292
+ "eyeball",2293
+ "futures-core",2294
+ "futures-util",2295
+ "hkdf",2296
+ "hmac",2297
+ "itertools 0.14.0",2298
+ "js_option",2299
+ "matrix-sdk-common",2300
+ "matrix-sdk-test",2301
+ "pbkdf2",2302
+ "rand 0.10.3",2303
+ "rmp-serde",2304
+ "ruma",2305
+ "serde",2306
+ "serde_json",2307
+ "sha2",2308
+ "subtle",2309
+ "thiserror 2.0.21",2310
+ "time",2311
+ "tokio",2312
+ "tokio-stream",2313
+ "tracing",2314
+ "ulid",2315
+ "url",2316
+ "vodozemac",2317
+ "zeroize",2318
+]2319
+2320
+[[package]]2321
+name = "matrix-sdk-indexeddb"2322
+version = "0.18.0"2323
+source = "registry+https://github.com/rust-lang/crates.io-index"2324
+checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47"2325
+dependencies = [2326
+ "async-trait",2327
+ "base64 0.22.1",2328
+ "futures-util",2329
+ "getrandom 0.4.3",2330
+ "gloo-utils",2331
+ "growable-bloom-filter",2332
+ "hkdf",2333
+ "js-sys",2334
+ "matrix-sdk-base",2335
+ "matrix-sdk-crypto",2336
+ "matrix-sdk-store-encryption",2337
+ "matrix_indexed_db_futures",2338
+ "rmp-serde",2339
+ "ruma",2340
+ "serde",2341
+ "serde-wasm-bindgen",2342
+ "serde_json",2343
+ "sha2",2344
+ "thiserror 2.0.21",2345
+ "tokio",2346
+ "tracing",2347
+ "uuid",2348
+ "wasm-bindgen",2349
+ "web-sys",2350
+ "zeroize",2351
+]2352
+2353
+[[package]]2354
+name = "matrix-sdk-sqlite"2355
+version = "0.18.0"2356
+source = "registry+https://github.com/rust-lang/crates.io-index"2357
+checksum = "a49133429271005745f8d5a05362d2ba6567274777250236799927ca30dc0670"2358
+dependencies = [2359
+ "as_variant",2360
+ "async-trait",2361
+ "deadpool 0.13.1",2362
+ "deadpool-sync",2363
+ "itertools 0.14.0",2364
+ "matrix-sdk-base",2365
+ "matrix-sdk-crypto",2366
+ "matrix-sdk-store-encryption",2367
+ "num_cpus",2368
+ "rmp-serde",2369
+ "ruma",2370
+ "rusqlite",2371
+ "serde",2372
+ "serde_json",2373
+ "serde_path_to_error",2374
+ "thiserror 2.0.21",2375
+ "tokio",2376
+ "tracing",2377
+ "vodozemac",2378
+ "zeroize",2379
+]2380
+2381
+[[package]]2382
+name = "matrix-sdk-store-encryption"2383
+version = "0.18.0"2384
+source = "registry+https://github.com/rust-lang/crates.io-index"2385
+checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e"2386
+dependencies = [2387
+ "base64 0.22.1",2388
+ "blake3",2389
+ "chacha20poly1305",2390
+ "getrandom 0.2.17",2391
+ "getrandom 0.4.3",2392
+ "hmac",2393
+ "pbkdf2",2394
+ "rand 0.10.3",2395
+ "rmp-serde",2396
+ "serde",2397
+ "serde_json",2398
+ "sha2",2399
+ "thiserror 2.0.21",2400
+ "zeroize",2401
+]2402
+2403
+[[package]]2404
+name = "matrix-sdk-test"2405
+version = "0.18.0"2406
+source = "registry+https://github.com/rust-lang/crates.io-index"2407
+checksum = "8023d4315fff35598c2d4a955920c1273c6aaab4dcd9ed405238a882581649bf"2408
+dependencies = [2409
+ "as_variant",2410
+ "ctor",2411
+ "getrandom 0.4.3",2412
+ "http",2413
+ "insta",2414
+ "matrix-sdk-common",2415
+ "matrix-sdk-test-macros",2416
+ "ruma",2417
+ "serde",2418
+ "serde_json",2419
+ "sha2",2420
+ "tokio",2421
+ "tracing-subscriber",2422
+ "vodozemac",2423
+ "wasm-bindgen-test",2424
+ "wiremock",2425
+]2426
+2427
+[[package]]2428
+name = "matrix-sdk-test-macros"2429
+version = "0.18.0"2430
+source = "registry+https://github.com/rust-lang/crates.io-index"2431
+checksum = "d97d6a0e5b1401c530b3c5d18cb98257d255d396bacf3903beda6718a2f4c4c5"2432
+dependencies = [2433
+ "quote",2434
+ "syn 2.0.119",2435
+]2436
+2437
+[[package]]2438
+name = "matrix-sdk-ui"2439
+version = "0.18.0"2440
+source = "registry+https://github.com/rust-lang/crates.io-index"2441
+checksum = "6d1f2b8a8cf57164d7cee4eb61c0b9f074b2e11d766a9391b7db0b9f2cbd83d8"2442
+dependencies = [2443
+ "as_variant",2444
+ "async-rx",2445
+ "async-stream",2446
+ "async_cell",2447
+ "bitflags",2448
+ "chrono",2449
+ "emojis",2450
+ "eyeball",2451
+ "eyeball-im",2452
+ "eyeball-im-util",2453
+ "futures-core",2454
+ "futures-util",2455
+ "fuzzy-matcher",2456
+ "growable-bloom-filter",2457
+ "imbl",2458
+ "indexmap",2459
+ "itertools 0.14.0",2460
+ "matrix-sdk",2461
+ "matrix-sdk-base",2462
+ "matrix-sdk-common",2463
+ "mime",2464
+ "pin-project-lite",2465
+ "ruma",2466
+ "serde",2467
+ "serde_json",2468
+ "thiserror 2.0.21",2469
+ "tokio",2470
+ "tokio-stream",2471
+ "tracing",2472
+ "unicode-normalization",2473
+ "unicode-segmentation",2474
+]2475
+2476
+[[package]]2477
+name = "matrix_indexed_db_futures"2478
+version = "0.7.0"2479
+source = "registry+https://github.com/rust-lang/crates.io-index"2480
+checksum = "245ff6a224b4df7b0c90dda2dd5a6eb46112708d49e8bdd8b007fccb09fea8e4"2481
+dependencies = [2482
+ "accessory",2483
+ "cfg-if",2484
+ "delegate-display",2485
+ "derive_more 2.1.1",2486
+ "fancy_constructor",2487
+ "futures-core",2488
+ "js-sys",2489
+ "matrix_indexed_db_futures_macros_internal",2490
+ "sealed 0.6.0",2491
+ "serde",2492
+ "serde-wasm-bindgen",2493
+ "smallvec",2494
+ "thiserror 2.0.21",2495
+ "tokio",2496
+ "wasm-bindgen",2497
+ "wasm-bindgen-futures",2498
+ "wasm_evt_listener",2499
+ "web-sys",2500
+ "web-time",2501
+]2502
+2503
+[[package]]2504
+name = "matrix_indexed_db_futures_macros_internal"2505
+version = "1.0.0"2506
+source = "registry+https://github.com/rust-lang/crates.io-index"2507
+checksum = "b428aee5c0fe9e5babd29e99d289b7f64718c444989aac0442d1fd6d3e3f66d1"2508
+dependencies = [2509
+ "macroific 2.0.0",2510
+ "proc-macro2",2511
+ "quote",2512
+ "syn 2.0.119",2513
+]2514
+2515
+[[package]]2516
+name = "memchr"2517
+version = "2.8.3"2518
+source = "registry+https://github.com/rust-lang/crates.io-index"2519
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"2520
+2521
+[[package]]2522
+name = "mime"2523
+version = "0.3.17"2524
+source = "registry+https://github.com/rust-lang/crates.io-index"2525
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"2526
+2527
+[[package]]2528
+name = "mime2ext"2529
+version = "0.1.54"2530
+source = "registry+https://github.com/rust-lang/crates.io-index"2531
+checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc"2532
+2533
+[[package]]2534
+name = "mime_guess"2535
+version = "2.0.5"2536
+source = "registry+https://github.com/rust-lang/crates.io-index"2537
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"2538
+dependencies = [2539
+ "mime",2540
+ "unicase",2541
+]2542
+2543
+[[package]]2544
+name = "minicov"2545
+version = "0.3.8"2546
+source = "registry+https://github.com/rust-lang/crates.io-index"2547
+checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"2548
+dependencies = [2549
+ "cc",2550
+ "walkdir",2551
+]2552
+2553
+[[package]]2554
+name = "miniz_oxide"2555
+version = "0.9.1"2556
+source = "registry+https://github.com/rust-lang/crates.io-index"2557
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"2558
+dependencies = [2559
+ "adler2",2560
+ "simd-adler32",2561
+]2562
+2563
+[[package]]2564
+name = "mio"2565
+version = "1.2.3"2566
+source = "registry+https://github.com/rust-lang/crates.io-index"2567
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"2568
+dependencies = [2569
+ "libc",2570
+ "wasi",2571
+ "windows-sys 0.61.2",2572
+]2573
+2574
+[[package]]2575
+name = "multer"2576
+version = "3.1.0"2577
+source = "registry+https://github.com/rust-lang/crates.io-index"2578
+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"2579
+dependencies = [2580
+ "bytes",2581
+ "encoding_rs",2582
+ "futures-util",2583
+ "http",2584
+ "httparse",2585
+ "memchr",2586
+ "mime",2587
+ "spin",2588
+ "version_check",2589
+]2590
+2591
+[[package]]2592
+name = "multiversion"2593
+version = "0.9.0"2594
+source = "registry+https://github.com/rust-lang/crates.io-index"2595
+checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c"2596
+dependencies = [2597
+ "multiversion-macros",2598
+]2599
+2600
+[[package]]2601
+name = "multiversion-macros"2602
+version = "0.9.0"2603
+source = "registry+https://github.com/rust-lang/crates.io-index"2604
+checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83"2605
+dependencies = [2606
+ "proc-macro2",2607
+ "quote",2608
+ "rustversion",2609
+ "syn 3.0.6",2610
+]2611
+2612
+[[package]]2613
+name = "multiversion_no_op"2614
+version = "1.0.0"2615
+source = "registry+https://github.com/rust-lang/crates.io-index"2616
+checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"2617
+2618
+[[package]]2619
+name = "new_debug_unreachable"2620
+version = "1.0.6"2621
+source = "registry+https://github.com/rust-lang/crates.io-index"2622
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"2623
+2624
+[[package]]2625
+name = "nu-ansi-term"2626
+version = "0.50.3"2627
+source = "registry+https://github.com/rust-lang/crates.io-index"2628
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"2629
+dependencies = [2630
+ "windows-sys 0.61.2",2631
+]2632
+2633
+[[package]]2634
+name = "num-conv"2635
+version = "0.2.2"2636
+source = "registry+https://github.com/rust-lang/crates.io-index"2637
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"2638
+2639
+[[package]]2640
+name = "num-traits"2641
+version = "0.2.19"2642
+source = "registry+https://github.com/rust-lang/crates.io-index"2643
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"2644
+dependencies = [2645
+ "autocfg",2646
+ "libm",2647
+]2648
+2649
+[[package]]2650
+name = "num_cpus"2651
+version = "1.17.0"2652
+source = "registry+https://github.com/rust-lang/crates.io-index"2653
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"2654
+dependencies = [2655
+ "hermit-abi",2656
+ "libc",2657
+]2658
+2659
+[[package]]2660
+name = "oauth2"2661
+version = "5.0.0"2662
+source = "registry+https://github.com/rust-lang/crates.io-index"2663
+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"2664
+dependencies = [2665
+ "base64 0.22.1",2666
+ "chrono",2667
+ "getrandom 0.2.17",2668
+ "http",2669
+ "rand 0.8.8",2670
+ "serde",2671
+ "serde_json",2672
+ "serde_path_to_error",2673
+ "sha2",2674
+ "thiserror 1.0.69",2675
+ "url",2676
+]2677
+2678
+[[package]]2679
+name = "oauth2-reqwest"2680
+version = "0.1.0-alpha.3"2681
+source = "registry+https://github.com/rust-lang/crates.io-index"2682
+checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2"2683
+dependencies = [2684
+ "oauth2",2685
+ "reqwest",2686
+]2687
+2688
+[[package]]2689
+name = "once_cell"2690
+version = "1.21.4"2691
+source = "registry+https://github.com/rust-lang/crates.io-index"2692
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"2693
+2694
+[[package]]2695
+name = "oorandom"2696
+version = "11.1.5"2697
+source = "registry+https://github.com/rust-lang/crates.io-index"2698
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"2699
+2700
+[[package]]2701
+name = "opaque-debug"2702
+version = "0.3.1"2703
+source = "registry+https://github.com/rust-lang/crates.io-index"2704
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"2705
+2706
+[[package]]2707
+name = "openssl-probe"2708
+version = "0.2.1"2709
+source = "registry+https://github.com/rust-lang/crates.io-index"2710
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"2711
+2712
+[[package]]2713
+name = "parking"2714
+version = "2.2.1"2715
+source = "registry+https://github.com/rust-lang/crates.io-index"2716
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"2717
+2718
+[[package]]2719
+name = "parking_lot"2720
+version = "0.12.5"2721
+source = "registry+https://github.com/rust-lang/crates.io-index"2722
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"2723
+dependencies = [2724
+ "lock_api",2725
+ "parking_lot_core",2726
+]2727
+2728
+[[package]]2729
+name = "parking_lot_core"2730
+version = "0.9.12"2731
+source = "registry+https://github.com/rust-lang/crates.io-index"2732
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"2733
+dependencies = [2734
+ "cfg-if",2735
+ "libc",2736
+ "redox_syscall",2737
+ "smallvec",2738
+ "windows-link",2739
+]2740
+2741
+[[package]]2742
+name = "pbkdf2"2743
+version = "0.12.2"2744
+source = "registry+https://github.com/rust-lang/crates.io-index"2745
+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"2746
+dependencies = [2747
+ "digest",2748
+]2749
+2750
+[[package]]2751
+name = "percent-encoding"2752
+version = "2.3.2"2753
+source = "registry+https://github.com/rust-lang/crates.io-index"2754
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"2755
+2756
+[[package]]2757
+name = "pest"2758
+version = "2.9.2"2759
+source = "registry+https://github.com/rust-lang/crates.io-index"2760
+checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"2761
+dependencies = [2762
+ "memchr",2763
+ "ucd-trie",2764
+]2765
+2766
+[[package]]2767
+name = "pest_derive"2768
+version = "2.9.2"2769
+source = "registry+https://github.com/rust-lang/crates.io-index"2770
+checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"2771
+dependencies = [2772
+ "pest",2773
+ "pest_generator",2774
+]2775
+2776
+[[package]]2777
+name = "pest_generator"2778
+version = "2.9.2"2779
+source = "registry+https://github.com/rust-lang/crates.io-index"2780
+checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"2781
+dependencies = [2782
+ "pest",2783
+ "pest_meta",2784
+ "proc-macro2",2785
+ "quote",2786
+ "syn 2.0.119",2787
+]2788
+2789
+[[package]]2790
+name = "pest_meta"2791
+version = "2.9.2"2792
+source = "registry+https://github.com/rust-lang/crates.io-index"2793
+checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"2794
+dependencies = [2795
+ "pest",2796
+]2797
+2798
+[[package]]2799
+name = "phf"2800
+version = "0.13.1"2801
+source = "registry+https://github.com/rust-lang/crates.io-index"2802
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"2803
+dependencies = [2804
+ "phf_shared",2805
+ "serde",2806
+]2807
+2808
+[[package]]2809
+name = "phf_codegen"2810
+version = "0.13.1"2811
+source = "registry+https://github.com/rust-lang/crates.io-index"2812
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"2813
+dependencies = [2814
+ "phf_generator",2815
+ "phf_shared",2816
+]2817
+2818
+[[package]]2819
+name = "phf_generator"2820
+version = "0.13.1"2821
+source = "registry+https://github.com/rust-lang/crates.io-index"2822
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"2823
+dependencies = [2824
+ "fastrand",2825
+ "phf_shared",2826
+]2827
+2828
+[[package]]2829
+name = "phf_shared"2830
+version = "0.13.1"2831
+source = "registry+https://github.com/rust-lang/crates.io-index"2832
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"2833
+dependencies = [2834
+ "siphasher",2835
+]2836
+2837
+[[package]]2838
+name = "pin-project-lite"2839
+version = "0.2.17"2840
+source = "registry+https://github.com/rust-lang/crates.io-index"2841
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"2842
+2843
+[[package]]2844
+name = "pkcs8"2845
+version = "0.10.2"2846
+source = "registry+https://github.com/rust-lang/crates.io-index"2847
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"2848
+dependencies = [2849
+ "der",2850
+ "spki",2851
+]2852
+2853
+[[package]]2854
+name = "pkg-config"2855
+version = "0.3.34"2856
+source = "registry+https://github.com/rust-lang/crates.io-index"2857
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"2858
+2859
+[[package]]2860
+name = "poly1305"2861
+version = "0.8.0"2862
+source = "registry+https://github.com/rust-lang/crates.io-index"2863
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"2864
+dependencies = [2865
+ "cpufeatures 0.2.17",2866
+ "opaque-debug",2867
+ "universal-hash",2868
+]2869
+2870
+[[package]]2871
+name = "potential_utf"2872
+version = "0.1.6"2873
+source = "registry+https://github.com/rust-lang/crates.io-index"2874
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"2875
+dependencies = [2876
+ "zerovec",2877
+]2878
+2879
+[[package]]2880
+name = "powerfmt"2881
+version = "0.2.0"2882
+source = "registry+https://github.com/rust-lang/crates.io-index"2883
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"2884
+2885
+[[package]]2886
+name = "ppv-lite86"2887
+version = "0.2.21"2888
+source = "registry+https://github.com/rust-lang/crates.io-index"2889
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"2890
+dependencies = [2891
+ "zerocopy",2892
+]2893
+2894
+[[package]]2895
+name = "precomputed-hash"2896
+version = "0.1.1"2897
+source = "registry+https://github.com/rust-lang/crates.io-index"2898
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"2899
+2900
+[[package]]2901
+name = "proc-macro-crate"2902
+version = "3.5.0"2903
+source = "registry+https://github.com/rust-lang/crates.io-index"2904
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"2905
+dependencies = [2906
+ "toml_edit",2907
+]2908
+2909
+[[package]]2910
+name = "proc-macro-error-attr2"2911
+version = "2.0.0"2912
+source = "registry+https://github.com/rust-lang/crates.io-index"2913
+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"2914
+dependencies = [2915
+ "proc-macro2",2916
+ "quote",2917
+]2918
+2919
+[[package]]2920
+name = "proc-macro-error2"2921
+version = "2.0.1"2922
+source = "registry+https://github.com/rust-lang/crates.io-index"2923
+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"2924
+dependencies = [2925
+ "proc-macro-error-attr2",2926
+ "proc-macro2",2927
+ "quote",2928
+]2929
+2930
+[[package]]2931
+name = "proc-macro2"2932
+version = "1.0.107"2933
+source = "registry+https://github.com/rust-lang/crates.io-index"2934
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"2935
+dependencies = [2936
+ "unicode-ident",2937
+]2938
+2939
+[[package]]2940
+name = "prost"2941
+version = "0.14.4"2942
+source = "registry+https://github.com/rust-lang/crates.io-index"2943
+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"2944
+dependencies = [2945
+ "bytes",2946
+ "prost-derive",2947
+]2948
+2949
+[[package]]2950
+name = "prost-derive"2951
+version = "0.14.4"2952
+source = "registry+https://github.com/rust-lang/crates.io-index"2953
+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"2954
+dependencies = [2955
+ "anyhow",2956
+ "itertools 0.14.0",2957
+ "proc-macro2",2958
+ "quote",2959
+ "syn 2.0.119",2960
+]2961
+2962
+[[package]]2963
+name = "quinn"2964
+version = "0.11.12"2965
+source = "registry+https://github.com/rust-lang/crates.io-index"2966
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"2967
+dependencies = [2968
+ "bytes",2969
+ "cfg_aliases",2970
+ "pin-project-lite",2971
+ "quinn-proto",2972
+ "quinn-udp",2973
+ "rustc-hash",2974
+ "rustls",2975
+ "socket2",2976
+ "thiserror 2.0.21",2977
+ "tokio",2978
+ "tracing",2979
+ "web-time",2980
+]2981
+2982
+[[package]]2983
+name = "quinn-proto"2984
+version = "0.11.18"2985
+source = "registry+https://github.com/rust-lang/crates.io-index"2986
+checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"2987
+dependencies = [2988
+ "aws-lc-rs",2989
+ "bytes",2990
+ "getrandom 0.4.3",2991
+ "lru-slab",2992
+ "rand 0.10.3",2993
+ "rand_pcg",2994
+ "ring",2995
+ "rustc-hash",2996
+ "rustls",2997
+ "rustls-pki-types",2998
+ "slab",2999
+ "thiserror 2.0.21",3000
+ "tinyvec",3001
+ "tracing",3002
+ "web-time",3003
+]3004
+3005
+[[package]]3006
+name = "quinn-udp"3007
+version = "0.5.15"3008
+source = "registry+https://github.com/rust-lang/crates.io-index"3009
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"3010
+dependencies = [3011
+ "cfg_aliases",3012
+ "libc",3013
+ "once_cell",3014
+ "socket2",3015
+ "tracing",3016
+ "windows-sys 0.61.2",3017
+]3018
+3019
+[[package]]3020
+name = "quote"3021
+version = "1.0.47"3022
+source = "registry+https://github.com/rust-lang/crates.io-index"3023
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"3024
+dependencies = [3025
+ "proc-macro2",3026
+]3027
+3028
+[[package]]3029
+name = "r-efi"3030
+version = "5.3.0"3031
+source = "registry+https://github.com/rust-lang/crates.io-index"3032
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"3033
+3034
+[[package]]3035
+name = "r-efi"3036
+version = "6.0.0"3037
+source = "registry+https://github.com/rust-lang/crates.io-index"3038
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"3039
+3040
+[[package]]3041
+name = "rand"3042
+version = "0.8.8"3043
+source = "registry+https://github.com/rust-lang/crates.io-index"3044
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"3045
+dependencies = [3046
+ "libc",3047
+ "rand_chacha 0.3.1",3048
+ "rand_core 0.6.4",3049
+]3050
+3051
+[[package]]3052
+name = "rand"3053
+version = "0.9.5"3054
+source = "registry+https://github.com/rust-lang/crates.io-index"3055
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"3056
+dependencies = [3057
+ "rand_chacha 0.9.0",3058
+ "rand_core 0.9.5",3059
+]3060
+3061
+[[package]]3062
+name = "rand"3063
+version = "0.10.3"3064
+source = "registry+https://github.com/rust-lang/crates.io-index"3065
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"3066
+dependencies = [3067
+ "chacha20 0.10.2",3068
+ "getrandom 0.4.3",3069
+ "rand_core 0.10.1",3070
+]3071
+3072
+[[package]]3073
+name = "rand_chacha"3074
+version = "0.3.1"3075
+source = "registry+https://github.com/rust-lang/crates.io-index"3076
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"3077
+dependencies = [3078
+ "ppv-lite86",3079
+ "rand_core 0.6.4",3080
+]3081
+3082
+[[package]]3083
+name = "rand_chacha"3084
+version = "0.9.0"3085
+source = "registry+https://github.com/rust-lang/crates.io-index"3086
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"3087
+dependencies = [3088
+ "ppv-lite86",3089
+ "rand_core 0.9.5",3090
+]3091
+3092
+[[package]]3093
+name = "rand_core"3094
+version = "0.6.4"3095
+source = "registry+https://github.com/rust-lang/crates.io-index"3096
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"3097
+dependencies = [3098
+ "getrandom 0.2.17",3099
+]3100
+3101
+[[package]]3102
+name = "rand_core"3103
+version = "0.9.5"3104
+source = "registry+https://github.com/rust-lang/crates.io-index"3105
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"3106
+dependencies = [3107
+ "getrandom 0.3.4",3108
+]3109
+3110
+[[package]]3111
+name = "rand_core"3112
+version = "0.10.1"3113
+source = "registry+https://github.com/rust-lang/crates.io-index"3114
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"3115
+3116
+[[package]]3117
+name = "rand_pcg"3118
+version = "0.10.2"3119
+source = "registry+https://github.com/rust-lang/crates.io-index"3120
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"3121
+dependencies = [3122
+ "rand_core 0.10.1",3123
+]3124
+3125
+[[package]]3126
+name = "rand_xoshiro"3127
+version = "0.7.0"3128
+source = "registry+https://github.com/rust-lang/crates.io-index"3129
+checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"3130
+dependencies = [3131
+ "rand_core 0.9.5",3132
+]3133
+3134
+[[package]]3135
+name = "readlock"3136
+version = "0.1.11"3137
+source = "registry+https://github.com/rust-lang/crates.io-index"3138
+checksum = "6da6f291b23556edd9edaf655a0be2ad8ef8002ff5f1bca62b264f3f58b53f34"3139
+3140
+[[package]]3141
+name = "readlock-tokio"3142
+version = "0.1.6"3143
+source = "registry+https://github.com/rust-lang/crates.io-index"3144
+checksum = "fc7e264f9ec4f3d112e8e2f214e8e7cb5cf3b83278f3570b7e00bfe13d3bd8ff"3145
+dependencies = [3146
+ "tokio",3147
+]3148
+3149
+[[package]]3150
+name = "redox_syscall"3151
+version = "0.5.18"3152
+source = "registry+https://github.com/rust-lang/crates.io-index"3153
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"3154
+dependencies = [3155
+ "bitflags",3156
+]3157
+3158
+[[package]]3159
+name = "regex"3160
+version = "1.13.1"3161
+source = "registry+https://github.com/rust-lang/crates.io-index"3162
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"3163
+dependencies = [3164
+ "aho-corasick",3165
+ "memchr",3166
+ "regex-automata",3167
+ "regex-syntax",3168
+]3169
+3170
+[[package]]3171
+name = "regex-automata"3172
+version = "0.4.18"3173
+source = "registry+https://github.com/rust-lang/crates.io-index"3174
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"3175
+dependencies = [3176
+ "aho-corasick",3177
+ "memchr",3178
+ "regex-syntax",3179
+]3180
+3181
+[[package]]3182
+name = "regex-syntax"3183
+version = "0.8.11"3184
+source = "registry+https://github.com/rust-lang/crates.io-index"3185
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"3186
+3187
+[[package]]3188
+name = "reqwest"3189
+version = "0.13.5"3190
+source = "registry+https://github.com/rust-lang/crates.io-index"3191
+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"3192
+dependencies = [3193
+ "base64 0.23.1",3194
+ "bytes",3195
+ "futures-core",3196
+ "futures-util",3197
+ "h2",3198
+ "http",3199
+ "http-body",3200
+ "http-body-util",3201
+ "hyper",3202
+ "hyper-rustls",3203
+ "hyper-util",3204
+ "js-sys",3205
+ "log",3206
+ "percent-encoding",3207
+ "pin-project-lite",3208
+ "quinn",3209
+ "rustls",3210
+ "rustls-pki-types",3211
+ "rustls-platform-verifier",3212
+ "serde",3213
+ "serde_json",3214
+ "serde_urlencoded",3215
+ "sync_wrapper",3216
+ "tokio",3217
+ "tokio-rustls",3218
+ "tokio-util",3219
+ "tower",3220
+ "tower-http",3221
+ "tower-service",3222
+ "url",3223
+ "wasm-bindgen",3224
+ "wasm-bindgen-futures",3225
+ "wasm-streams",3226
+ "web-sys",3227
+]3228
+3229
+[[package]]3230
+name = "ring"3231
+version = "0.17.14"3232
+source = "registry+https://github.com/rust-lang/crates.io-index"3233
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"3234
+dependencies = [3235
+ "cc",3236
+ "cfg-if",3237
+ "getrandom 0.2.17",3238
+ "libc",3239
+ "untrusted",3240
+ "windows-sys 0.52.0",3241
+]3242
+3243
+[[package]]3244
+name = "rmp"3245
+version = "0.8.15"3246
+source = "registry+https://github.com/rust-lang/crates.io-index"3247
+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"3248
+dependencies = [3249
+ "num-traits",3250
+]3251
+3252
+[[package]]3253
+name = "rmp-serde"3254
+version = "1.3.1"3255
+source = "registry+https://github.com/rust-lang/crates.io-index"3256
+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"3257
+dependencies = [3258
+ "rmp",3259
+ "serde",3260
+]3261
+3262
+[[package]]3263
+name = "ruma"3264
+version = "0.16.0"3265
+source = "registry+https://github.com/rust-lang/crates.io-index"3266
+checksum = "ee4fe5bfacdb0e95e733da3b6c37d98edf46447a4a8e8dea824e0da266d8ad59"3267
+dependencies = [3268
+ "assign",3269
+ "js_int",3270
+ "js_option",3271
+ "ruma-client-api",3272
+ "ruma-common",3273
+ "ruma-events",3274
+ "ruma-html",3275
+ "web-time",3276
+]3277
+3278
+[[package]]3279
+name = "ruma-client-api"3280
+version = "0.24.0"3281
+source = "registry+https://github.com/rust-lang/crates.io-index"3282
+checksum = "bf7ca43a888ca569168d7e3901f4dd14a777b860bb19f4c08e35414162eb261c"3283
+dependencies = [3284
+ "as_variant",3285
+ "assign",3286
+ "bytes",3287
+ "http",3288
+ "js_int",3289
+ "js_option",3290
+ "maplit",3291
+ "ruma-common",3292
+ "ruma-events",3293
+ "serde",3294
+ "serde_html_form",3295
+ "serde_json",3296
+ "thiserror 2.0.21",3297
+ "url",3298
+ "web-time",3299
+]3300
+3301
+[[package]]3302
+name = "ruma-common"3303
+version = "0.19.0"3304
+source = "registry+https://github.com/rust-lang/crates.io-index"3305
+checksum = "2c3b4f00112791b490acce57df1ce3eb3f88899b045bebcff8a29f75369640cc"3306
+dependencies = [3307
+ "as_variant",3308
+ "base64 0.22.1",3309
+ "bytes",3310
+ "date_header",3311
+ "form_urlencoded",3312
+ "getrandom 0.4.3",3313
+ "http",3314
+ "indexmap",3315
+ "js_int",3316
+ "konst",3317
+ "percent-encoding",3318
+ "rand 0.10.3",3319
+ "regex",3320
+ "ruma-identifiers-validation",3321
+ "ruma-macros",3322
+ "serde",3323
+ "serde_html_form",3324
+ "serde_json",3325
+ "thiserror 2.0.21",3326
+ "time",3327
+ "tracing",3328
+ "url",3329
+ "uuid",3330
+ "web-time",3331
+ "wildmatch",3332
+ "zeroize",3333
+]3334
+3335
+[[package]]3336
+name = "ruma-events"3337
+version = "0.34.0"3338
+source = "registry+https://github.com/rust-lang/crates.io-index"3339
+checksum = "85d2f90830fc131691349b96a69ff53444eb6c3e8dc7869c77961b43cfaf3344"3340
+dependencies = [3341
+ "as_variant",3342
+ "indexmap",3343
+ "js_int",3344
+ "js_option",3345
+ "ruma-common",3346
+ "ruma-html",3347
+ "ruma-macros",3348
+ "serde",3349
+ "serde_json",3350
+ "thiserror 2.0.21",3351
+ "tracing",3352
+ "web-time",3353
+ "wildmatch",3354
+ "zeroize",3355
+]3356
+3357
+[[package]]3358
+name = "ruma-html"3359
+version = "0.8.0"3360
+source = "registry+https://github.com/rust-lang/crates.io-index"3361
+checksum = "48d33a944650f4bbd2188dd204d39dd87a9a1498f14b3a13252910c90ed7cd43"3362
+dependencies = [3363
+ "as_variant",3364
+ "html5ever",3365
+ "tracing",3366
+ "wildmatch",3367
+]3368
+3369
+[[package]]3370
+name = "ruma-identifiers-validation"3371
+version = "0.12.1"3372
+source = "registry+https://github.com/rust-lang/crates.io-index"3373
+checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432"3374
+dependencies = [3375
+ "js_int",3376
+ "thiserror 2.0.21",3377
+]3378
+3379
+[[package]]3380
+name = "ruma-macros"3381
+version = "0.19.0"3382
+source = "registry+https://github.com/rust-lang/crates.io-index"3383
+checksum = "c8cfb39eaa9b9fd389126ff941e060b496add5cbbfef559d80c46e571dda459c"3384
+dependencies = [3385
+ "as_variant",3386
+ "cfg-if",3387
+ "proc-macro-crate",3388
+ "proc-macro2",3389
+ "quote",3390
+ "ruma-identifiers-validation",3391
+ "serde",3392
+ "syn 2.0.119",3393
+ "toml",3394
+]3395
+3396
+[[package]]3397
+name = "rusqlite"3398
+version = "0.37.0"3399
+source = "registry+https://github.com/rust-lang/crates.io-index"3400
+checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"3401
+dependencies = [3402
+ "bitflags",3403
+ "fallible-iterator",3404
+ "fallible-streaming-iterator",3405
+ "hashlink",3406
+ "libsqlite3-sys",3407
+ "smallvec",3408
+]3409
+3410
+[[package]]3411
+name = "rustc-hash"3412
+version = "2.1.3"3413
+source = "registry+https://github.com/rust-lang/crates.io-index"3414
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"3415
+3416
+[[package]]3417
+name = "rustc_version"3418
+version = "0.4.1"3419
+source = "registry+https://github.com/rust-lang/crates.io-index"3420
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"3421
+dependencies = [3422
+ "semver",3423
+]3424
+3425
+[[package]]3426
+name = "rustix"3427
+version = "1.1.5"3428
+source = "registry+https://github.com/rust-lang/crates.io-index"3429
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"3430
+dependencies = [3431
+ "bitflags",3432
+ "errno",3433
+ "libc",3434
+ "linux-raw-sys",3435
+ "windows-sys 0.61.2",3436
+]3437
+3438
+[[package]]3439
+name = "rustls"3440
+version = "0.23.45"3441
+source = "registry+https://github.com/rust-lang/crates.io-index"3442
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"3443
+dependencies = [3444
+ "aws-lc-rs",3445
+ "log",3446
+ "once_cell",3447
+ "rustls-pki-types",3448
+ "rustls-webpki",3449
+ "subtle",3450
+ "zeroize",3451
+]3452
+3453
+[[package]]3454
+name = "rustls-native-certs"3455
+version = "0.8.4"3456
+source = "registry+https://github.com/rust-lang/crates.io-index"3457
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"3458
+dependencies = [3459
+ "openssl-probe",3460
+ "rustls-pki-types",3461
+ "schannel",3462
+ "security-framework",3463
+]3464
+3465
+[[package]]3466
+name = "rustls-pki-types"3467
+version = "1.15.1"3468
+source = "registry+https://github.com/rust-lang/crates.io-index"3469
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"3470
+dependencies = [3471
+ "web-time",3472
+ "zeroize",3473
+]3474
+3475
+[[package]]3476
+name = "rustls-platform-verifier"3477
+version = "0.7.0"3478
+source = "registry+https://github.com/rust-lang/crates.io-index"3479
+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"3480
+dependencies = [3481
+ "core-foundation",3482
+ "core-foundation-sys",3483
+ "jni",3484
+ "log",3485
+ "once_cell",3486
+ "rustls",3487
+ "rustls-native-certs",3488
+ "rustls-platform-verifier-android",3489
+ "rustls-webpki",3490
+ "security-framework",3491
+ "security-framework-sys",3492
+ "webpki-root-certs",3493
+ "windows-sys 0.61.2",3494
+]3495
+3496
+[[package]]3497
+name = "rustls-platform-verifier-android"3498
+version = "0.1.1"3499
+source = "registry+https://github.com/rust-lang/crates.io-index"3500
+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"3501
+3502
+[[package]]3503
+name = "rustls-webpki"3504
+version = "0.103.15"3505
+source = "registry+https://github.com/rust-lang/crates.io-index"3506
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"3507
+dependencies = [3508
+ "aws-lc-rs",3509
+ "ring",3510
+ "rustls-pki-types",3511
+ "untrusted",3512
+]3513
+3514
+[[package]]3515
+name = "rustversion"3516
+version = "1.0.23"3517
+source = "registry+https://github.com/rust-lang/crates.io-index"3518
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"3519
+3520
+[[package]]3521
+name = "ryu"3522
+version = "1.0.23"3523
+source = "registry+https://github.com/rust-lang/crates.io-index"3524
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"3525
+3526
+[[package]]3527
+name = "same-file"3528
+version = "1.0.6"3529
+source = "registry+https://github.com/rust-lang/crates.io-index"3530
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"3531
+dependencies = [3532
+ "winapi-util",3533
+]3534
+3535
+[[package]]3536
+name = "schannel"3537
+version = "0.1.29"3538
+source = "registry+https://github.com/rust-lang/crates.io-index"3539
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"3540
+dependencies = [3541
+ "windows-sys 0.61.2",3542
+]3543
+3544
+[[package]]3545
+name = "scoped-tls"3546
+version = "1.0.1"3547
+source = "registry+https://github.com/rust-lang/crates.io-index"3548
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"3549
+3550
+[[package]]3551
+name = "scopeguard"3552
+version = "1.2.0"3553
+source = "registry+https://github.com/rust-lang/crates.io-index"3554
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"3555
+3556
+[[package]]3557
+name = "sealed"3558
+version = "0.6.0"3559
+source = "registry+https://github.com/rust-lang/crates.io-index"3560
+checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"3561
+dependencies = [3562
+ "proc-macro2",3563
+ "quote",3564
+ "syn 2.0.119",3565
+]3566
+3567
+[[package]]3568
+name = "sealed"3569
+version = "0.7.0"3570
+source = "registry+https://github.com/rust-lang/crates.io-index"3571
+checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"3572
+dependencies = [3573
+ "proc-macro2",3574
+ "quote",3575
+ "syn 3.0.6",3576
+]3577
+3578
+[[package]]3579
+name = "security-framework"3580
+version = "3.7.0"3581
+source = "registry+https://github.com/rust-lang/crates.io-index"3582
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"3583
+dependencies = [3584
+ "bitflags",3585
+ "core-foundation",3586
+ "core-foundation-sys",3587
+ "libc",3588
+ "security-framework-sys",3589
+]3590
+3591
+[[package]]3592
+name = "security-framework-sys"3593
+version = "2.17.0"3594
+source = "registry+https://github.com/rust-lang/crates.io-index"3595
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"3596
+dependencies = [3597
+ "core-foundation-sys",3598
+ "libc",3599
+]3600
+3601
+[[package]]3602
+name = "semver"3603
+version = "1.0.28"3604
+source = "registry+https://github.com/rust-lang/crates.io-index"3605
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"3606
+3607
+[[package]]3608
+name = "serde"3609
+version = "1.0.229"3610
+source = "registry+https://github.com/rust-lang/crates.io-index"3611
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"3612
+dependencies = [3613
+ "serde_core",3614
+ "serde_derive",3615
+]3616
+3617
+[[package]]3618
+name = "serde-wasm-bindgen"3619
+version = "0.6.5"3620
+source = "registry+https://github.com/rust-lang/crates.io-index"3621
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"3622
+dependencies = [3623
+ "js-sys",3624
+ "serde",3625
+ "wasm-bindgen",3626
+]3627
+3628
+[[package]]3629
+name = "serde_bytes"3630
+version = "0.11.19"3631
+source = "registry+https://github.com/rust-lang/crates.io-index"3632
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"3633
+dependencies = [3634
+ "serde",3635
+ "serde_core",3636
+]3637
+3638
+[[package]]3639
+name = "serde_core"3640
+version = "1.0.229"3641
+source = "registry+https://github.com/rust-lang/crates.io-index"3642
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"3643
+dependencies = [3644
+ "serde_derive",3645
+]3646
+3647
+[[package]]3648
+name = "serde_derive"3649
+version = "1.0.229"3650
+source = "registry+https://github.com/rust-lang/crates.io-index"3651
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"3652
+dependencies = [3653
+ "proc-macro2",3654
+ "quote",3655
+ "syn 3.0.6",3656
+]3657
+3658
+[[package]]3659
+name = "serde_html_form"3660
+version = "0.4.1"3661
+source = "registry+https://github.com/rust-lang/crates.io-index"3662
+checksum = "8f0346d7a342ab90f405cfc08f25d15075f944f42fcabbc5eac923829fa6d228"3663
+dependencies = [3664
+ "form_urlencoded",3665
+ "indexmap",3666
+ "itoa",3667
+ "serde_core",3668
+ "zmij",3669
+]3670
+3671
+[[package]]3672
+name = "serde_json"3673
+version = "1.0.151"3674
+source = "registry+https://github.com/rust-lang/crates.io-index"3675
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"3676
+dependencies = [3677
+ "itoa",3678
+ "memchr",3679
+ "serde",3680
+ "serde_core",3681
+ "zmij",3682
+]3683
+3684
+[[package]]3685
+name = "serde_path_to_error"3686
+version = "0.1.20"3687
+source = "registry+https://github.com/rust-lang/crates.io-index"3688
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"3689
+dependencies = [3690
+ "itoa",3691
+ "serde",3692
+ "serde_core",3693
+]3694
+3695
+[[package]]3696
+name = "serde_spanned"3697
+version = "1.1.1"3698
+source = "registry+https://github.com/rust-lang/crates.io-index"3699
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"3700
+dependencies = [3701
+ "serde_core",3702
+]3703
+3704
+[[package]]3705
+name = "serde_urlencoded"3706
+version = "0.7.1"3707
+source = "registry+https://github.com/rust-lang/crates.io-index"3708
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"3709
+dependencies = [3710
+ "form_urlencoded",3711
+ "itoa",3712
+ "ryu",3713
+ "serde",3714
+]3715
+3716
+[[package]]3717
+name = "sha2"3718
+version = "0.10.9"3719
+source = "registry+https://github.com/rust-lang/crates.io-index"3720
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"3721
+dependencies = [3722
+ "cfg-if",3723
+ "cpufeatures 0.2.17",3724
+ "digest",3725
+]3726
+3727
+[[package]]3728
+name = "sharded-slab"3729
+version = "0.1.7"3730
+source = "registry+https://github.com/rust-lang/crates.io-index"3731
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"3732
+dependencies = [3733
+ "lazy_static",3734
+]3735
+3736
+[[package]]3737
+name = "shlex"3738
+version = "2.0.1"3739
+source = "registry+https://github.com/rust-lang/crates.io-index"3740
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"3741
+3742
+[[package]]3743
+name = "signal-hook-registry"3744
+version = "1.4.8"3745
+source = "registry+https://github.com/rust-lang/crates.io-index"3746
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"3747
+dependencies = [3748
+ "errno",3749
+ "libc",3750
+]3751
+3752
+[[package]]3753
+name = "signature"3754
+version = "2.2.0"3755
+source = "registry+https://github.com/rust-lang/crates.io-index"3756
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"3757
+dependencies = [3758
+ "rand_core 0.6.4",3759
+]3760
+3761
+[[package]]3762
+name = "simd-adler32"3763
+version = "0.3.10"3764
+source = "registry+https://github.com/rust-lang/crates.io-index"3765
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"3766
+3767
+[[package]]3768
+name = "simd_cesu8"3769
+version = "1.2.0"3770
+source = "registry+https://github.com/rust-lang/crates.io-index"3771
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"3772
+dependencies = [3773
+ "rustc_version",3774
+ "simdutf8",3775
+]3776
+3777
+[[package]]3778
+name = "simdutf8"3779
+version = "0.1.5"3780
+source = "registry+https://github.com/rust-lang/crates.io-index"3781
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"3782
+3783
+[[package]]3784
+name = "similar"3785
+version = "2.7.0"3786
+source = "registry+https://github.com/rust-lang/crates.io-index"3787
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"3788
+3789
+[[package]]3790
+name = "siphasher"3791
+version = "1.0.3"3792
+source = "registry+https://github.com/rust-lang/crates.io-index"3793
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"3794
+3795
+[[package]]3796
+name = "slab"3797
+version = "0.4.12"3798
+source = "registry+https://github.com/rust-lang/crates.io-index"3799
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"3800
+3801
+[[package]]3802
+name = "smallvec"3803
+version = "1.16.1"3804
+source = "registry+https://github.com/rust-lang/crates.io-index"3805
+checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"3806
+dependencies = [3807
+ "serde",3808
+]3809
+3810
+[[package]]3811
+name = "socket2"3812
+version = "0.6.5"3813
+source = "registry+https://github.com/rust-lang/crates.io-index"3814
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"3815
+dependencies = [3816
+ "libc",3817
+ "windows-sys 0.61.2",3818
+]3819
+3820
+[[package]]3821
+name = "spin"3822
+version = "0.9.9"3823
+source = "registry+https://github.com/rust-lang/crates.io-index"3824
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"3825
+3826
+[[package]]3827
+name = "spki"3828
+version = "0.7.3"3829
+source = "registry+https://github.com/rust-lang/crates.io-index"3830
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"3831
+dependencies = [3832
+ "base64ct",3833
+ "der",3834
+]3835
+3836
+[[package]]3837
+name = "stable_deref_trait"3838
+version = "1.2.1"3839
+source = "registry+https://github.com/rust-lang/crates.io-index"3840
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"3841
+3842
+[[package]]3843
+name = "string_cache"3844
+version = "0.9.0"3845
+source = "registry+https://github.com/rust-lang/crates.io-index"3846
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"3847
+dependencies = [3848
+ "new_debug_unreachable",3849
+ "parking_lot",3850
+ "phf_shared",3851
+ "precomputed-hash",3852
+]3853
+3854
+[[package]]3855
+name = "string_cache_codegen"3856
+version = "0.6.1"3857
+source = "registry+https://github.com/rust-lang/crates.io-index"3858
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"3859
+dependencies = [3860
+ "phf_generator",3861
+ "phf_shared",3862
+ "proc-macro2",3863
+ "quote",3864
+]3865
+3866
+[[package]]3867
+name = "subtle"3868
+version = "2.6.1"3869
+source = "registry+https://github.com/rust-lang/crates.io-index"3870
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"3871
+3872
+[[package]]3873
+name = "syn"3874
+version = "1.0.109"3875
+source = "registry+https://github.com/rust-lang/crates.io-index"3876
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"3877
+dependencies = [3878
+ "proc-macro2",3879
+ "quote",3880
+ "unicode-ident",3881
+]3882
+3883
+[[package]]3884
+name = "syn"3885
+version = "2.0.119"3886
+source = "registry+https://github.com/rust-lang/crates.io-index"3887
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"3888
+dependencies = [3889
+ "proc-macro2",3890
+ "quote",3891
+ "unicode-ident",3892
+]3893
+3894
+[[package]]3895
+name = "syn"3896
+version = "3.0.6"3897
+source = "registry+https://github.com/rust-lang/crates.io-index"3898
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"3899
+dependencies = [3900
+ "proc-macro2",3901
+ "quote",3902
+ "unicode-ident",3903
+]3904
+3905
+[[package]]3906
+name = "sync_wrapper"3907
+version = "1.0.2"3908
+source = "registry+https://github.com/rust-lang/crates.io-index"3909
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"3910
+dependencies = [3911
+ "futures-core",3912
+]3913
+3914
+[[package]]3915
+name = "synstructure"3916
+version = "0.14.0"3917
+source = "registry+https://github.com/rust-lang/crates.io-index"3918
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"3919
+dependencies = [3920
+ "proc-macro2",3921
+ "quote",3922
+ "syn 3.0.6",3923
+]3924
+3925
+[[package]]3926
+name = "tempfile"3927
+version = "3.27.0"3928
+source = "registry+https://github.com/rust-lang/crates.io-index"3929
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"3930
+dependencies = [3931
+ "fastrand",3932
+ "getrandom 0.4.3",3933
+ "once_cell",3934
+ "rustix",3935
+ "windows-sys 0.61.2",3936
+]3937
+3938
+[[package]]3939
+name = "tendril"3940
+version = "0.5.1"3941
+source = "registry+https://github.com/rust-lang/crates.io-index"3942
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"3943
+dependencies = [3944
+ "new_debug_unreachable",3945
+]3946
+3947
+[[package]]3948
+name = "thiserror"3949
+version = "1.0.69"3950
+source = "registry+https://github.com/rust-lang/crates.io-index"3951
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"3952
+dependencies = [3953
+ "thiserror-impl 1.0.69",3954
+]3955
+3956
+[[package]]3957
+name = "thiserror"3958
+version = "2.0.21"3959
+source = "registry+https://github.com/rust-lang/crates.io-index"3960
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"3961
+dependencies = [3962
+ "thiserror-impl 2.0.21",3963
+]3964
+3965
+[[package]]3966
+name = "thiserror-impl"3967
+version = "1.0.69"3968
+source = "registry+https://github.com/rust-lang/crates.io-index"3969
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"3970
+dependencies = [3971
+ "proc-macro2",3972
+ "quote",3973
+ "syn 2.0.119",3974
+]3975
+3976
+[[package]]3977
+name = "thiserror-impl"3978
+version = "2.0.21"3979
+source = "registry+https://github.com/rust-lang/crates.io-index"3980
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"3981
+dependencies = [3982
+ "proc-macro2",3983
+ "quote",3984
+ "syn 3.0.6",3985
+]3986
+3987
+[[package]]3988
+name = "thread_local"3989
+version = "1.1.10"3990
+source = "registry+https://github.com/rust-lang/crates.io-index"3991
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"3992
+dependencies = [3993
+ "cfg-if",3994
+]3995
+3996
+[[package]]3997
+name = "time"3998
+version = "0.3.55"3999
+source = "registry+https://github.com/rust-lang/crates.io-index"4000
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"4001
+dependencies = [4002
+ "deranged",4003
+ "num-conv",4004
+ "powerfmt",4005
+ "serde_core",4006
+ "time-core",4007
+ "time-macros",4008
+]4009
+4010
+[[package]]4011
+name = "time-core"4012
+version = "0.1.9"4013
+source = "registry+https://github.com/rust-lang/crates.io-index"4014
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"4015
+4016
+[[package]]4017
+name = "time-macros"4018
+version = "0.2.32"4019
+source = "registry+https://github.com/rust-lang/crates.io-index"4020
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"4021
+dependencies = [4022
+ "num-conv",4023
+ "time-core",4024
+]4025
+4026
+[[package]]4027
+name = "tinystr"4028
+version = "0.8.4"4029
+source = "registry+https://github.com/rust-lang/crates.io-index"4030
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"4031
+dependencies = [4032
+ "displaydoc",4033
+ "zerovec",4034
+]4035
+4036
+[[package]]4037
+name = "tinyvec"4038
+version = "1.13.3"4039
+source = "registry+https://github.com/rust-lang/crates.io-index"4040
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"4041
+4042
+[[package]]4043
+name = "tokio"4044
+version = "1.53.1"4045
+source = "registry+https://github.com/rust-lang/crates.io-index"4046
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"4047
+dependencies = [4048
+ "bytes",4049
+ "libc",4050
+ "mio",4051
+ "parking_lot",4052
+ "pin-project-lite",4053
+ "signal-hook-registry",4054
+ "socket2",4055
+ "tokio-macros",4056
+ "windows-sys 0.61.2",4057
+]4058
+4059
+[[package]]4060
+name = "tokio-macros"4061
+version = "2.7.2"4062
+source = "registry+https://github.com/rust-lang/crates.io-index"4063
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"4064
+dependencies = [4065
+ "proc-macro2",4066
+ "quote",4067
+ "syn 3.0.6",4068
+]4069
+4070
+[[package]]4071
+name = "tokio-rustls"4072
+version = "0.26.5"4073
+source = "registry+https://github.com/rust-lang/crates.io-index"4074
+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"4075
+dependencies = [4076
+ "rustls",4077
+ "tokio",4078
+]4079
+4080
+[[package]]4081
+name = "tokio-stream"4082
+version = "0.1.19"4083
+source = "registry+https://github.com/rust-lang/crates.io-index"4084
+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"4085
+dependencies = [4086
+ "futures-core",4087
+ "pin-project-lite",4088
+ "tokio",4089
+ "tokio-util",4090
+]4091
+4092
+[[package]]4093
+name = "tokio-util"4094
+version = "0.7.19"4095
+source = "registry+https://github.com/rust-lang/crates.io-index"4096
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"4097
+dependencies = [4098
+ "bytes",4099
+ "futures-core",4100
+ "futures-sink",4101
+ "libc",4102
+ "pin-project-lite",4103
+ "tokio",4104
+]4105
+4106
+[[package]]4107
+name = "toml"4108
+version = "1.1.6+spec-1.1.0"4109
+source = "registry+https://github.com/rust-lang/crates.io-index"4110
+checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"4111
+dependencies = [4112
+ "serde_core",4113
+ "serde_spanned",4114
+ "toml_datetime",4115
+ "toml_parser",4116
+ "winnow",4117
+]4118
+4119
+[[package]]4120
+name = "toml_datetime"4121
+version = "1.1.1+spec-1.1.0"4122
+source = "registry+https://github.com/rust-lang/crates.io-index"4123
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"4124
+dependencies = [4125
+ "serde_core",4126
+]4127
+4128
+[[package]]4129
+name = "toml_edit"4130
+version = "0.25.15+spec-1.1.0"4131
+source = "registry+https://github.com/rust-lang/crates.io-index"4132
+checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"4133
+dependencies = [4134
+ "indexmap",4135
+ "toml_datetime",4136
+ "toml_parser",4137
+ "winnow",4138
+]4139
+4140
+[[package]]4141
+name = "toml_parser"4142
+version = "1.1.3+spec-1.1.0"4143
+source = "registry+https://github.com/rust-lang/crates.io-index"4144
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"4145
+dependencies = [4146
+ "winnow",4147
+]4148
+4149
+[[package]]4150
+name = "tower"4151
+version = "0.5.3"4152
+source = "registry+https://github.com/rust-lang/crates.io-index"4153
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"4154
+dependencies = [4155
+ "futures-core",4156
+ "futures-util",4157
+ "pin-project-lite",4158
+ "sync_wrapper",4159
+ "tokio",4160
+ "tower-layer",4161
+ "tower-service",4162
+ "tracing",4163
+]4164
+4165
+[[package]]4166
+name = "tower-http"4167
+version = "0.6.11"4168
+source = "registry+https://github.com/rust-lang/crates.io-index"4169
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"4170
+dependencies = [4171
+ "async-compression",4172
+ "bitflags",4173
+ "bytes",4174
+ "futures-core",4175
+ "futures-util",4176
+ "http",4177
+ "http-body",4178
+ "http-body-util",4179
+ "http-range-header",4180
+ "httpdate",4181
+ "mime",4182
+ "mime_guess",4183
+ "percent-encoding",4184
+ "pin-project-lite",4185
+ "tokio",4186
+ "tokio-util",4187
+ "tower",4188
+ "tower-layer",4189
+ "tower-service",4190
+ "tracing",4191
+ "url",4192
+]4193
+4194
+[[package]]4195
+name = "tower-layer"4196
+version = "0.3.3"4197
+source = "registry+https://github.com/rust-lang/crates.io-index"4198
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"4199
+4200
+[[package]]4201
+name = "tower-service"4202
+version = "0.3.3"4203
+source = "registry+https://github.com/rust-lang/crates.io-index"4204
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"4205
+4206
+[[package]]4207
+name = "tracing"4208
+version = "0.1.44"4209
+source = "registry+https://github.com/rust-lang/crates.io-index"4210
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"4211
+dependencies = [4212
+ "log",4213
+ "pin-project-lite",4214
+ "tracing-attributes",4215
+ "tracing-core",4216
+]4217
+4218
+[[package]]4219
+name = "tracing-attributes"4220
+version = "0.1.31"4221
+source = "registry+https://github.com/rust-lang/crates.io-index"4222
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"4223
+dependencies = [4224
+ "proc-macro2",4225
+ "quote",4226
+ "syn 2.0.119",4227
+]4228
+4229
+[[package]]4230
+name = "tracing-core"4231
+version = "0.1.36"4232
+source = "registry+https://github.com/rust-lang/crates.io-index"4233
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"4234
+dependencies = [4235
+ "once_cell",4236
+ "valuable",4237
+]4238
+4239
+[[package]]4240
+name = "tracing-log"4241
+version = "0.2.0"4242
+source = "registry+https://github.com/rust-lang/crates.io-index"4243
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"4244
+dependencies = [4245
+ "log",4246
+ "once_cell",4247
+ "tracing-core",4248
+]4249
+4250
+[[package]]4251
+name = "tracing-subscriber"4252
+version = "0.3.23"4253
+source = "registry+https://github.com/rust-lang/crates.io-index"4254
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"4255
+dependencies = [4256
+ "matchers",4257
+ "nu-ansi-term",4258
+ "once_cell",4259
+ "regex-automata",4260
+ "sharded-slab",4261
+ "smallvec",4262
+ "thread_local",4263
+ "tracing",4264
+ "tracing-core",4265
+ "tracing-log",4266
+]4267
+4268
+[[package]]4269
+name = "try-lock"4270
+version = "0.2.5"4271
+source = "registry+https://github.com/rust-lang/crates.io-index"4272
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"4273
+4274
+[[package]]4275
+name = "typenum"4276
+version = "1.20.1"4277
+source = "registry+https://github.com/rust-lang/crates.io-index"4278
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"4279
+4280
+[[package]]4281
+name = "typewit"4282
+version = "1.15.2"4283
+source = "registry+https://github.com/rust-lang/crates.io-index"4284
+checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6"4285
+4286
+[[package]]4287
+name = "ucd-trie"4288
+version = "0.1.7"4289
+source = "registry+https://github.com/rust-lang/crates.io-index"4290
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"4291
+4292
+[[package]]4293
+name = "ulid"4294
+version = "1.2.1"4295
+source = "registry+https://github.com/rust-lang/crates.io-index"4296
+checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"4297
+dependencies = [4298
+ "rand 0.9.5",4299
+ "web-time",4300
+]4301
+4302
+[[package]]4303
+name = "unicase"4304
+version = "2.9.0"4305
+source = "registry+https://github.com/rust-lang/crates.io-index"4306
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"4307
+4308
+[[package]]4309
+name = "unicode-ident"4310
+version = "1.0.26"4311
+source = "registry+https://github.com/rust-lang/crates.io-index"4312
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"4313
+4314
+[[package]]4315
+name = "unicode-normalization"4316
+version = "0.1.25"4317
+source = "registry+https://github.com/rust-lang/crates.io-index"4318
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"4319
+dependencies = [4320
+ "tinyvec",4321
+]4322
+4323
+[[package]]4324
+name = "unicode-segmentation"4325
+version = "1.13.3"4326
+source = "registry+https://github.com/rust-lang/crates.io-index"4327
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"4328
+4329
+[[package]]4330
+name = "unicode-xid"4331
+version = "0.2.6"4332
+source = "registry+https://github.com/rust-lang/crates.io-index"4333
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"4334
+4335
+[[package]]4336
+name = "universal-hash"4337
+version = "0.5.1"4338
+source = "registry+https://github.com/rust-lang/crates.io-index"4339
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"4340
+dependencies = [4341
+ "crypto-common",4342
+ "subtle",4343
+]4344
+4345
+[[package]]4346
+name = "untrusted"4347
+version = "0.9.0"4348
+source = "registry+https://github.com/rust-lang/crates.io-index"4349
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"4350
+4351
+[[package]]4352
+name = "url"4353
+version = "2.5.8"4354
+source = "registry+https://github.com/rust-lang/crates.io-index"4355
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"4356
+dependencies = [4357
+ "form_urlencoded",4358
+ "idna",4359
+ "percent-encoding",4360
+ "serde",4361
+ "serde_derive",4362
+]4363
+4364
+[[package]]4365
+name = "urlencoding"4366
+version = "2.1.3"4367
+source = "registry+https://github.com/rust-lang/crates.io-index"4368
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"4369
+4370
+[[package]]4371
+name = "utf8_iter"4372
+version = "1.0.4"4373
+source = "registry+https://github.com/rust-lang/crates.io-index"4374
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"4375
+4376
+[[package]]4377
+name = "uuid"4378
+version = "1.26.1"4379
+source = "registry+https://github.com/rust-lang/crates.io-index"4380
+checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"4381
+dependencies = [4382
+ "getrandom 0.4.3",4383
+ "js-sys",4384
+ "serde_core",4385
+ "wasm-bindgen",4386
+]4387
+4388
+[[package]]4389
+name = "valuable"4390
+version = "0.1.1"4391
+source = "registry+https://github.com/rust-lang/crates.io-index"4392
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"4393
+4394
+[[package]]4395
+name = "vcpkg"4396
+version = "0.2.15"4397
+source = "registry+https://github.com/rust-lang/crates.io-index"4398
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"4399
+4400
+[[package]]4401
+name = "version_check"4402
+version = "0.9.5"4403
+source = "registry+https://github.com/rust-lang/crates.io-index"4404
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"4405
+4406
+[[package]]4407
+name = "vodozemac"4408
+version = "0.10.0"4409
+source = "registry+https://github.com/rust-lang/crates.io-index"4410
+checksum = "b98bf83c0992966775b8012f194b07b44928996163e5a05b741b43891571ae5b"4411
+dependencies = [4412
+ "aes",4413
+ "arrayvec",4414
+ "base64 0.22.1",4415
+ "base64ct",4416
+ "cbc",4417
+ "chacha20poly1305",4418
+ "curve25519-dalek",4419
+ "ed25519-dalek",4420
+ "getrandom 0.2.17",4421
+ "hkdf",4422
+ "hmac",4423
+ "matrix-pickle",4424
+ "prost",4425
+ "rand 0.8.8",4426
+ "serde",4427
+ "serde_bytes",4428
+ "serde_json",4429
+ "sha2",4430
+ "subtle",4431
+ "thiserror 2.0.21",4432
+ "x25519-dalek",4433
+ "zeroize",4434
+]4435
+4436
+[[package]]4437
+name = "walkdir"4438
+version = "2.5.0"4439
+source = "registry+https://github.com/rust-lang/crates.io-index"4440
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"4441
+dependencies = [4442
+ "same-file",4443
+ "winapi-util",4444
+]4445
+4446
+[[package]]4447
+name = "want"4448
+version = "0.3.1"4449
+source = "registry+https://github.com/rust-lang/crates.io-index"4450
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"4451
+dependencies = [4452
+ "try-lock",4453
+]4454
+4455
+[[package]]4456
+name = "wasi"4457
+version = "0.11.1+wasi-snapshot-preview1"4458
+source = "registry+https://github.com/rust-lang/crates.io-index"4459
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"4460
+4461
+[[package]]4462
+name = "wasip2"4463
+version = "1.0.4+wasi-0.2.12"4464
+source = "registry+https://github.com/rust-lang/crates.io-index"4465
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"4466
+dependencies = [4467
+ "wit-bindgen",4468
+]4469
+4470
+[[package]]4471
+name = "wasm-bindgen"4472
+version = "0.2.128"4473
+source = "registry+https://github.com/rust-lang/crates.io-index"4474
+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"4475
+dependencies = [4476
+ "cfg-if",4477
+ "once_cell",4478
+ "rustversion",4479
+ "wasm-bindgen-macro",4480
+ "wasm-bindgen-shared",4481
+]4482
+4483
+[[package]]4484
+name = "wasm-bindgen-futures"4485
+version = "0.4.78"4486
+source = "registry+https://github.com/rust-lang/crates.io-index"4487
+checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"4488
+dependencies = [4489
+ "js-sys",4490
+ "wasm-bindgen",4491
+]4492
+4493
+[[package]]4494
+name = "wasm-bindgen-macro"4495
+version = "0.2.128"4496
+source = "registry+https://github.com/rust-lang/crates.io-index"4497
+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"4498
+dependencies = [4499
+ "quote",4500
+ "wasm-bindgen-macro-support",4501
+]4502
+4503
+[[package]]4504
+name = "wasm-bindgen-macro-support"4505
+version = "0.2.128"4506
+source = "registry+https://github.com/rust-lang/crates.io-index"4507
+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"4508
+dependencies = [4509
+ "bumpalo",4510
+ "proc-macro2",4511
+ "quote",4512
+ "syn 3.0.6",4513
+ "wasm-bindgen-shared",4514
+]4515
+4516
+[[package]]4517
+name = "wasm-bindgen-shared"4518
+version = "0.2.128"4519
+source = "registry+https://github.com/rust-lang/crates.io-index"4520
+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"4521
+dependencies = [4522
+ "unicode-ident",4523
+]4524
+4525
+[[package]]4526
+name = "wasm-bindgen-test"4527
+version = "0.3.78"4528
+source = "registry+https://github.com/rust-lang/crates.io-index"4529
+checksum = "45863ef0bef521c12124eb39d9a38513c47db75f22e503c06beacd40afeb35db"4530
+dependencies = [4531
+ "async-trait",4532
+ "cast",4533
+ "js-sys",4534
+ "libm",4535
+ "minicov",4536
+ "nu-ansi-term",4537
+ "num-traits",4538
+ "oorandom",4539
+ "serde",4540
+ "serde_json",4541
+ "wasm-bindgen",4542
+ "wasm-bindgen-futures",4543
+ "wasm-bindgen-test-macro",4544
+ "wasm-bindgen-test-shared",4545
+]4546
+4547
+[[package]]4548
+name = "wasm-bindgen-test-macro"4549
+version = "0.3.78"4550
+source = "registry+https://github.com/rust-lang/crates.io-index"4551
+checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12"4552
+dependencies = [4553
+ "proc-macro2",4554
+ "quote",4555
+ "syn 3.0.6",4556
+]4557
+4558
+[[package]]4559
+name = "wasm-bindgen-test-shared"4560
+version = "0.2.128"4561
+source = "registry+https://github.com/rust-lang/crates.io-index"4562
+checksum = "f37b4f992cebe528ef34964ae69681ac0fe7080071e7298e46008f9d380302af"4563
+4564
+[[package]]4565
+name = "wasm-streams"4566
+version = "0.5.0"4567
+source = "registry+https://github.com/rust-lang/crates.io-index"4568
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"4569
+dependencies = [4570
+ "futures-util",4571
+ "js-sys",4572
+ "wasm-bindgen",4573
+ "wasm-bindgen-futures",4574
+ "web-sys",4575
+]4576
+4577
+[[package]]4578
+name = "wasm_evt_listener"4579
+version = "0.1.0"4580
+source = "registry+https://github.com/rust-lang/crates.io-index"4581
+checksum = "dc92d6378b411ed94839112a36d9dbc77143451d85b05dfb0cce93a78dab1963"4582
+dependencies = [4583
+ "accessory",4584
+ "derivative",4585
+ "derive_more 1.0.0",4586
+ "fancy_constructor",4587
+ "futures-core",4588
+ "js-sys",4589
+ "smallvec",4590
+ "tokio",4591
+ "wasm-bindgen",4592
+ "web-sys",4593
+]4594
+4595
+[[package]]4596
+name = "web-sys"4597
+version = "0.3.105"4598
+source = "registry+https://github.com/rust-lang/crates.io-index"4599
+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"4600
+dependencies = [4601
+ "js-sys",4602
+ "wasm-bindgen",4603
+]4604
+4605
+[[package]]4606
+name = "web-time"4607
+version = "1.1.0"4608
+source = "registry+https://github.com/rust-lang/crates.io-index"4609
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"4610
+dependencies = [4611
+ "js-sys",4612
+ "serde",4613
+ "wasm-bindgen",4614
+]4615
+4616
+[[package]]4617
+name = "web_atoms"4618
+version = "0.2.6"4619
+source = "registry+https://github.com/rust-lang/crates.io-index"4620
+checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"4621
+dependencies = [4622
+ "phf",4623
+ "phf_codegen",4624
+ "string_cache",4625
+ "string_cache_codegen",4626
+]4627
+4628
+[[package]]4629
+name = "webpki-root-certs"4630
+version = "1.0.9"4631
+source = "registry+https://github.com/rust-lang/crates.io-index"4632
+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"4633
+dependencies = [4634
+ "rustls-pki-types",4635
+]4636
+4637
+[[package]]4638
+name = "webpki-roots"4639
+version = "1.0.9"4640
+source = "registry+https://github.com/rust-lang/crates.io-index"4641
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"4642
+dependencies = [4643
+ "rustls-pki-types",4644
+]4645
+4646
+[[package]]4647
+name = "wildmatch"4648
+version = "2.6.1"4649
+source = "registry+https://github.com/rust-lang/crates.io-index"4650
+checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68"4651
+4652
+[[package]]4653
+name = "winapi"4654
+version = "0.3.9"4655
+source = "registry+https://github.com/rust-lang/crates.io-index"4656
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"4657
+dependencies = [4658
+ "winapi-i686-pc-windows-gnu",4659
+ "winapi-x86_64-pc-windows-gnu",4660
+]4661
+4662
+[[package]]4663
+name = "winapi-i686-pc-windows-gnu"4664
+version = "0.4.0"4665
+source = "registry+https://github.com/rust-lang/crates.io-index"4666
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"4667
+4668
+[[package]]4669
+name = "winapi-util"4670
+version = "0.1.11"4671
+source = "registry+https://github.com/rust-lang/crates.io-index"4672
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"4673
+dependencies = [4674
+ "windows-sys 0.61.2",4675
+]4676
+4677
+[[package]]4678
+name = "winapi-x86_64-pc-windows-gnu"4679
+version = "0.4.0"4680
+source = "registry+https://github.com/rust-lang/crates.io-index"4681
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"4682
+4683
+[[package]]4684
+name = "windows-core"4685
+version = "0.62.2"4686
+source = "registry+https://github.com/rust-lang/crates.io-index"4687
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"4688
+dependencies = [4689
+ "windows-implement",4690
+ "windows-interface",4691
+ "windows-link",4692
+ "windows-result",4693
+ "windows-strings",4694
+]4695
+4696
+[[package]]4697
+name = "windows-implement"4698
+version = "0.60.2"4699
+source = "registry+https://github.com/rust-lang/crates.io-index"4700
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"4701
+dependencies = [4702
+ "proc-macro2",4703
+ "quote",4704
+ "syn 2.0.119",4705
+]4706
+4707
+[[package]]4708
+name = "windows-interface"4709
+version = "0.59.3"4710
+source = "registry+https://github.com/rust-lang/crates.io-index"4711
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"4712
+dependencies = [4713
+ "proc-macro2",4714
+ "quote",4715
+ "syn 2.0.119",4716
+]4717
+4718
+[[package]]4719
+name = "windows-link"4720
+version = "0.2.1"4721
+source = "registry+https://github.com/rust-lang/crates.io-index"4722
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"4723
+4724
+[[package]]4725
+name = "windows-result"4726
+version = "0.4.1"4727
+source = "registry+https://github.com/rust-lang/crates.io-index"4728
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"4729
+dependencies = [4730
+ "windows-link",4731
+]4732
+4733
+[[package]]4734
+name = "windows-strings"4735
+version = "0.5.1"4736
+source = "registry+https://github.com/rust-lang/crates.io-index"4737
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"4738
+dependencies = [4739
+ "windows-link",4740
+]4741
+4742
+[[package]]4743
+name = "windows-sys"4744
+version = "0.52.0"4745
+source = "registry+https://github.com/rust-lang/crates.io-index"4746
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"4747
+dependencies = [4748
+ "windows-targets",4749
+]4750
+4751
+[[package]]4752
+name = "windows-sys"4753
+version = "0.61.2"4754
+source = "registry+https://github.com/rust-lang/crates.io-index"4755
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"4756
+dependencies = [4757
+ "windows-link",4758
+]4759
+4760
+[[package]]4761
+name = "windows-targets"4762
+version = "0.52.6"4763
+source = "registry+https://github.com/rust-lang/crates.io-index"4764
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"4765
+dependencies = [4766
+ "windows_aarch64_gnullvm",4767
+ "windows_aarch64_msvc",4768
+ "windows_i686_gnu",4769
+ "windows_i686_gnullvm",4770
+ "windows_i686_msvc",4771
+ "windows_x86_64_gnu",4772
+ "windows_x86_64_gnullvm",4773
+ "windows_x86_64_msvc",4774
+]4775
+4776
+[[package]]4777
+name = "windows_aarch64_gnullvm"4778
+version = "0.52.6"4779
+source = "registry+https://github.com/rust-lang/crates.io-index"4780
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"4781
+4782
+[[package]]4783
+name = "windows_aarch64_msvc"4784
+version = "0.52.6"4785
+source = "registry+https://github.com/rust-lang/crates.io-index"4786
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"4787
+4788
+[[package]]4789
+name = "windows_i686_gnu"4790
+version = "0.52.6"4791
+source = "registry+https://github.com/rust-lang/crates.io-index"4792
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"4793
+4794
+[[package]]4795
+name = "windows_i686_gnullvm"4796
+version = "0.52.6"4797
+source = "registry+https://github.com/rust-lang/crates.io-index"4798
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"4799
+4800
+[[package]]4801
+name = "windows_i686_msvc"4802
+version = "0.52.6"4803
+source = "registry+https://github.com/rust-lang/crates.io-index"4804
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"4805
+4806
+[[package]]4807
+name = "windows_x86_64_gnu"4808
+version = "0.52.6"4809
+source = "registry+https://github.com/rust-lang/crates.io-index"4810
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"4811
+4812
+[[package]]4813
+name = "windows_x86_64_gnullvm"4814
+version = "0.52.6"4815
+source = "registry+https://github.com/rust-lang/crates.io-index"4816
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"4817
+4818
+[[package]]4819
+name = "windows_x86_64_msvc"4820
+version = "0.52.6"4821
+source = "registry+https://github.com/rust-lang/crates.io-index"4822
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"4823
+4824
+[[package]]4825
+name = "winnow"4826
+version = "1.0.4"4827
+source = "registry+https://github.com/rust-lang/crates.io-index"4828
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"4829
+dependencies = [4830
+ "memchr",4831
+]4832
+4833
+[[package]]4834
+name = "wiremock"4835
+version = "0.6.5"4836
+source = "registry+https://github.com/rust-lang/crates.io-index"4837
+checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"4838
+dependencies = [4839
+ "assert-json-diff",4840
+ "base64 0.22.1",4841
+ "deadpool 0.12.3",4842
+ "futures",4843
+ "http",4844
+ "http-body-util",4845
+ "hyper",4846
+ "hyper-util",4847
+ "log",4848
+ "once_cell",4849
+ "regex",4850
+ "serde",4851
+ "serde_json",4852
+ "tokio",4853
+ "url",4854
+]4855
+4856
+[[package]]4857
+name = "wit-bindgen"4858
+version = "0.57.1"4859
+source = "registry+https://github.com/rust-lang/crates.io-index"4860
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"4861
+4862
+[[package]]4863
+name = "writeable"4864
+version = "0.6.4"4865
+source = "registry+https://github.com/rust-lang/crates.io-index"4866
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"4867
+4868
+[[package]]4869
+name = "x25519-dalek"4870
+version = "2.0.1"4871
+source = "registry+https://github.com/rust-lang/crates.io-index"4872
+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"4873
+dependencies = [4874
+ "curve25519-dalek",4875
+ "rand_core 0.6.4",4876
+ "serde",4877
+ "zeroize",4878
+]4879
+4880
+[[package]]4881
+name = "xxhash-rust"4882
+version = "0.8.18"4883
+source = "registry+https://github.com/rust-lang/crates.io-index"4884
+checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"4885
+4886
+[[package]]4887
+name = "yoke"4888
+version = "0.8.3"4889
+source = "registry+https://github.com/rust-lang/crates.io-index"4890
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"4891
+dependencies = [4892
+ "stable_deref_trait",4893
+ "yoke-derive",4894
+ "zerofrom",4895
+]4896
+4897
+[[package]]4898
+name = "yoke-derive"4899
+version = "0.8.3"4900
+source = "registry+https://github.com/rust-lang/crates.io-index"4901
+checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"4902
+dependencies = [4903
+ "proc-macro2",4904
+ "quote",4905
+ "syn 3.0.6",4906
+ "synstructure",4907
+]4908
+4909
+[[package]]4910
+name = "zerocopy"4911
+version = "0.8.57"4912
+source = "registry+https://github.com/rust-lang/crates.io-index"4913
+checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"4914
+dependencies = [4915
+ "zerocopy-derive",4916
+]4917
+4918
+[[package]]4919
+name = "zerocopy-derive"4920
+version = "0.8.57"4921
+source = "registry+https://github.com/rust-lang/crates.io-index"4922
+checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"4923
+dependencies = [4924
+ "proc-macro2",4925
+ "quote",4926
+ "syn 2.0.119",4927
+]4928
+4929
+[[package]]4930
+name = "zerofrom"4931
+version = "0.1.8"4932
+source = "registry+https://github.com/rust-lang/crates.io-index"4933
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"4934
+dependencies = [4935
+ "zerofrom-derive",4936
+]4937
+4938
+[[package]]4939
+name = "zerofrom-derive"4940
+version = "0.1.8"4941
+source = "registry+https://github.com/rust-lang/crates.io-index"4942
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"4943
+dependencies = [4944
+ "proc-macro2",4945
+ "quote",4946
+ "syn 3.0.6",4947
+ "synstructure",4948
+]4949
+4950
+[[package]]4951
+name = "zeroize"4952
+version = "1.9.0"4953
+source = "registry+https://github.com/rust-lang/crates.io-index"4954
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"4955
+dependencies = [4956
+ "zeroize_derive",4957
+]4958
+4959
+[[package]]4960
+name = "zeroize_derive"4961
+version = "1.5.0"4962
+source = "registry+https://github.com/rust-lang/crates.io-index"4963
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"4964
+dependencies = [4965
+ "proc-macro2",4966
+ "quote",4967
+ "syn 2.0.119",4968
+]4969
+4970
+[[package]]4971
+name = "zerotrie"4972
+version = "0.2.5"4973
+source = "registry+https://github.com/rust-lang/crates.io-index"4974
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"4975
+dependencies = [4976
+ "displaydoc",4977
+ "yoke",4978
+ "zerofrom",4979
+]4980
+4981
+[[package]]4982
+name = "zerovec"4983
+version = "0.11.8"4984
+source = "registry+https://github.com/rust-lang/crates.io-index"4985
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"4986
+dependencies = [4987
+ "yoke",4988
+ "zerofrom",4989
+ "zerovec-derive",4990
+]4991
+4992
+[[package]]4993
+name = "zerovec-derive"4994
+version = "0.11.6"4995
+source = "registry+https://github.com/rust-lang/crates.io-index"4996
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"4997
+dependencies = [4998
+ "proc-macro2",4999
+ "quote",5000
+ "syn 3.0.6",5001
+]5002
+5003
+[[package]]5004
+name = "zlib-rs"5005
+version = "0.6.8"5006
+source = "registry+https://github.com/rust-lang/crates.io-index"5007
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"5008
+5009
+[[package]]5010
+name = "zmij"5011
+version = "1.0.23"5012
+source = "registry+https://github.com/rust-lang/crates.io-index"5013
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"@@ -0,0 +1,15 @@
1
+[workspace]2
+resolver = "3"3
+members = ["crates/malice-core", "crates/malice-server"]4
+5
+[workspace.dependencies]6
+macltok = { path = "vendor/macltok" }7
+ruma = { version = "0.16", default-features = false }8
+serde = { version = "1", features = ["derive"] }9
+serde_json = "1"10
+tokio = { version = "1" }11
+tracing = "0.1"12
+http = "1"13
+14
+[workspace.lints.rust]15
+unsafe_code = "forbid"@@ -0,0 +1,77 @@
1
+# macltok request, what a host needs to stream large media itself2
+3
+## Problem4
+5
+`ClientHandle::media(&MediaId, MediaSize)` answers with `Media { bytes: Arc<[u8]>, .. }`, the whole6
+file, decrypted and hash-checked, capped by `EngineConfig::max_media_bytes`. That is right for7
+avatars, thumbnails and images. It is wrong for a 20 minute video.8
+9
+- In a browser the whole file sits in wasm linear memory, which never shrinks and tops out at 4 GB10
+ on wasm32. A few long videos and the worker is carrying hundreds of MB it can never give back.11
+- Nothing plays until the last byte has arrived and the hash has been checked, and seeking has to12
+ wait for the same.13
+14
+malice's on-device build serves media through a service worker so `<video>` and `<audio>` get real15
+URLs and Range requests. For large media it wants to answer each Range itself, fetching the matching16
+ciphertext range from the homeserver's authenticated media endpoint and decrypting it in the service17
+worker with WebCrypto AES-CTR (the counter block is the IV plus `offset / 16`, so any 16-byte aligned18
+range decrypts on its own). Nothing is buffered and the browser caches only ciphertext. The hash can19
+only be checked on a whole read, so a partial read plays unverified bytes. malice accepts that.20
+21
+What it can't get today is what the request needs. `MediaRegistration` and `MediaSourceInfo`22
+(`normalise/media.rs`) are `pub(crate)`, and there is no way to read the access token.23
+24
+## Request25
+26
+Two read-only accessors on `ClientHandle`. Suggested shapes, adjust freely.27
+28
+```rust29
+/// where a registered piece of media lives and how to decrypt it, for a host that fetches it30
+/// itself. `None` for an unknown id, same as `media` answering `MediaUnknown`31
+pub fn media_source(&self, media_id: &MediaId, size: MediaSize) -> Option<MediaSourceDetails>;32
+33
+#[non_exhaustive]34
+pub struct MediaSourceDetails {35
+ pub uri: OwnedMxcUri,36
+ pub mimetype: Option<String>,37
+ pub declared_size: Option<u64>,38
+ /// `Some` for an encrypted attachment39
+ pub encryption: Option<AttachmentEncryption>,40
+}41
+42
+#[non_exhaustive]43
+pub struct AttachmentEncryption {44
+ /// the JWK `k`, unpadded url-safe base64, AES-256-CTR45
+ pub key: String,46
+ /// unpadded base64, the initial counter block47
+ pub iv: String,48
+ /// unpadded base64 SHA-256 of the ciphertext, for a host that does read it whole49
+ pub sha256: String,50
+}51
+52
+/// the current access token and the homeserver it belongs to. it changes on every refresh, so53
+/// ask again after a 401 rather than keeping it54
+pub fn media_credentials(&self) -> Option<MediaCredentials>;55
+56
+#[non_exhaustive]57
+pub struct MediaCredentials {58
+ pub homeserver: String,59
+ pub access_token: String,60
+}61
+```62
+63
+Thumbnail sizes with no uploader thumbnail are server-side resizes, which are never encrypted, so64
+`media_source` for `MediaSize::Thumbnail` can answer the uploader's thumbnail when there is one and65
+the full source otherwise, whichever `media` would have used.66
+67
+The token accessor is the sensitive one. It hands a caller the bearer token, which `session_record`68
+already does in serialized form, so it doesn't widen what a host can reach. It is worth a doc line69
+saying the token should stay in memory and only go to the homeserver's media endpoints.70
+71
+## Also worth considering72
+73
+Streaming decryption inside macltok, `media_stream(&MediaId, range) -> impl Stream<Item = Bytes>`,74
+would let every host stream without reimplementing AES-CTR and Range handling, and would keep the75
+token inside macltok. matrix-sdk 0.18 decrypts attachments whole (`Media::get_media_content`), so76
+it would mean reimplementing the attachment decryptor over the raw download. malice will do it77
+host-side for now, so this is not blocking.@@ -0,0 +1,58 @@
1
+# macltok request, pre-share room keys before send2
+3
+## Problem4
+5
+Sending into an encrypted room is slow in malice (and Element X), and under federation lag it can take over a minute. Clients on other SDKs feel instant because they do the key work before the user presses send.6
+7
+matrix-sdk 0.18 does all of it inline on the send path. `SendRawMessageLikeEvent::into_future` (`matrix-sdk-0.18.0/src/room/futures.rs:190-245`) calls the private `ensure_room_encryption_ready` (`futures.rs:523-537`), which runs, in the room's single send-queue worker and before the `/send` PUT8
+9
+1. `sync_members()` if `!are_members_synced()`10
+2. `query_keys_for_untracked_or_dirty_users()`, a `/keys/query` that the homeserver fans out over federation11
+3. `preshare_room_key()` (`room/mod.rs:2396-2431`), which claims one-time keys (`/keys/claim`, 10s server timeout, also federated) and then sends the megolm key to every device as to-device messages, awaiting each12
+13
+Every request uses the default 30s timeout, and the send queue retries with `short_retry` (3 attempts, exponential backoff). A slow remote server stalls the send and everything queued behind it in that room.14
+15
+For comparison, matrix-dart-sdk calls `prepareOutboundGroupSession` unawaited from `Client.setTyping`, so FluffyChat has usually finished steps 1 to 3 by the time the user hits send. matrix-js-sdk exposes `CryptoApi.prepareToEncrypt(room)` for the same purpose. matrix-sdk has the primitive but keeps it private, with this at `room/mod.rs:2392`16
+17
+```rust18
+// TODO: expose this publicly so people can pre-share a group session if19
+// e.g. a user starts to type a message for a room.20
+```21
+22
+## What is reachable today23
+24
+Checked against the 0.18 sources. Only the first step is public.25
+26
+| step | public from `matrix_sdk`? |27
+|---|---|28
+| `Room::sync_members`, `are_members_synced` | yes |29
+| `Room::query_keys_for_untracked_or_dirty_users` | no, private. `Encryption::request_user_identity(user_id)` does a per-user `/keys/query` |30
+| `Client::claim_one_time_keys` | no, `pub(crate)` |31
+| `Room::share_room_key`, `Room::preshare_room_key` | no, private. `OlmMachine::share_room_key` is pub but `Client::olm_machine()` is `pub(crate)` |32
+| `ensure_room_encryption_ready` | no, private free fn |33
+34
+There is no public encrypt-without-send path either. Every caller of `ensure_room_encryption_ready` goes on to send.35
+36
+## Request37
+38
+Expose a command that runs the send path's encryption prep without sending anything, fire and forget, deduplicated per room.39
+40
+```rust41
+impl ClientHandle {42
+ /// no-op for unencrypted rooms, coalesces with an in-flight prepare for the same room43
+ pub async fn prepare_to_send(&self, room_id: OwnedRoomId) -> Result<(), Error>;44
+}45
+```46
+47
+malice would call it when the user starts typing in a room (alongside a typing notice) and probably when a room is opened. A later send then finds members synced, device lists fresh and the outbound session already shared, so only the `/send` PUT remains. Correctness is unchanged, since the send path still runs `ensure_room_encryption_ready` and redoes whatever went stale in between (a member joining after the prepare, say).48
+49
+Suggested route, in order of preference50
+51
+1. Upstream a small matrix-rust-sdk PR making `Room::preshare_room_key` public, or adding `pub async fn Room::prepare_encryption(&self)` that wraps `ensure_room_encryption_ready`. The TODO invites exactly this. Until it is released macltok could carry it via a `[patch.crates-io]` git fork, if that fits its dependency policy.52
+2. A partial warm-up using only public API while the PR is pending. `sync_members()` when not synced, then `request_user_identity` for members whose device lists are unknown, capped (members can be many and each query is a federated round trip). This removes step 1 and the `/keys/query` part of step 2 from the send path, which is the part federation lag hurts most. Steps 3 (`/keys/claim` and the to-device share) stay on the send path. It does not know which users are dirty (only `tracked_users()` is public), so it can over-query. Worth it only if option 1 will take a while.53
+54
+malice will pair it with the existing `ClientHandle::set_typing(room_id, bool)` (`transport.rs:357`), so no new typing API is needed.55
+56
+## Not requested57
+58
+A "send without waiting for fresh device lists" mode. It would encrypt to stale device lists, so new members and new devices miss the key and see undecryptable messages. Pre-sharing gives the same latency without that.@@ -0,0 +1,127 @@
1
+# Malice2
+3
+Malice is a hosted Matrix client with a Rust backend built on macltok and a4
+Svelte frontend. The Nix package serves both the API and the built frontend.5
+6
+## NixOS module7
+8
+Add this repository to your NixOS flake inputs, then include its module:9
+10
+```nix11
+# In your flake.nix12
+inputs.malice.url = "git+file:///absolute/path/to/malice";13
+14
+# In the modules list for your nixosSystem:15
+malice.nixosModules.default16
+```17
+18
+Use a published Git URL instead of the local path if you deploy from another19
+machine. For a local Git flake, add new source files to Git before rebuilding;20
+Nix does not include untracked files.21
+22
+Enable the service in your NixOS configuration. For access from another machine,23
+put it behind an HTTPS reverse proxy; the service binds to localhost and uses24
+Secure session cookies by default:25
+26
+```nix27
+{28
+ services.malice.enable = true;29
+ # Optional: services.malice.port = 3000;30
+31
+ services.nginx.enable = true;32
+ networking.firewall.allowedTCPPorts = [ 80 443 ];33
+ services.nginx.virtualHosts."chat.example.org" = {34
+ enableACME = true;35
+ forceSSL = true;36
+ locations."/" = {37
+ proxyPass = "http://127.0.0.1:3000";38
+ extraConfig = ''39
+ proxy_set_header Host $host;40
+ proxy_buffering off; # Keep event-stream updates timely.41
+ '';42
+ };43
+ };44
+ security.acme.acceptTerms = true;45
+ security.acme.defaults.email = "you@example.org";46
+}47
+```48
+49
+Replace the domain and ACME email, then rebuild your NixOS configuration as50
+usual. Check `systemctl status malice` and visit `https://chat.example.org/`.51
+The module stores the master key and sessions in `/var/lib/malice`; back up that52
+entire directory together. Its `listenAddress`, `port`, `package`, and53
+`openFirewall` options can be overridden. Do not expose the HTTP listener54
+without a trusted TLS proxy.55
+56
+## Client behavior57
+58
+The home list shows non-space rooms (channels); Matrix spaces have their own59
+navigation and show their advertised child rooms. A Matrix homeserver is the60
+network address used at sign-in, not a space. Sending a message returns a queued61
+command, then the timeline shows whether delivery is queued, sending, confirmed62
+by the server, or failed. Opening a room loads its timeline; scrolling up loads63
+older history. Images and avatars are served through authenticated, bounded64
+media endpoints; SVG content is not rendered. Link previews use URL text only65
+and do not fetch third-party pages.66
+67
+The Security panel accepts incoming device-verification requests and supports68
+emoji/number comparison, confirmation, rejection, and recovery-key entry.69
+Recovery keys and encryption state are per Matrix account; preserve the local70
+session data when migrating an installation.71
+72
+## Development73
+74
+From the repository root:75
+76
+```sh77
+nix develop78
+npm --prefix web ci79
+```80
+81
+For live frontend changes, start these in separate terminals inside `nix develop`:82
+83
+```sh84
+MALICE_SESSION_COOKIE_SECURE=false cargo run --bin malice85
+npm --prefix web run dev86
+```87
+88
+Open `http://127.0.0.1:5173/`. Vite proxies `/api` to the Rust server on port89
+3000. The cookie override is for local HTTP development only. By default,90
+Malice accepts HTTPS homeservers; set `MALICE_ALLOW_HTTP_HOMESERVER=true` only91
+when testing against a trusted local HTTP homeserver.92
+93
+To serve the built frontend directly from Rust instead of using Vite:94
+95
+```sh96
+npm --prefix web run build97
+MALICE_SESSION_COOKIE_SECURE=false cargo run --bin malice98
+```99
+100
+Open `http://127.0.0.1:3000/`. Rust uses `web/dist` unless `MALICE_WEB_DIR`101
+points at another built frontend directory. Local session data defaults to102
+`local-data/`; set `MALICE_DATA_DIR` to use another directory. Keep its103
+`master.key` and session files private.104
+105
+## Testing106
+107
+In `nix develop`, after `npm --prefix web ci`:108
+109
+```sh110
+cargo fmt -- --check111
+cargo clippy --all-targets -- -D warnings112
+cargo test --all-targets113
+npm --prefix web exec -- tsc --noEmit -p web/tsconfig.json114
+npm --prefix web run build115
+```116
+117
+The Rust integration test uses a mocked Matrix homeserver; it does not require118
+live account credentials. To verify the deployable package and NixOS module:119
+120
+```sh121
+nix build .#server122
+nix flake check123
+```124
+125
+`nix build` creates `result/bin/malice` with the frontend bundled. On a NixOS126
+host, `curl -fsS http://127.0.0.1:3000/api/health` checks the local backend;127
+opening the proxied URL checks the frontend and TLS setup.@@ -0,0 +1,18 @@
1
+[package]2
+name = "malice-core"3
+version = "0.1.0"4
+edition = "2024"5
+6
+[lib]7
+name = "malice_core"8
+9
+[dependencies]10
+macltok = { workspace = true, features = ["serde", "mirror"] }11
+futures-util = { version = "0.3", default-features = false, features = ["std"] }12
+ruma = { workspace = true }13
+serde = { workspace = true }14
+serde_json = { workspace = true }15
+http = { workspace = true }16
+17
+[lints]18
+workspace = true@@ -0,0 +1,65 @@
1
+use serde::Serialize;2
+3
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]4
+pub enum Failure {5
+ BadRequest,6
+ NotFound,7
+ Conflict,8
+ Busy,9
+ TooLarge,10
+ Unsupported,11
+ EngineStopped,12
+ Upstream,13
+}14
+15
+#[derive(Debug, Clone, Serialize)]16
+pub struct CoreError {17
+ pub kind: Failure,18
+ pub message: String,19
+}20
+21
+impl CoreError {22
+ pub fn new(kind: Failure, message: impl Into<String>) -> Self {23
+ Self {24
+ kind,25
+ message: message.into(),26
+ }27
+ }28
+29
+ pub fn bad_request(message: impl Into<String>) -> Self {30
+ Self::new(Failure::BadRequest, message)31
+ }32
+33
+ pub fn not_found(message: impl Into<String>) -> Self {34
+ Self::new(Failure::NotFound, message)35
+ }36
+37
+ pub fn unsupported(message: impl Into<String>) -> Self {38
+ Self::new(Failure::Unsupported, message)39
+ }40
+}41
+42
+impl From<macltok::Error> for CoreError {43
+ fn from(error: macltok::Error) -> Self {44
+ use macltok::ErrorKind;45
+ let kind = match error.kind {46
+ ErrorKind::Interrupted => {47
+ return Self::new(48
+ Failure::EngineStopped,49
+ "the sync engine stopped; sign in again",50
+ );51
+ }52
+ ErrorKind::QueueFull | ErrorKind::RoomBusy => Failure::Busy,53
+ ErrorKind::RoomUnknown | ErrorKind::MediaUnknown | ErrorKind::MediaUnavailable => {54
+ Failure::NotFound55
+ }56
+ ErrorKind::MediaTooLarge => Failure::TooLarge,57
+ ErrorKind::Invalid | ErrorKind::EmptyBody => Failure::BadRequest,58
+ ErrorKind::RoomNotOpen59
+ | ErrorKind::TimelineNotOpen60
+ | ErrorKind::VerificationInactive => Failure::Conflict,61
+ _ => Failure::Upstream,62
+ };63
+ Self::new(kind, error.message)64
+ }65
+}@@ -0,0 +1,179 @@
1
+use std::collections::HashMap;2
+3
+use futures_util::{Stream, StreamExt, stream};4
+use macltok::{5
+ ConnectionState, EncryptionState, MirrorHandle, RoomSummary, SessionStatus, State, StateChange,6
+ TimelineChange, TimelineId, TimelineItem, UploadOutcome, VerificationState, imbl::Vector,7
+};8
+use serde::Serialize;9
+10
+#[derive(Debug, Clone, Serialize)]11
+#[serde(tag = "type", rename_all = "snake_case")]12
+pub enum WireEvent {13
+ Snapshot {14
+ rooms: Vec<RoomSummary>,15
+ timelines: HashMap<String, Vector<TimelineItem>>,16
+ connection: String,17
+ encryption: Option<EncryptionState>,18
+ verification: Option<VerificationState>,19
+ },20
+ Rooms {21
+ rooms: Vec<RoomSummary>,22
+ },23
+ Timeline {24
+ room_id: String,25
+ items: Vector<TimelineItem>,26
+ },27
+ TimelineClosed {28
+ room_id: String,29
+ },30
+ Connection {31
+ state: String,32
+ },33
+ Encryption {34
+ state: EncryptionState,35
+ },36
+ Verification {37
+ state: VerificationState,38
+ },39
+ Error {40
+ message: String,41
+ },42
+ /// Emitted directly by a Host when an account's engine ends; the hosted43
+ /// SSE stream never produces this, since it has no equivalent of an44
+ /// account disappearing out from under a live subscription.45
+ SessionEnded {46
+ retain: SessionRetain,47
+ },48
+}49
+50
+/// `macltok::Retain`'s wire form. macltok's own enum has no `rename_all`,51
+/// so this exists to give it one.52
+#[derive(Debug, Clone, Copy, Serialize)]53
+#[serde(rename_all = "snake_case")]54
+pub enum SessionRetain {55
+ Nothing,56
+ Store,57
+ RecordAndStore,58
+}59
+60
+impl From<macltok::Retain> for SessionRetain {61
+ fn from(retain: macltok::Retain) -> Self {62
+ match retain {63
+ macltok::Retain::Nothing => Self::Nothing,64
+ macltok::Retain::Store => Self::Store,65
+ macltok::Retain::RecordAndStore => Self::RecordAndStore,66
+ }67
+ }68
+}69
+70
+/// A fresh subscriber's first frame, built from the mirror's current state.71
+pub fn snapshot_event(state: &State) -> WireEvent {72
+ let timelines = state73
+ .timelines74
+ .iter()75
+ .filter_map(|(timeline_id, timeline)| match timeline_id {76
+ TimelineId::Room(room_id) => {77
+ Some((room_id.as_str().to_owned(), timeline.items.clone()))78
+ }79
+ _ => None,80
+ })81
+ .collect();82
+ WireEvent::Snapshot {83
+ rooms: state.rooms.iter().cloned().collect(),84
+ timelines,85
+ connection: connection_label(&state.connection),86
+ encryption: state.encryption.clone(),87
+ verification: state.verification.clone(),88
+ }89
+}90
+91
+fn error(message: &str) -> WireEvent {92
+ WireEvent::Error {93
+ message: message.to_owned(),94
+ }95
+}96
+97
+pub fn wire_events(change: &StateChange) -> Vec<WireEvent> {98
+ match change {99
+ StateChange::State(state) => {100
+ let mut events = vec![snapshot_event(state)];101
+ if let Some(message) = &state.session_save_failure {102
+ events.push(WireEvent::Error {103
+ message: message.clone(),104
+ });105
+ }106
+ events107
+ }108
+ StateChange::Rooms(rooms) => vec![WireEvent::Rooms {109
+ rooms: rooms.iter().cloned().collect(),110
+ }],111
+ StateChange::Timeline {112
+ timeline_id: TimelineId::Room(room_id),113
+ change,114
+ } => match change {115
+ TimelineChange::Reset { items } | TimelineChange::Changed { items, .. } => {116
+ vec![WireEvent::Timeline {117
+ room_id: room_id.as_str().to_owned(),118
+ items: items.clone(),119
+ }]120
+ }121
+ TimelineChange::Closed => vec![WireEvent::TimelineClosed {122
+ room_id: room_id.as_str().to_owned(),123
+ }],124
+ _ => vec![],125
+ },126
+ StateChange::Timeline { .. } => vec![],127
+ StateChange::Connection(state) => vec![WireEvent::Connection {128
+ state: connection_label(state),129
+ }],130
+ StateChange::Encryption(state) => vec![WireEvent::Encryption {131
+ state: state.clone(),132
+ }],133
+ StateChange::Verification(state) => vec![WireEvent::Verification {134
+ state: state.clone(),135
+ }],136
+ StateChange::Failed(failure) => vec![error(&failure.message)],137
+ StateChange::SessionSaveFailed(_) => {138
+ vec![error(139
+ "saving the session failed, you may need to sign in again",140
+ )]141
+ }142
+ StateChange::Session(143
+ SessionStatus::AccessRevoked | SessionStatus::SoftLoggedOut { .. },144
+ ) => {145
+ vec![error("session rejected by the server; sign in again")]146
+ }147
+ StateChange::UploadFinished { outcome, .. } => match outcome {148
+ UploadOutcome::Sent { .. } => vec![],149
+ UploadOutcome::Cancelled => vec![error("upload cancelled")],150
+ UploadOutcome::RoomForgotten => vec![error("upload cancelled, room forgotten")],151
+ UploadOutcome::Missed => vec![error("upload status unknown")],152
+ _ => vec![error("upload did not complete")],153
+ },154
+ StateChange::Upload { upload, .. } => match &upload.failure {155
+ Some(failure) if !failure.will_retry => vec![error("upload failed")],156
+ _ => vec![],157
+ },158
+ _ => vec![],159
+ }160
+}161
+162
+/// Snapshot first, then updates with no gap. Ends once the mirror stops.163
+pub fn subscribe(mirror: &MirrorHandle) -> impl Stream<Item = WireEvent> + use<> {164
+ mirror165
+ .subscribe()166
+ .flat_map(|update| stream::iter(wire_events(&update.change)))167
+}168
+169
+pub fn connection_label(state: &ConnectionState) -> String {170
+ match state {171
+ ConnectionState::Idle => "idle",172
+ ConnectionState::Connecting => "connecting",173
+ ConnectionState::Online => "online",174
+ ConnectionState::Offline => "offline",175
+ ConnectionState::Stopped => "stopped",176
+ _ => "unknown",177
+ }178
+ .to_owned()179
+}@@ -0,0 +1,6 @@
1
+pub mod error;2
+pub mod feed;3
+pub mod login;4
+pub mod media;5
+pub mod parse;6
+pub mod request;@@ -0,0 +1,95 @@
1
+use serde::Deserialize;2
+3
+use crate::error::CoreError;4
+5
+#[derive(Debug, Deserialize)]6
+pub struct LoginInput {7
+ #[serde(default)]8
+ pub homeserver: String,9
+ #[serde(default)]10
+ pub username: String,11
+ #[serde(default)]12
+ pub password: String,13
+}14
+15
+impl LoginInput {16
+ pub fn validate(self, allow_http: bool) -> Result<LoginInput, CoreError> {17
+ let homeserver = normalise_homeserver(&self.homeserver, allow_http)?;18
+ let username = self.username.trim();19
+ if username.is_empty() {20
+ return Err(CoreError::bad_request("username is required"));21
+ }22
+ if self.password.is_empty() {23
+ return Err(CoreError::bad_request("password is required"));24
+ }25
+ Ok(LoginInput {26
+ homeserver,27
+ username: username.to_owned(),28
+ password: self.password,29
+ })30
+ }31
+}32
+33
+pub fn normalise_homeserver(raw: &str, allow_http: bool) -> Result<String, CoreError> {34
+ let trimmed = raw.trim();35
+ if trimmed.is_empty() {36
+ return Err(CoreError::bad_request("homeserver is required"));37
+ }38
+ if trimmed.contains(char::is_whitespace) {39
+ return Err(CoreError::bad_request("homeserver must not contain spaces"));40
+ }41
+ let with_scheme = if trimmed.contains("://") {42
+ trimmed.to_owned()43
+ } else {44
+ format!("https://{trimmed}")45
+ };46
+ let uri: http::Uri = with_scheme47
+ .parse()48
+ .map_err(|_| CoreError::bad_request("invalid homeserver"))?;49
+ let scheme = uri50
+ .scheme_str()51
+ .ok_or_else(|| CoreError::bad_request("invalid homeserver"))?;52
+ if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {53
+ return Err(CoreError::bad_request("homeserver must use http(s)"));54
+ }55
+ let authority = uri56
+ .authority()57
+ .ok_or_else(|| CoreError::bad_request("homeserver must include a host"))?;58
+ if authority.as_str().contains('@') || authority.host().is_empty() {59
+ return Err(CoreError::bad_request("homeserver must include a host"));60
+ }61
+ if scheme.eq_ignore_ascii_case("http") {62
+ let host = authority.host().trim_end_matches('.');63
+ let loopback = host.eq_ignore_ascii_case("localhost")64
+ || host == "127.0.0.1"65
+ || host == "[::1]"66
+ || host == "::1";67
+ if !(allow_http && loopback) {68
+ return Err(CoreError::bad_request(69
+ "insecure http homeservers are not allowed",70
+ ));71
+ }72
+ }73
+ Ok(with_scheme)74
+}75
+76
+/// macltok's `AccountError` displays only its context, the homeserver's answer is in the sources77
+pub fn describe_error(error: &(dyn std::error::Error + 'static)) -> String {78
+ let mut message = error.to_string();79
+ let mut source = error.source();80
+ while let Some(cause) = source {81
+ message.push_str(", ");82
+ message.push_str(&cause.to_string());83
+ source = cause.source();84
+ }85
+ message86
+}87
+88
+pub fn sanitise_auth_error(message: &str, password: &str) -> String {89
+ let message = if password.is_empty() {90
+ message.to_owned()91
+ } else {92
+ message.replace(password, "[redacted]")93
+ };94
+ message.chars().take(400).collect()95
+}@@ -0,0 +1,45 @@
1
+pub const AVATAR_SIZE: u32 = 256;2
+pub const THUMBNAIL_SIZE: u32 = 800;3
+4
+pub fn image_content_type(bytes: &[u8]) -> Option<&'static str> {5
+ if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {6
+ Some("image/png")7
+ } else if bytes.starts_with(b"\xff\xd8\xff") {8
+ Some("image/jpeg")9
+ } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {10
+ Some("image/gif")11
+ } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {12
+ Some("image/webp")13
+ } else if bytes.len() >= 1214
+ && &bytes[4..8] == b"ftyp"15
+ && (&bytes[8..12] == b"avif" || &bytes[8..12] == b"avis")16
+ {17
+ Some("image/avif")18
+ } else {19
+ None20
+ }21
+}22
+23
+pub fn attachment_content_type(bytes: &[u8]) -> Option<&'static str> {24
+ if let Some(image) = image_content_type(bytes) {25
+ return Some(image);26
+ }27
+ if bytes.starts_with(b"\x1a\x45\xdf\xa3") {28
+ Some("video/webm")29
+ } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE" {30
+ Some("audio/wav")31
+ } else if bytes.starts_with(b"OggS") {32
+ Some("audio/ogg")33
+ } else if bytes.starts_with(b"ID3") || bytes.starts_with(b"\xff\xfb") {34
+ Some("audio/mpeg")35
+ } else if bytes.len() >= 1236
+ && &bytes[4..8] == b"ftyp"37
+ && [b"isom", b"iso2", b"mp41", b"mp42", b"avc1"]38
+ .iter()39
+ .any(|brand| &bytes[8..12] == brand.as_slice())40
+ {41
+ Some("video/mp4")42
+ } else {43
+ None44
+ }45
+}@@ -0,0 +1,25 @@
1
+use crate::error::CoreError;2
+3
+pub fn parse_room_id(raw: &str) -> Result<ruma::OwnedRoomId, CoreError> {4
+ let trimmed = raw.trim();5
+ if trimmed.is_empty() {6
+ return Err(CoreError::bad_request("room_id is required"));7
+ }8
+ ruma::OwnedRoomId::try_from(trimmed).map_err(|_| CoreError::bad_request("invalid room_id"))9
+}10
+11
+pub fn parse_event_id(raw: &str) -> Result<ruma::OwnedEventId, CoreError> {12
+ let trimmed = raw.trim();13
+ if trimmed.is_empty() {14
+ return Err(CoreError::bad_request("event_id is required"));15
+ }16
+ ruma::OwnedEventId::try_from(trimmed).map_err(|_| CoreError::bad_request("invalid event_id"))17
+}18
+19
+pub fn parse_room_or_alias(raw: &str) -> Result<ruma::OwnedRoomOrAliasId, CoreError> {20
+ let trimmed = raw.trim();21
+ if trimmed.is_empty() {22
+ return Err(CoreError::bad_request("room is required"));23
+ }24
+ ruma::OwnedRoomOrAliasId::try_from(trimmed).map_err(|_| CoreError::bad_request("invalid room"))25
+}@@ -0,0 +1,336 @@
1
+use macltok::{2
+ AttachmentUpload, ClientHandle, CreateRoomOptions, MediaId, MediaSize, MemberFilter,3
+ MessageContent, MirrorHandle, TimelineId, VerificationAction, VerificationTarget,4
+};5
+use serde::Deserialize;6
+use serde_json::json;7
+8
+use crate::{9
+ error::CoreError,10
+ media::{AVATAR_SIZE, THUMBNAIL_SIZE, attachment_content_type, image_content_type},11
+ parse::{parse_event_id, parse_room_id, parse_room_or_alias},12
+};13
+14
+#[derive(Debug, Deserialize)]15
+#[serde(tag = "op", rename_all = "snake_case")]16
+pub enum Request {17
+ Rooms,18
+ Members {19
+ #[serde(default)]20
+ room_id: String,21
+ },22
+ Open {23
+ #[serde(default)]24
+ room_id: String,25
+ },26
+ Close {27
+ #[serde(default)]28
+ room_id: String,29
+ },30
+ Send {31
+ #[serde(default)]32
+ room_id: String,33
+ #[serde(default)]34
+ body: String,35
+ },36
+ Upload {37
+ room_id: String,38
+ filename: String,39
+ mimetype: String,40
+ #[serde(skip)]41
+ bytes: Vec<u8>,42
+ },43
+ Paginate {44
+ #[serde(default)]45
+ room_id: String,46
+ },47
+ Join {48
+ #[serde(default)]49
+ room: String,50
+ },51
+ Create {52
+ #[serde(default)]53
+ name: String,54
+ #[serde(default)]55
+ topic: Option<String>,56
+ },57
+ Leave {58
+ #[serde(default)]59
+ room_id: String,60
+ },61
+ React {62
+ #[serde(default)]63
+ room_id: String,64
+ #[serde(default)]65
+ event_id: String,66
+ #[serde(default)]67
+ key: String,68
+ },69
+ Read {70
+ #[serde(default)]71
+ room_id: String,72
+ #[serde(default)]73
+ event_id: String,74
+ },75
+ Profile,76
+ SpaceChildren {77
+ #[serde(default)]78
+ room_id: String,79
+ },80
+ Encryption,81
+ Recover {82
+ recovery_key: String,83
+ },84
+ StartVerification,85
+ Verify {86
+ action: String,87
+ },88
+ Avatar {89
+ mxc: String,90
+ },91
+ Media {92
+ media_id: String,93
+ size: String,94
+ },95
+ Typing {96
+ #[serde(default)]97
+ room_id: String,98
+ #[serde(default)]99
+ typing: bool,100
+ },101
+}102
+103
+impl Request {104
+ pub fn upload(room_id: String, filename: String, mimetype: String, bytes: Vec<u8>) -> Request {105
+ Request::Upload {106
+ room_id,107
+ filename,108
+ mimetype,109
+ bytes,110
+ }111
+ }112
+}113
+114
+pub enum Reply {115
+ Done,116
+ Json(serde_json::Value),117
+ Avatar {118
+ bytes: Vec<u8>,119
+ content_type: &'static str,120
+ },121
+ Attachment {122
+ bytes: Vec<u8>,123
+ content_type: Option<&'static str>,124
+ },125
+}126
+127
+pub async fn dispatch(128
+ handle: &ClientHandle,129
+ mirror: &MirrorHandle,130
+ request: Request,131
+) -> Result<Reply, CoreError> {132
+ match request {133
+ Request::Rooms => {134
+ let rooms = handle.rooms().await;135
+ Ok(Reply::Json(json!({ "rooms": rooms })))136
+ }137
+ Request::Members { room_id } => {138
+ let room_id = parse_room_id(&room_id)?;139
+ let members = handle.members(&room_id, MemberFilter::Active).await?;140
+ Ok(Reply::Json(json!({ "members": members })))141
+ }142
+ Request::Open { room_id } => {143
+ let room_id = parse_room_id(&room_id)?;144
+ handle.open_room(room_id).await?;145
+ Ok(Reply::Done)146
+ }147
+ Request::Close { room_id } => {148
+ let room_id = parse_room_id(&room_id)?;149
+ handle.close_room(room_id).await?;150
+ Ok(Reply::Done)151
+ }152
+ Request::Send { room_id, body } => {153
+ let room_id = parse_room_id(&room_id)?;154
+ if body.trim().is_empty() {155
+ return Err(CoreError::bad_request("body must not be empty"));156
+ }157
+ if body.len() > 64 * 1024 {158
+ return Err(CoreError::bad_request("body is too large"));159
+ }160
+ handle161
+ .send_message(TimelineId::Room(room_id), MessageContent::text(body))162
+ .await?;163
+ Ok(Reply::Done)164
+ }165
+ Request::Upload {166
+ room_id,167
+ filename,168
+ mimetype,169
+ bytes,170
+ } => {171
+ let room_id = parse_room_id(&room_id)?;172
+ if bytes.is_empty() || bytes.len() > 20 * 1024 * 1024 {173
+ return Err(CoreError::bad_request(174
+ "file must be between 1 byte and 20 MiB",175
+ ));176
+ }177
+ let upload = AttachmentUpload::new(room_id, filename, mimetype, bytes);178
+ handle.upload(upload).await?;179
+ Ok(Reply::Done)180
+ }181
+ Request::Paginate { room_id } => {182
+ let room_id = parse_room_id(&room_id)?;183
+ handle.paginate(TimelineId::Room(room_id), 30).await?;184
+ Ok(Reply::Done)185
+ }186
+ Request::Join { room } => {187
+ let room = parse_room_or_alias(&room)?;188
+ let room_id = handle.join_room(room, Vec::new()).await?;189
+ Ok(Reply::Json(json!({ "room_id": room_id })))190
+ }191
+ Request::Create { name, topic } => {192
+ let name = name.trim();193
+ if name.is_empty() {194
+ return Err(CoreError::bad_request("name is required"));195
+ }196
+ if name.len() > 255 {197
+ return Err(CoreError::bad_request("name is too long"));198
+ }199
+ if topic.as_deref().is_some_and(|topic| topic.len() > 1024) {200
+ return Err(CoreError::bad_request("topic is too long"));201
+ }202
+ let mut options = CreateRoomOptions::default().with_name(name);203
+ if let Some(topic) = topic204
+ .as_deref()205
+ .map(str::trim)206
+ .filter(|topic| !topic.is_empty())207
+ {208
+ options = options.with_topic(topic);209
+ }210
+ let room_id = handle.create_room(options).await?;211
+ Ok(Reply::Json(json!({ "room_id": room_id })))212
+ }213
+ Request::Leave { room_id } => {214
+ let room_id = parse_room_id(&room_id)?;215
+ handle.leave_room(room_id).await?;216
+ Ok(Reply::Done)217
+ }218
+ Request::React {219
+ room_id,220
+ event_id,221
+ key,222
+ } => {223
+ let room_id = parse_room_id(&room_id)?;224
+ let event_id = parse_event_id(&event_id)?;225
+ let key = key.trim();226
+ if key.is_empty() || key.chars().count() > 32 {227
+ return Err(CoreError::bad_request("key must be 1..32 characters"));228
+ }229
+ handle230
+ .toggle_reaction(TimelineId::Room(room_id), event_id, key)231
+ .await?;232
+ Ok(Reply::Done)233
+ }234
+ Request::Read { room_id, event_id } => {235
+ let room_id = parse_room_id(&room_id)?;236
+ let event_id = parse_event_id(&event_id)?;237
+ handle238
+ .mark_read(TimelineId::Room(room_id), event_id)239
+ .await?;240
+ Ok(Reply::Done)241
+ }242
+ Request::Profile => {243
+ let profile = handle.own_profile().await?;244
+ Ok(Reply::Json(json!({245
+ "display_name": profile.display_name,246
+ "avatar_url": profile.avatar,247
+ })))248
+ }249
+ Request::SpaceChildren { room_id } => {250
+ let room_id = parse_room_id(&room_id)?;251
+ let rooms = handle.rooms().await;252
+ match rooms.into_iter().find(|room| room.room_id == room_id) {253
+ Some(space) => Ok(Reply::Json(json!({ "room_ids": space.children }))),254
+ None => Err(CoreError::not_found(255
+ "room is not available in the local store",256
+ )),257
+ }258
+ }259
+ Request::Encryption => {260
+ let state = mirror.snapshot();261
+ Ok(Reply::Json(json!({262
+ "state": state.encryption,263
+ "verification": state.verification,264
+ })))265
+ }266
+ Request::Recover { recovery_key } => {267
+ if recovery_key.trim().is_empty() {268
+ return Err(CoreError::bad_request("recovery_key is required"));269
+ }270
+ handle.recover(recovery_key).await?;271
+ Ok(Reply::Done)272
+ }273
+ Request::StartVerification => {274
+ handle275
+ .start_verification(VerificationTarget::OwnIdentity)276
+ .await?;277
+ Ok(Reply::Done)278
+ }279
+ Request::Verify { action } => {280
+ let action = match action.as_str() {281
+ "Accept" => VerificationAction::Accept,282
+ "Confirm" => VerificationAction::Confirm,283
+ "Reject" => VerificationAction::Reject,284
+ "Cancel" => VerificationAction::Cancel,285
+ _ => return Err(CoreError::bad_request("invalid verification action")),286
+ };287
+ handle.verify(action).await?;288
+ Ok(Reply::Done)289
+ }290
+ Request::Avatar { mxc } => {291
+ let mxc_ref = <&ruma::MxcUri>::from(mxc.as_str());292
+ if !mxc_ref.is_valid() {293
+ return Err(CoreError::bad_request("invalid mxc URI"));294
+ }295
+ let size = MediaSize::Thumbnail {296
+ width: AVATAR_SIZE,297
+ height: AVATAR_SIZE,298
+ };299
+ let bytes = handle.mxc_media(mxc_ref, size).await?.bytes.to_vec();300
+ let Some(content_type) = image_content_type(&bytes) else {301
+ return Err(CoreError::unsupported(302
+ "MXC content is not a supported image",303
+ ));304
+ };305
+ Ok(Reply::Avatar {306
+ bytes,307
+ content_type,308
+ })309
+ }310
+ Request::Media { media_id, size } => {311
+ if media_id.is_empty() || media_id.len() > 512 {312
+ return Err(CoreError::bad_request("invalid media_id"));313
+ }314
+ let media_id = MediaId::from(media_id);315
+ let size = match size.as_str() {316
+ "Thumbnail" => MediaSize::Thumbnail {317
+ width: THUMBNAIL_SIZE,318
+ height: THUMBNAIL_SIZE,319
+ },320
+ "Full" => MediaSize::Full,321
+ _ => return Err(CoreError::bad_request("invalid size")),322
+ };323
+ let bytes = handle.media(&media_id, size).await?.bytes.to_vec();324
+ let content_type = attachment_content_type(&bytes);325
+ Ok(Reply::Attachment {326
+ bytes,327
+ content_type,328
+ })329
+ }330
+ Request::Typing { room_id, typing } => {331
+ let room_id = parse_room_id(&room_id)?;332
+ handle.set_typing(room_id, typing).await?;333
+ Ok(Reply::Done)334
+ }335
+ }336
+}@@ -0,0 +1,40 @@
1
+[package]2
+name = "malice"3
+version = "0.1.0"4
+edition = "2024"5
+6
+[[bin]]7
+name = "malice"8
+path = "src/main.rs"9
+10
+[lib]11
+name = "malice"12
+path = "src/lib.rs"13
+14
+[dependencies]15
+malice-core = { path = "../malice-core" }16
+macltok = { workspace = true, features = ["sqlite", "serde", "mirror"] }17
+axum = { version = "0.8", features = ["macros", "multipart"] }18
+futures-util = { version = "0.3", default-features = false, features = ["std"] }19
+hex = "0.4"20
+listenfd = "1"21
+rand_core = { version = "0.6", features = ["getrandom"] }22
+ruma = { workspace = true }23
+serde = { workspace = true }24
+serde_json = { workspace = true }25
+sha2 = "0.10"26
+tokio = { workspace = true, features = ["full"] }27
+tower-http = { version = "0.6", features = ["fs", "trace"] }28
+tracing = { workspace = true }29
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }30
+31
+[dev-dependencies]32
+http-body-util = "0.1"33
+tower = { version = "0.5", features = ["util"] }34
+matrix-sdk = { version = "0.18", default-features = false, features = ["testing"] }35
+reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls"] }36
+tempfile = "3"37
+wiremock = "0.6"38
+39
+[lints]40
+workspace = true@@ -0,0 +1,111 @@
1
+use axum::response::Json;2
+use axum::{3
+ extract::Request,4
+ http::{HeaderMap, Method, StatusCode, header},5
+ middleware::Next,6
+ response::{IntoResponse, Response},7
+};8
+use serde_json::json;9
+10
+pub const COOKIE_NAME: &str = "malice_session";11
+const COOKIE_MAX_AGE: u32 = 2_592_000;12
+13
+/// Parse the opaque session id from the `Cookie` header.14
+///15
+/// Returns `None` when the cookie is absent or malformed. The value is16
+/// restricted to a safe alphabet so it can be used as a file name.17
+pub fn session_sid(headers: &HeaderMap) -> Option<String> {18
+ for value in headers.get_all(header::COOKIE) {19
+ let Ok(raw) = value.to_str() else {20
+ continue;21
+ };22
+ for part in raw.split(';') {23
+ let part = part.trim();24
+ let Some((name, val)) = part.split_once('=') else {25
+ continue;26
+ };27
+ if name.trim() != COOKIE_NAME {28
+ continue;29
+ }30
+ let val = val.trim().trim_matches('"');31
+ if valid_sid(val) {32
+ return Some(val.to_owned());33
+ }34
+ }35
+ }36
+ None37
+}38
+39
+pub fn valid_sid(value: &str) -> bool {40
+ !value.is_empty()41
+ && value.len() <= 12842
+ && value43
+ .chars()44
+ .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')45
+}46
+47
+pub fn set_cookie_value(sid: &str, secure: bool) -> String {48
+ let mut cookie =49
+ format!("{COOKIE_NAME}={sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age={COOKIE_MAX_AGE}");50
+ if secure {51
+ cookie.push_str("; Secure");52
+ }53
+ cookie54
+}55
+56
+pub fn clear_cookie_value(secure: bool) -> String {57
+ let mut cookie = format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");58
+ if secure {59
+ cookie.push_str("; Secure");60
+ }61
+ cookie62
+}63
+64
+/// Reject cross-origin mutating API requests. Port is part of an origin:65
+/// another app on the same host must not be able to drive this user's cookie.66
+/// The dev proxy preserves Host, so its forwarded requests remain same-origin.67
+pub async fn csrf_guard(request: Request, next: Next) -> Response {68
+ let method = request.method().clone();69
+ let path = request.uri().path().to_owned();70
+ let is_api = path == "/api" || path.starts_with("/api/");71
+ let is_mutating = method != Method::GET && method != Method::HEAD && method != Method::OPTIONS;72
+ if !(is_api && is_mutating) {73
+ return next.run(request).await;74
+ }75
+ let headers = request.headers();76
+ // Host only: X-Forwarded-Host is client-controlled and must not decide77
+ // same-origin. The Host the backend received is what the browser used,78
+ // which is exactly what Origin must match.79
+ let host = headers80
+ .get(header::HOST)81
+ .and_then(|value| value.to_str().ok())82
+ .unwrap_or("");83
+ let origin = headers84
+ .get(header::ORIGIN)85
+ .and_then(|value| value.to_str().ok());86
+ let referer = headers87
+ .get(header::REFERER)88
+ .and_then(|value| value.to_str().ok());89
+ let allowed = match (origin, referer) {90
+ (Some(value), _) => same_authority(value, host),91
+ (None, Some(value)) => same_authority(value, host),92
+ (None, None) => true,93
+ };94
+ if !allowed {95
+ return (96
+ StatusCode::FORBIDDEN,97
+ Json(json!({"message": "origin check failed"})),98
+ )99
+ .into_response();100
+ }101
+ next.run(request).await102
+}103
+104
+fn same_authority(source: &str, host: &str) -> bool {105
+ source.parse::<axum::http::Uri>().ok().is_some_and(|uri| {106
+ matches!(uri.scheme_str(), Some("http" | "https"))107
+ && uri108
+ .authority()109
+ .is_some_and(|authority| authority.as_str().eq_ignore_ascii_case(host))110
+ })111
+}@@ -0,0 +1,110 @@
1
+use axum::{2
+ http::{HeaderMap, StatusCode, header},3
+ response::{IntoResponse, Response},4
+};5
+use malice_core::error::{CoreError, Failure};6
+use serde::Serialize;7
+use serde_json::json;8
+9
+use crate::csrf::clear_cookie_value;10
+11
+/// A JSON API failure: `{ "message": ... }` with a meaningful status code.12
+pub struct ApiError {13
+ status: StatusCode,14
+ message: String,15
+ /// When set, the response clears the session cookie (stale credential).16
+ clear_session: Option<bool>,17
+}18
+19
+impl ApiError {20
+ pub fn new(status: StatusCode, message: impl Into<String>) -> Self {21
+ Self {22
+ status,23
+ message: message.into(),24
+ clear_session: None,25
+ }26
+ }27
+28
+ pub fn bad_request(message: impl Into<String>) -> Self {29
+ Self::new(StatusCode::BAD_REQUEST, message)30
+ }31
+32
+ pub fn unauthorized(message: impl Into<String>) -> Self {33
+ Self::new(StatusCode::UNAUTHORIZED, message)34
+ }35
+36
+ /// 401 that also expires the session cookie.37
+ pub fn unauthorized_stale(message: impl Into<String>, cookie_secure: bool) -> Self {38
+ Self {39
+ status: StatusCode::UNAUTHORIZED,40
+ message: message.into(),41
+ clear_session: Some(cookie_secure),42
+ }43
+ }44
+45
+ pub fn not_found(message: impl Into<String>) -> Self {46
+ Self::new(StatusCode::NOT_FOUND, message)47
+ }48
+49
+ pub fn engine_stopped() -> Self {50
+ Self::new(51
+ StatusCode::BAD_GATEWAY,52
+ "the sync engine stopped; sign in again",53
+ )54
+ }55
+}56
+57
+impl From<CoreError> for ApiError {58
+ fn from(error: CoreError) -> Self {59
+ let status = match error.kind {60
+ Failure::BadRequest => StatusCode::BAD_REQUEST,61
+ Failure::NotFound => StatusCode::NOT_FOUND,62
+ Failure::Conflict => StatusCode::CONFLICT,63
+ Failure::Busy => StatusCode::TOO_MANY_REQUESTS,64
+ Failure::TooLarge => StatusCode::PAYLOAD_TOO_LARGE,65
+ Failure::Unsupported => StatusCode::UNSUPPORTED_MEDIA_TYPE,66
+ Failure::EngineStopped => return Self::engine_stopped(),67
+ Failure::Upstream => StatusCode::BAD_GATEWAY,68
+ };69
+ Self::new(status, error.message)70
+ }71
+}72
+73
+impl IntoResponse for ApiError {74
+ fn into_response(self) -> Response {75
+ let mut headers = HeaderMap::new();76
+ if let Some(secure) = self.clear_session77
+ && let Ok(value) = clear_cookie_value(secure).parse()78
+ {79
+ headers.insert(header::SET_COOKIE, value);80
+ }81
+ (82
+ self.status,83
+ headers,84
+ axum::Json(json!({ "message": self.message })),85
+ )86
+ .into_response()87
+ }88
+}89
+90
+/// 200 JSON response, optionally refreshing the session cookie.91
+pub fn json_ok<T: Serialize>(value: &T, set_cookie: Option<String>) -> Response {92
+ let mut headers = HeaderMap::new();93
+ if let Some(cookie) = set_cookie94
+ && let Ok(value) = cookie.parse()95
+ {96
+ headers.insert(header::SET_COOKIE, value);97
+ }98
+ (StatusCode::OK, headers, axum::Json(json!(value))).into_response()99
+}100
+101
+/// 204 response, optionally clearing the session cookie.102
+pub fn no_content(clear_cookie: Option<String>) -> Response {103
+ let mut headers = HeaderMap::new();104
+ if let Some(cookie) = clear_cookie105
+ && let Ok(value) = cookie.parse()106
+ {107
+ headers.insert(header::SET_COOKIE, value);108
+ }109
+ (StatusCode::NO_CONTENT, headers).into_response()110
+}@@ -0,0 +1,73 @@
1
+//! Idle-exit tracking for the socket-activated static-only server: counts2
+//! in-flight requests and the time of the last one, so the process can shut3
+//! itself down once nothing has happened for `MALICE_IDLE_EXIT_SECS`.4
+5
+use std::{6
+ sync::{7
+ Arc,8
+ atomic::{AtomicUsize, Ordering},9
+ },10
+ time::{Duration, Instant},11
+};12
+13
+use axum::{extract::State, middleware::Next};14
+15
+#[derive(Clone)]16
+pub struct IdleTracker {17
+ in_flight: Arc<AtomicUsize>,18
+ last_activity: Arc<std::sync::Mutex<Instant>>,19
+}20
+21
+impl IdleTracker {22
+ pub fn new() -> Self {23
+ Self {24
+ in_flight: Arc::new(AtomicUsize::new(0)),25
+ last_activity: Arc::new(std::sync::Mutex::new(Instant::now())),26
+ }27
+ }28
+29
+ fn touch(&self) {30
+ *self31
+ .last_activity32
+ .lock()33
+ .expect("idle tracker mutex poisoned") = Instant::now();34
+ }35
+36
+ /// Resolves once no request has started or finished, and none is still37
+ /// in flight, for at least `idle`.38
+ pub async fn wait_idle(&self, idle: Duration) {39
+ loop {40
+ tokio::time::sleep(idle.min(Duration::from_millis(200))).await;41
+ if self.in_flight.load(Ordering::SeqCst) != 0 {42
+ continue;43
+ }44
+ let elapsed = self45
+ .last_activity46
+ .lock()47
+ .expect("idle tracker mutex poisoned")48
+ .elapsed();49
+ if elapsed >= idle {50
+ return;51
+ }52
+ }53
+ }54
+}55
+56
+impl Default for IdleTracker {57
+ fn default() -> Self {58
+ Self::new()59
+ }60
+}61
+62
+pub async fn track(63
+ State(idle): State<IdleTracker>,64
+ request: axum::extract::Request,65
+ next: Next,66
+) -> axum::response::Response {67
+ idle.in_flight.fetch_add(1, Ordering::SeqCst);68
+ idle.touch();69
+ let response = next.run(request).await;70
+ idle.touch();71
+ idle.in_flight.fetch_sub(1, Ordering::SeqCst);72
+ response73
+}@@ -0,0 +1,5 @@
1
+pub mod csrf;2
+pub mod error;3
+pub mod idle;4
+pub mod routes;5
+pub mod state;@@ -0,0 +1,159 @@
1
+use std::{future::Future, net::SocketAddr, path::PathBuf, time::Duration};2
+3
+use axum::{Router, middleware};4
+use listenfd::ListenFd;5
+use malice::{6
+ idle::IdleTracker,7
+ routes::{router, static_router},8
+ state::{AppState, ensure_dirs, load_or_create_master_key},9
+};10
+11
+fn env_flag(name: &str, default: bool) -> bool {12
+ match std::env::var(name) {13
+ Ok(value) => {14
+ let value = value.trim().to_ascii_lowercase();15
+ match value.as_str() {16
+ "1" | "true" | "yes" | "on" => true,17
+ "0" | "false" | "no" | "off" => false,18
+ _ => default,19
+ }20
+ }21
+ Err(_) => default,22
+ }23
+}24
+25
+fn data_dir() -> PathBuf {26
+ std::env::var("MALICE_DATA_DIR")27
+ .map(PathBuf::from)28
+ .unwrap_or_else(|_| PathBuf::from("local-data"))29
+}30
+31
+fn bind_addr() -> SocketAddr {32
+ std::env::var("MALICE_BIND")33
+ .unwrap_or_else(|_| "127.0.0.1:3000".to_owned())34
+ .parse()35
+ .expect("MALICE_BIND must be host:port")36
+}37
+38
+fn idle_exit_secs() -> Option<u64> {39
+ std::env::var("MALICE_IDLE_EXIT_SECS")40
+ .ok()41
+ .and_then(|value| value.trim().parse().ok())42
+}43
+44
+fn web_dir() -> Option<String> {45
+ if let Ok(dir) = std::env::var("MALICE_WEB_DIR") {46
+ assert!(47
+ std::path::Path::new(&dir).join("index.html").is_file(),48
+ "MALICE_WEB_DIR must point to built frontend assets containing index.html"49
+ );50
+ return Some(dir);51
+ }52
+53
+ let local = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("web/dist");54
+ local55
+ .join("index.html")56
+ .is_file()57
+ .then(|| local.to_string_lossy().into_owned())58
+}59
+60
+/// Takes over a systemd-activated listener from fd 3 when present, so the61
+/// socket unit can hold the port before the process ever starts. Falls back62
+/// to a plain bind otherwise.63
+async fn bind_listener() -> tokio::net::TcpListener {64
+ let mut listenfd = ListenFd::from_env();65
+ if let Some(listener) = listenfd66
+ .take_tcp_listener(0)67
+ .expect("inspecting inherited sockets")68
+ {69
+ listener70
+ .set_nonblocking(true)71
+ .expect("setting inherited socket nonblocking");72
+ return tokio::net::TcpListener::from_std(listener)73
+ .expect("adopting inherited socket into tokio");74
+ }75
+ let addr = bind_addr();76
+ tracing::info!("listening on {addr}");77
+ tokio::net::TcpListener::bind(addr)78
+ .await79
+ .expect("binding the listener must succeed")80
+}81
+82
+/// Serves `app` on `listener`, exiting on ctrl-c or, when83
+/// `MALICE_IDLE_EXIT_SECS` is set, after that long with no request in flight84
+/// or started. `on_shutdown` runs after the exit signal either way.85
+async fn serve(86
+ listener: tokio::net::TcpListener,87
+ app: Router,88
+ on_shutdown: impl Future<Output = ()> + Send + 'static,89
+) {90
+ let idle = idle_exit_secs().map(|secs| (IdleTracker::new(), secs));91
+ let app = match &idle {92
+ Some((tracker, _)) => app.layer(middleware::from_fn_with_state(93
+ tracker.clone(),94
+ malice::idle::track,95
+ )),96
+ None => app,97
+ };98
+ axum::serve(listener, app)99
+ .with_graceful_shutdown(async move {100
+ match idle {101
+ Some((tracker, secs)) => {102
+ tokio::select! {103
+ _ = tokio::signal::ctrl_c() => {}104
+ _ = tracker.wait_idle(Duration::from_secs(secs)) => {105
+ tracing::info!("idle for {secs}s with no requests, exiting");106
+ }107
+ }108
+ }109
+ None => {110
+ let _ = tokio::signal::ctrl_c().await;111
+ }112
+ }113
+ on_shutdown.await;114
+ })115
+ .await116
+ .expect("serving must succeed");117
+}118
+119
+async fn run_hosted() {120
+ let data_dir = data_dir();121
+ ensure_dirs(&data_dir);122
+ let master_key =123
+ load_or_create_master_key(&data_dir).expect("loading the master key must succeed");124
+ let cookie_secure = env_flag("MALICE_SESSION_COOKIE_SECURE", true);125
+ let allow_http_homeserver = env_flag("MALICE_ALLOW_HTTP_HOMESERVER", false);126
+ let state = AppState::new(data_dir, master_key, cookie_secure, allow_http_homeserver);127
+ let app = router(state.clone(), web_dir());128
+ let listener = bind_listener().await;129
+ serve(130
+ listener,131
+ app,132
+ async move { state.shutdown_gracefully().await },133
+ )134
+ .await;135
+}136
+137
+async fn run_static_only() {138
+ let app = static_router(web_dir());139
+ let listener = bind_listener().await;140
+ serve(listener, app, async {}).await;141
+}142
+143
+#[tokio::main]144
+async fn main() {145
+ tracing_subscriber::fmt()146
+ .with_env_filter(147
+ tracing_subscriber::EnvFilter::try_from_default_env()148
+ .unwrap_or_else(|_| "malice=info,tower_http=info".into()),149
+ )150
+ .with_target(false)151
+ .compact()152
+ .init();153
+154
+ if env_flag("MALICE_STATIC_ONLY", false) {155
+ run_static_only().await;156
+ } else {157
+ run_hosted().await;158
+ }159
+}@@ -0,0 +1,835 @@
1
+use std::sync::Arc;2
+3
+use axum::{4
+ Router,5
+ extract::{DefaultBodyLimit, Multipart, Query, State},6
+ http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},7
+ middleware,8
+ middleware::Next,9
+ response::{IntoResponse, Response, Sse, sse::Event as SseEvent},10
+ routing::{any, get, post},11
+};12
+use futures_util::StreamExt;13
+use macltok::Retain;14
+use malice_core::{15
+ feed::subscribe,16
+ login::{LoginInput, sanitise_auth_error},17
+ request::{Reply, Request, dispatch},18
+};19
+use serde::{Deserialize, Serialize};20
+use tower_http::{21
+ services::{ServeDir, ServeFile},22
+ trace::TraceLayer,23
+};24
+25
+use crate::{26
+ csrf::{csrf_guard, session_sid, set_cookie_value},27
+ error::{ApiError, json_ok, no_content},28
+ state::{AppState, RestoreError, UserSession},29
+};30
+31
+#[derive(Debug, Deserialize)]32
+struct RoomIdBody {33
+ #[serde(default)]34
+ room_id: String,35
+}36
+37
+#[derive(Debug, Deserialize)]38
+struct SendBody {39
+ #[serde(default)]40
+ room_id: String,41
+ #[serde(default)]42
+ body: String,43
+}44
+45
+#[derive(Debug, Deserialize)]46
+struct PaginateBody {47
+ #[serde(default)]48
+ room_id: String,49
+}50
+51
+#[derive(Debug, Deserialize)]52
+struct JoinBody {53
+ #[serde(default)]54
+ room: String,55
+}56
+57
+#[derive(Debug, Deserialize)]58
+struct CreateBody {59
+ #[serde(default)]60
+ name: String,61
+ #[serde(default)]62
+ topic: Option<String>,63
+}64
+65
+#[derive(Debug, Deserialize)]66
+struct ReactBody {67
+ #[serde(default)]68
+ room_id: String,69
+ #[serde(default)]70
+ event_id: String,71
+ #[serde(default)]72
+ key: String,73
+}74
+75
+#[derive(Debug, Deserialize)]76
+struct ReadBody {77
+ #[serde(default)]78
+ room_id: String,79
+ #[serde(default)]80
+ event_id: String,81
+}82
+83
+#[derive(Debug, Deserialize)]84
+struct MembersQuery {85
+ #[serde(default)]86
+ room_id: String,87
+}88
+#[derive(Debug, Deserialize)]89
+struct RecoverBody {90
+ recovery_key: String,91
+}92
+93
+#[derive(Debug, Deserialize)]94
+struct VerifyBody {95
+ action: String,96
+}97
+98
+#[derive(Debug, Deserialize)]99
+struct MediaQuery {100
+ mxc: String,101
+}102
+#[derive(Debug, Deserialize)]103
+struct MessageMediaQuery {104
+ media_id: String,105
+ size: String,106
+}107
+108
+#[derive(Debug, Deserialize)]109
+struct TypingBody {110
+ #[serde(default)]111
+ room_id: String,112
+ #[serde(default)]113
+ typing: bool,114
+}115
+116
+#[derive(Debug, Serialize)]117
+struct SoftLogoutInfo {118
+ user_id: String,119
+ homeserver: String,120
+}121
+122
+#[derive(Debug, Serialize)]123
+struct SessionResponse {124
+ user_id: Option<String>,125
+ #[serde(skip_serializing_if = "Option::is_none")]126
+ soft_logout: Option<SoftLogoutInfo>,127
+}128
+129
+async fn auth(130
+ state: &AppState,131
+ headers: &HeaderMap,132
+) -> Result<(Arc<UserSession>, String), ApiError> {133
+ let Some(sid) = session_sid(headers) else {134
+ return Err(ApiError::unauthorized("not signed in"));135
+ };136
+ match state.get_or_restore(&sid).await {137
+ Ok(user) => Ok((user, sid)),138
+ Err(RestoreError::NotFound) => Err(ApiError::unauthorized_stale(139
+ "session expired; sign in again",140
+ state.cookie_secure(),141
+ )),142
+ Err(RestoreError::SoftLoggedOut { .. }) => Err(ApiError::unauthorized(143
+ "signed out by the server, sign in again",144
+ )),145
+ Err(RestoreError::Stale(message)) => {146
+ state.remove_session(&sid, Retain::Nothing).await;147
+ Err(ApiError::unauthorized_stale(message, state.cookie_secure()))148
+ }149
+ Err(RestoreError::Unavailable(message)) => {150
+ Err(ApiError::new(StatusCode::BAD_GATEWAY, message))151
+ }152
+ }153
+}154
+155
+/// commands answer when they're done, so the browser gets the outcome in the response156
+fn respond(result: Result<Reply, malice_core::error::CoreError>) -> Response {157
+ match result {158
+ Ok(Reply::Done) => no_content(None),159
+ Ok(Reply::Json(value)) => json_ok(&value, None),160
+ Ok(Reply::Avatar {161
+ bytes,162
+ content_type,163
+ }) => (164
+ [165
+ (axum::http::header::CONTENT_TYPE, content_type),166
+ (axum::http::header::CACHE_CONTROL, "private, max-age=3600"),167
+ (axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"),168
+ ],169
+ bytes,170
+ )171
+ .into_response(),172
+ Ok(Reply::Attachment {173
+ bytes,174
+ content_type,175
+ }) => (176
+ [177
+ (178
+ axum::http::header::CONTENT_TYPE,179
+ content_type.unwrap_or("application/octet-stream"),180
+ ),181
+ (182
+ axum::http::header::CONTENT_DISPOSITION,183
+ if content_type.is_some() {184
+ "inline"185
+ } else {186
+ "attachment"187
+ },188
+ ),189
+ (axum::http::header::CACHE_CONTROL, "private, max-age=3600"),190
+ (axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"),191
+ (axum::http::header::CONTENT_SECURITY_POLICY, "sandbox"),192
+ ],193
+ bytes,194
+ )195
+ .into_response(),196
+ Err(error) => ApiError::from(error).into_response(),197
+ }198
+}199
+200
+async fn get_session(State(state): State<AppState>, headers: HeaderMap) -> Response {201
+ let Some(sid) = session_sid(&headers) else {202
+ return json_ok(203
+ &SessionResponse {204
+ user_id: None,205
+ soft_logout: None,206
+ },207
+ None,208
+ );209
+ };210
+ match state.get_or_restore(&sid).await {211
+ Ok(user) => json_ok(212
+ &SessionResponse {213
+ user_id: Some(user.user_id.clone()),214
+ soft_logout: None,215
+ },216
+ None,217
+ ),218
+ Err(RestoreError::NotFound) => json_ok(219
+ &SessionResponse {220
+ user_id: None,221
+ soft_logout: None,222
+ },223
+ None,224
+ ),225
+ Err(RestoreError::SoftLoggedOut {226
+ user_id,227
+ homeserver,228
+ }) => json_ok(229
+ &SessionResponse {230
+ user_id: None,231
+ soft_logout: Some(SoftLogoutInfo {232
+ user_id,233
+ homeserver,234
+ }),235
+ },236
+ None,237
+ ),238
+ Err(RestoreError::Stale(message)) => {239
+ state.remove_session(&sid, Retain::Nothing).await;240
+ ApiError::unauthorized_stale(message, state.cookie_secure()).into_response()241
+ }242
+ Err(RestoreError::Unavailable(message)) => {243
+ ApiError::new(StatusCode::BAD_GATEWAY, message).into_response()244
+ }245
+ }246
+}247
+248
+async fn post_login(249
+ State(state): State<AppState>,250
+ headers: HeaderMap,251
+ body: axum::Json<LoginInput>,252
+) -> Response {253
+ let input = match body.0.validate(state.allow_http_homeserver()) {254
+ Ok(input) => input,255
+ Err(error) => return ApiError::from(error).into_response(),256
+ };257
+ let cookie_sid = session_sid(&headers);258
+ let password = input.password.clone();259
+ match state260
+ .login(261
+ cookie_sid.as_deref(),262
+ input.homeserver,263
+ input.username,264
+ input.password,265
+ )266
+ .await267
+ {268
+ Ok(user) => json_ok(269
+ &serde_json::json!({ "user_id": user.user_id }),270
+ Some(set_cookie_value(&user.sid, state.cookie_secure())),271
+ ),272
+ Err(message) => {273
+ let status = if message.contains("starting sync engine")274
+ || message.contains("could not save the session")275
+ || message.contains("another engine is using this session")276
+ {277
+ StatusCode::BAD_GATEWAY278
+ } else {279
+ StatusCode::UNAUTHORIZED280
+ };281
+ ApiError::new(status, sanitise_auth_error(&message, &password)).into_response()282
+ }283
+ }284
+}285
+286
+async fn post_logout(State(state): State<AppState>, headers: HeaderMap) -> Response {287
+ if let Some(sid) = session_sid(&headers) {288
+ state.logout(&sid).await;289
+ }290
+ no_content(Some(crate::csrf::clear_cookie_value(state.cookie_secure())))291
+}292
+293
+async fn get_rooms(State(state): State<AppState>, headers: HeaderMap) -> Response {294
+ let (user, _) = match auth(&state, &headers).await {295
+ Ok(pair) => pair,296
+ Err(error) => return error.into_response(),297
+ };298
+ respond(dispatch(&user.handle, &user.mirror, Request::Rooms).await)299
+}300
+301
+async fn get_members(302
+ State(state): State<AppState>,303
+ headers: HeaderMap,304
+ Query(query): Query<MembersQuery>,305
+) -> Response {306
+ let (user, _) = match auth(&state, &headers).await {307
+ Ok(pair) => pair,308
+ Err(error) => return error.into_response(),309
+ };310
+ respond(311
+ dispatch(312
+ &user.handle,313
+ &user.mirror,314
+ Request::Members {315
+ room_id: query.room_id,316
+ },317
+ )318
+ .await,319
+ )320
+}321
+322
+async fn rooms_open(323
+ State(state): State<AppState>,324
+ headers: HeaderMap,325
+ body: axum::Json<RoomIdBody>,326
+) -> Response {327
+ let (user, _) = match auth(&state, &headers).await {328
+ Ok(pair) => pair,329
+ Err(error) => return error.into_response(),330
+ };331
+ respond(332
+ dispatch(333
+ &user.handle,334
+ &user.mirror,335
+ Request::Open {336
+ room_id: body.0.room_id,337
+ },338
+ )339
+ .await,340
+ )341
+}342
+343
+async fn rooms_close(344
+ State(state): State<AppState>,345
+ headers: HeaderMap,346
+ body: axum::Json<RoomIdBody>,347
+) -> Response {348
+ let (user, _) = match auth(&state, &headers).await {349
+ Ok(pair) => pair,350
+ Err(error) => return error.into_response(),351
+ };352
+ respond(353
+ dispatch(354
+ &user.handle,355
+ &user.mirror,356
+ Request::Close {357
+ room_id: body.0.room_id,358
+ },359
+ )360
+ .await,361
+ )362
+}363
+364
+async fn rooms_send(365
+ State(state): State<AppState>,366
+ headers: HeaderMap,367
+ body: axum::Json<SendBody>,368
+) -> Response {369
+ let (user, _) = match auth(&state, &headers).await {370
+ Ok(pair) => pair,371
+ Err(error) => return error.into_response(),372
+ };373
+ respond(374
+ dispatch(375
+ &user.handle,376
+ &user.mirror,377
+ Request::Send {378
+ room_id: body.0.room_id,379
+ body: body.0.body,380
+ },381
+ )382
+ .await,383
+ )384
+}385
+386
+async fn rooms_upload(387
+ State(state): State<AppState>,388
+ headers: HeaderMap,389
+ mut upload: Multipart,390
+) -> Response {391
+ let (user, _) = match auth(&state, &headers).await {392
+ Ok(pair) => pair,393
+ Err(error) => return error.into_response(),394
+ };395
+ let mut room_id = None;396
+ let mut file = None;397
+ loop {398
+ let field = match upload.next_field().await {399
+ Ok(Some(field)) => field,400
+ Ok(None) => break,401
+ Err(_) => return ApiError::bad_request("invalid upload form").into_response(),402
+ };403
+ match field.name() {404
+ Some("room_id") if room_id.is_none() => {405
+ room_id = match field.text().await {406
+ Ok(value) => Some(value),407
+ Err(_) => return ApiError::bad_request("invalid room_id").into_response(),408
+ };409
+ }410
+ Some("file") if file.is_none() => {411
+ let filename = field.file_name().unwrap_or("attachment").to_owned();412
+ let mimetype = field413
+ .content_type()414
+ .unwrap_or("application/octet-stream")415
+ .to_owned();416
+ let bytes = match field.bytes().await {417
+ Ok(bytes) => bytes,418
+ Err(_) => return ApiError::bad_request("invalid file").into_response(),419
+ };420
+ if bytes.is_empty() || bytes.len() > 20 * 1024 * 1024 {421
+ return ApiError::bad_request("file must be between 1 byte and 20 MiB")422
+ .into_response();423
+ }424
+ file = Some((filename, mimetype, bytes));425
+ }426
+ _ => return ApiError::bad_request("unexpected upload field").into_response(),427
+ }428
+ }429
+ let Some(room_id) = room_id else {430
+ return ApiError::bad_request("room_id is required").into_response();431
+ };432
+ let Some((filename, mimetype, bytes)) = file else {433
+ return ApiError::bad_request("file is required").into_response();434
+ };435
+ respond(436
+ dispatch(437
+ &user.handle,438
+ &user.mirror,439
+ Request::upload(room_id, filename, mimetype, bytes.to_vec()),440
+ )441
+ .await,442
+ )443
+}444
+445
+async fn rooms_paginate(446
+ State(state): State<AppState>,447
+ headers: HeaderMap,448
+ body: axum::Json<PaginateBody>,449
+) -> Response {450
+ let (user, _) = match auth(&state, &headers).await {451
+ Ok(pair) => pair,452
+ Err(error) => return error.into_response(),453
+ };454
+ respond(455
+ dispatch(456
+ &user.handle,457
+ &user.mirror,458
+ Request::Paginate {459
+ room_id: body.0.room_id,460
+ },461
+ )462
+ .await,463
+ )464
+}465
+466
+async fn rooms_join(467
+ State(state): State<AppState>,468
+ headers: HeaderMap,469
+ body: axum::Json<JoinBody>,470
+) -> Response {471
+ let (user, _) = match auth(&state, &headers).await {472
+ Ok(pair) => pair,473
+ Err(error) => return error.into_response(),474
+ };475
+ respond(476
+ dispatch(477
+ &user.handle,478
+ &user.mirror,479
+ Request::Join { room: body.0.room },480
+ )481
+ .await,482
+ )483
+}484
+485
+async fn rooms_create(486
+ State(state): State<AppState>,487
+ headers: HeaderMap,488
+ body: axum::Json<CreateBody>,489
+) -> Response {490
+ let (user, _) = match auth(&state, &headers).await {491
+ Ok(pair) => pair,492
+ Err(error) => return error.into_response(),493
+ };494
+ respond(495
+ dispatch(496
+ &user.handle,497
+ &user.mirror,498
+ Request::Create {499
+ name: body.0.name,500
+ topic: body.0.topic,501
+ },502
+ )503
+ .await,504
+ )505
+}506
+507
+async fn rooms_leave(508
+ State(state): State<AppState>,509
+ headers: HeaderMap,510
+ body: axum::Json<RoomIdBody>,511
+) -> Response {512
+ let (user, _) = match auth(&state, &headers).await {513
+ Ok(pair) => pair,514
+ Err(error) => return error.into_response(),515
+ };516
+ respond(517
+ dispatch(518
+ &user.handle,519
+ &user.mirror,520
+ Request::Leave {521
+ room_id: body.0.room_id,522
+ },523
+ )524
+ .await,525
+ )526
+}527
+528
+async fn rooms_react(529
+ State(state): State<AppState>,530
+ headers: HeaderMap,531
+ body: axum::Json<ReactBody>,532
+) -> Response {533
+ let (user, _) = match auth(&state, &headers).await {534
+ Ok(pair) => pair,535
+ Err(error) => return error.into_response(),536
+ };537
+ respond(538
+ dispatch(539
+ &user.handle,540
+ &user.mirror,541
+ Request::React {542
+ room_id: body.0.room_id,543
+ event_id: body.0.event_id,544
+ key: body.0.key,545
+ },546
+ )547
+ .await,548
+ )549
+}550
+551
+async fn rooms_read(552
+ State(state): State<AppState>,553
+ headers: HeaderMap,554
+ body: axum::Json<ReadBody>,555
+) -> Response {556
+ let (user, _) = match auth(&state, &headers).await {557
+ Ok(pair) => pair,558
+ Err(error) => return error.into_response(),559
+ };560
+ respond(561
+ dispatch(562
+ &user.handle,563
+ &user.mirror,564
+ Request::Read {565
+ room_id: body.0.room_id,566
+ event_id: body.0.event_id,567
+ },568
+ )569
+ .await,570
+ )571
+}572
+573
+async fn get_profile(State(state): State<AppState>, headers: HeaderMap) -> Response {574
+ let (user, _) = match auth(&state, &headers).await {575
+ Ok(pair) => pair,576
+ Err(error) => return error.into_response(),577
+ };578
+ respond(dispatch(&user.handle, &user.mirror, Request::Profile).await)579
+}580
+581
+async fn get_space_children(582
+ State(state): State<AppState>,583
+ headers: HeaderMap,584
+ Query(query): Query<MembersQuery>,585
+) -> Response {586
+ let (user, _) = match auth(&state, &headers).await {587
+ Ok(pair) => pair,588
+ Err(error) => return error.into_response(),589
+ };590
+ respond(591
+ dispatch(592
+ &user.handle,593
+ &user.mirror,594
+ Request::SpaceChildren {595
+ room_id: query.room_id,596
+ },597
+ )598
+ .await,599
+ )600
+}601
+602
+async fn get_media(603
+ State(state): State<AppState>,604
+ headers: HeaderMap,605
+ Query(query): Query<MediaQuery>,606
+) -> Response {607
+ let (user, _) = match auth(&state, &headers).await {608
+ Ok(pair) => pair,609
+ Err(error) => return error.into_response(),610
+ };611
+ respond(612
+ dispatch(613
+ &user.handle,614
+ &user.mirror,615
+ Request::Avatar { mxc: query.mxc },616
+ )617
+ .await,618
+ )619
+}620
+621
+async fn get_message_media(622
+ State(state): State<AppState>,623
+ headers: HeaderMap,624
+ Query(query): Query<MessageMediaQuery>,625
+) -> Response {626
+ let (user, _) = match auth(&state, &headers).await {627
+ Ok(pair) => pair,628
+ Err(error) => return error.into_response(),629
+ };630
+ // the declared mimetype is the sender's claim, so the bytes get sniffed instead631
+ respond(632
+ dispatch(633
+ &user.handle,634
+ &user.mirror,635
+ Request::Media {636
+ media_id: query.media_id,637
+ size: query.size,638
+ },639
+ )640
+ .await,641
+ )642
+}643
+644
+async fn get_encryption(State(state): State<AppState>, headers: HeaderMap) -> Response {645
+ let (user, _) = match auth(&state, &headers).await {646
+ Ok(pair) => pair,647
+ Err(error) => return error.into_response(),648
+ };649
+ respond(dispatch(&user.handle, &user.mirror, Request::Encryption).await)650
+}651
+652
+async fn recover_encryption(653
+ State(state): State<AppState>,654
+ headers: HeaderMap,655
+ body: axum::Json<RecoverBody>,656
+) -> Response {657
+ let (user, _) = match auth(&state, &headers).await {658
+ Ok(pair) => pair,659
+ Err(error) => return error.into_response(),660
+ };661
+ respond(662
+ dispatch(663
+ &user.handle,664
+ &user.mirror,665
+ Request::Recover {666
+ recovery_key: body.0.recovery_key,667
+ },668
+ )669
+ .await,670
+ )671
+}672
+673
+async fn start_verification(State(state): State<AppState>, headers: HeaderMap) -> Response {674
+ let (user, _) = match auth(&state, &headers).await {675
+ Ok(pair) => pair,676
+ Err(error) => return error.into_response(),677
+ };678
+ respond(dispatch(&user.handle, &user.mirror, Request::StartVerification).await)679
+}680
+681
+async fn verify_encryption(682
+ State(state): State<AppState>,683
+ headers: HeaderMap,684
+ body: axum::Json<VerifyBody>,685
+) -> Response {686
+ let (user, _) = match auth(&state, &headers).await {687
+ Ok(pair) => pair,688
+ Err(error) => return error.into_response(),689
+ };690
+ respond(691
+ dispatch(692
+ &user.handle,693
+ &user.mirror,694
+ Request::Verify {695
+ action: body.0.action,696
+ },697
+ )698
+ .await,699
+ )700
+}701
+702
+async fn rooms_typing(703
+ State(state): State<AppState>,704
+ headers: HeaderMap,705
+ body: axum::Json<TypingBody>,706
+) -> Response {707
+ let (user, _) = match auth(&state, &headers).await {708
+ Ok(pair) => pair,709
+ Err(error) => return error.into_response(),710
+ };711
+ respond(712
+ dispatch(713
+ &user.handle,714
+ &user.mirror,715
+ Request::Typing {716
+ room_id: body.0.room_id,717
+ typing: body.0.typing,718
+ },719
+ )720
+ .await,721
+ )722
+}723
+724
+async fn get_events(State(state): State<AppState>, headers: HeaderMap) -> Response {725
+ let (user, _) = match auth(&state, &headers).await {726
+ Ok(pair) => pair,727
+ Err(error) => return error.into_response(),728
+ };729
+ let body = subscribe(&user.mirror).map(|event| {730
+ let json = serde_json::to_string(&event).unwrap_or_else(|_| "{\"type\":\"error\"}".into());731
+ Ok::<_, std::convert::Infallible>(SseEvent::default().data(json))732
+ });733
+ Sse::new(body).into_response()734
+}735
+736
+async fn get_health() -> Response {737
+ json_ok(&serde_json::json!({ "status": "ok" }), None)738
+}739
+740
+async fn static_shell() -> Response {741
+ (742
+ StatusCode::SERVICE_UNAVAILABLE,743
+ axum::Json(serde_json::json!({ "message": "frontend assets missing; run npm --prefix web run build or use the Nix package" })),744
+ )745
+ .into_response()746
+}747
+748
+pub fn router(state: AppState, web_dir: Option<String>) -> Router {749
+ let api = Router::new()750
+ .route("/session", get(get_session))751
+ .route("/login", post(post_login))752
+ .route("/logout", post(post_logout))753
+ .route("/rooms", get(get_rooms))754
+ .route("/events", get(get_events))755
+ .route("/health", get(get_health))756
+ .route("/rooms/open", post(rooms_open))757
+ .route("/rooms/close", post(rooms_close))758
+ .route("/rooms/send", post(rooms_send))759
+ .route("/rooms/paginate", post(rooms_paginate))760
+ .route(761
+ "/rooms/upload",762
+ post(rooms_upload).layer(DefaultBodyLimit::max(21 * 1024 * 1024)),763
+ )764
+ .route("/rooms/join", post(rooms_join))765
+ .route("/rooms/create", post(rooms_create))766
+ .route("/rooms/leave", post(rooms_leave))767
+ .route("/rooms/react", post(rooms_react))768
+ .route("/rooms/read", post(rooms_read))769
+ .route("/rooms/typing", post(rooms_typing))770
+ .route("/profile", get(get_profile))771
+ .route("/media", get(get_media))772
+ .route("/message-media", get(get_message_media))773
+ .route("/spaces/children", get(get_space_children))774
+ .route("/rooms/members", get(get_members))775
+ .route("/encryption", get(get_encryption))776
+ .route("/encryption/recover", post(recover_encryption))777
+ .route("/encryption/start", post(start_verification))778
+ .route("/encryption/verify", post(verify_encryption));779
+ let api = Router::new()780
+ .nest("/api", api)781
+ .layer(middleware::from_fn(csrf_guard));782
+ let router = api.with_state(state).layer(TraceLayer::new_for_http());783
+ match web_dir {784
+ Some(dir) => {785
+ let files =786
+ ServeDir::new(&dir).not_found_service(ServeFile::new(format!("{dir}/index.html")));787
+ router.fallback_service(files)788
+ }789
+ None => router.fallback(static_shell),790
+ }791
+}792
+793
+async fn api_not_found() -> StatusCode {794
+ StatusCode::NOT_FOUND795
+}796
+797
+fn is_no_cache_path(path: &str) -> bool {798
+ matches!(path, "/" | "/index.html" | "/sw.js")799
+}800
+801
+/// index.html and sw.js must never be cached, or a stale service worker or802
+/// app shell survives a rebuild; the rest of the bundle is hashed and can be803
+/// cached freely.804
+async fn static_headers(request: axum::extract::Request, next: Next) -> Response {805
+ let no_cache = is_no_cache_path(request.uri().path());806
+ let mut response = next.run(request).await;807
+ if no_cache {808
+ let headers = response.headers_mut();809
+ headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));810
+ headers.insert(811
+ HeaderName::from_static("service-worker-allowed"),812
+ HeaderValue::from_static("/"),813
+ );814
+ }815
+ response816
+}817
+818
+/// Serves only the built frontend: no `/api` routes, no session state, no819
+/// data dir. Used for the socket-activated on-device build.820
+pub fn static_router(web_dir: Option<String>) -> Router {821
+ let router = Router::new()822
+ .route("/api", any(api_not_found))823
+ .route("/api/{*rest}", any(api_not_found))824
+ .layer(TraceLayer::new_for_http());825
+ match web_dir {826
+ Some(dir) => {827
+ let files =828
+ ServeDir::new(&dir).not_found_service(ServeFile::new(format!("{dir}/index.html")));829
+ router830
+ .fallback_service(files)831
+ .layer(middleware::from_fn(static_headers))832
+ }833
+ None => router.fallback(static_shell),834
+ }835
+}@@ -0,0 +1,497 @@
1
+use std::{2
+ collections::HashMap,3
+ path::{Path, PathBuf},4
+ sync::Arc,5
+ time::Duration,6
+};7
+8
+use futures_util::StreamExt;9
+use macltok::{10
+ AccountError, ClientHandle, EncryptedSessionStore, EngineConfig, EngineError, Mirror,11
+ MirrorHandle, PasswordLogin, Restore, Retain, Session, SessionStatus, SessionStore, SoftLogout,12
+ StateChange, StoreConfig, Termination,13
+};14
+use rand_core::{OsRng, RngCore};15
+use sha2::{Digest, Sha256};16
+use tokio::{sync::Mutex, task::JoinHandle, time::timeout};17
+18
+use crate::csrf::valid_sid;19
+20
+const AUTH_TIMEOUT: Duration = Duration::from_secs(20);21
+22
+pub struct UserSession {23
+ pub sid: String,24
+ pub user_id: String,25
+ pub handle: ClientHandle,26
+ pub mirror: MirrorHandle,27
+ sessions: Arc<dyn SessionStore>,28
+ engine: Mutex<Option<JoinHandle<Result<Termination, EngineError>>>>,29
+ mirror_task: Mutex<Option<JoinHandle<()>>>,30
+}31
+32
+struct Inner {33
+ sessions: tokio::sync::RwLock<HashMap<String, Arc<UserSession>>>,34
+ /// Serialises concurrent restores of the same sid so two engines never35
+ /// open the same SQLite store at once.36
+ init_lock: Mutex<()>,37
+ data_dir: PathBuf,38
+ master_key: [u8; 32],39
+ cookie_secure: bool,40
+ allow_http_homeserver: bool,41
+}42
+43
+#[derive(Clone)]44
+pub struct AppState {45
+ inner: Arc<Inner>,46
+}47
+48
+#[derive(Debug)]49
+pub enum RestoreError {50
+ /// No cookie session and nothing on disk: plain guest.51
+ NotFound,52
+ /// Credential on disk is unusable: caller should clear the cookie.53
+ Stale(String),54
+ /// Credential looks fine but the engine would not start.55
+ Unavailable(String),56
+ /// The server logged this device out; it can log back in as the same user.57
+ SoftLoggedOut { user_id: String, homeserver: String },58
+}59
+60
+impl AppState {61
+ pub fn new(62
+ data_dir: PathBuf,63
+ master_key: [u8; 32],64
+ cookie_secure: bool,65
+ allow_http_homeserver: bool,66
+ ) -> Self {67
+ Self {68
+ inner: Arc::new(Inner {69
+ sessions: tokio::sync::RwLock::new(HashMap::new()),70
+ init_lock: Mutex::new(()),71
+ data_dir,72
+ master_key,73
+ cookie_secure,74
+ allow_http_homeserver,75
+ }),76
+ }77
+ }78
+79
+ pub fn allow_http_homeserver(&self) -> bool {80
+ self.inner.allow_http_homeserver81
+ }82
+83
+ pub fn cookie_secure(&self) -> bool {84
+ self.inner.cookie_secure85
+ }86
+87
+ fn sessions_dir(&self) -> PathBuf {88
+ self.inner.data_dir.join("sessions")89
+ }90
+91
+ fn session_file(&self, sid: &str) -> PathBuf {92
+ self.sessions_dir().join(format!("{sid}.bin"))93
+ }94
+95
+ fn store_dir(&self, sid: &str) -> PathBuf {96
+ self.inner.data_dir.join("stores").join(sid)97
+ }98
+99
+ fn store_passphrase(&self, sid: &str) -> String {100
+ let mut hasher = Sha256::new();101
+ hasher.update(self.inner.master_key);102
+ hasher.update(b"malice-store-v1:");103
+ hasher.update(sid.as_bytes());104
+ hex::encode(hasher.finalize())105
+ }106
+107
+ fn store_config(&self, sid: &str) -> StoreConfig {108
+ StoreConfig::sqlite(self.store_dir(sid), self.store_passphrase(sid))109
+ }110
+111
+ /// A fresh handle onto a sid's record and store. Cheap: nothing touches112
+ /// disk until the handle is actually used.113
+ fn session_store(&self, sid: &str) -> Arc<dyn SessionStore> {114
+ Arc::new(EncryptedSessionStore::file(115
+ self.session_file(sid),116
+ self.store_config(sid),117
+ ))118
+ }119
+120
+ async fn apply_retention(&self, sessions: &Arc<dyn SessionStore>, retain: Retain) {121
+ let result = match retain {122
+ Retain::RecordAndStore => Ok(()),123
+ Retain::Store => sessions.soft_log_out().await,124
+ Retain::Nothing => sessions.discard().await,125
+ };126
+ if let Err(error) = result {127
+ tracing::warn!(%error, "applying session retention failed");128
+ }129
+ }130
+131
+ /// Resolve a cookie sid to a live session, restoring from disk on demand.132
+ /// The shared map is never held across network or engine work: the fast133
+ /// path clones an `Arc` under a read lock, and restores serialise on a134
+ /// short init lock with a double-checked insert.135
+ pub async fn get_or_restore(&self, sid: &str) -> Result<Arc<UserSession>, RestoreError> {136
+ if !valid_sid(sid) {137
+ return Err(RestoreError::NotFound);138
+ }139
+ if let Some(user) = self.inner.sessions.read().await.get(sid).cloned() {140
+ return Ok(user);141
+ }142
+ let _init = self.inner.init_lock.lock().await;143
+ if let Some(user) = self.inner.sessions.read().await.get(sid).cloned() {144
+ return Ok(user);145
+ }146
+ let sessions = self.session_store(sid);147
+ let restore = Session::restore(sessions.clone()).await.map_err(|error| {148
+ tracing::warn!("restoring persisted session failed: {error}");149
+ restore_error(error)150
+ })?;151
+ match restore {152
+ None => Err(RestoreError::NotFound),153
+ Some(Restore::SoftLoggedOut(soft)) => Err(RestoreError::SoftLoggedOut {154
+ user_id: soft.user_id.to_string(),155
+ homeserver: soft.homeserver,156
+ }),157
+ Some(Restore::Resumed(session)) => self158
+ .start_user_session(sid.to_owned(), session, sessions)159
+ .await160
+ .map_err(|error| {161
+ tracing::warn!("starting restored session failed: {error}");162
+ RestoreError::Unavailable(error)163
+ }),164
+ }165
+ }166
+167
+ /// Restore just far enough to tell whether `sid`'s record is a soft168
+ /// logout, without disturbing anything else.169
+ async fn soft_logged_out(&self, sid: &str) -> Option<SoftLogout> {170
+ match Session::restore(self.session_store(sid)).await {171
+ Ok(Some(Restore::SoftLoggedOut(soft))) => Some(soft),172
+ _ => None,173
+ }174
+ }175
+176
+ /// Password login: creates a fresh opaque sid and starts the engine,177
+ /// which saves the record itself. The password never leaves this call.178
+ /// When the cookie sid names the same user's soft-logged-out device, logs179
+ /// back in as that device against the same sid instead, keeping its keys.180
+ pub async fn login(181
+ &self,182
+ cookie_sid: Option<&str>,183
+ homeserver: String,184
+ username: String,185
+ password: String,186
+ ) -> Result<Arc<UserSession>, String> {187
+ if let Some(sid) = cookie_sid.filter(|sid| valid_sid(sid))188
+ && let Some(soft) = self.soft_logged_out(sid).await189
+ {190
+ if username_matches(&username, &soft.user_id) {191
+ let sessions = self.session_store(sid);192
+ let session = login_with(soft.password_login(password), sessions.clone()).await?;193
+ let _init = self.inner.init_lock.lock().await;194
+ return self195
+ .start_user_session(sid.to_owned(), session, sessions)196
+ .await;197
+ }198
+ self.apply_retention(&self.session_store(sid), Retain::Nothing)199
+ .await;200
+ }201
+ let sid = new_sid();202
+ let sessions = self.session_store(&sid);203
+ let session = login_with(204
+ PasswordLogin::new(homeserver, username, password)205
+ .with_device_display_name("malice web"),206
+ sessions.clone(),207
+ )208
+ .await?;209
+ let _init = self.inner.init_lock.lock().await;210
+ self.start_user_session(sid, session, sessions).await211
+ }212
+213
+ async fn start_user_session(214
+ &self,215
+ sid: String,216
+ session: Session,217
+ sessions: Arc<dyn SessionStore>,218
+ ) -> Result<Arc<UserSession>, String> {219
+ let (engine, handle, events) = session220
+ .build(EngineConfig::default())221
+ .await222
+ .map_err(|error| format!("starting sync engine: {error}"))?;223
+ let engine_task = tokio::spawn(engine.run());224
+ let (mirror, mirror_handle) = Mirror::new(handle.clone(), events);225
+ let mirror_task = tokio::spawn(mirror.run());226
+ let Some(user_id) = wait_authenticated(&mirror_handle).await else {227
+ engine_task.abort();228
+ let _ = engine_task.await;229
+ mirror_task.abort();230
+ let _ = mirror_task.await;231
+ return Err("sync engine stopped before authenticating".to_owned());232
+ };233
+ let user = Arc::new(UserSession {234
+ sid: sid.clone(),235
+ user_id,236
+ handle,237
+ mirror: mirror_handle,238
+ sessions,239
+ engine: Mutex::new(Some(engine_task)),240
+ mirror_task: Mutex::new(Some(mirror_task)),241
+ });242
+ {243
+ let mut sessions_map = self.inner.sessions.write().await;244
+ sessions_map.insert(sid, user.clone());245
+ }246
+ let supervisor_state = self.clone();247
+ let supervisor_user = user.clone();248
+ tokio::spawn(async move {249
+ supervise(supervisor_state, supervisor_user).await;250
+ });251
+ Ok(user)252
+ }253
+254
+ /// Logout: best-effort server-side logout, then drop all local state and255
+ /// delete persisted secrets. Never holds the map lock across the network256
+ /// call. Forces `Retain::Nothing` regardless of what the engine's own257
+ /// termination said, so an explicit logout always ends in a full discard258
+ /// even if it raced the supervisor over a server-initiated soft logout.259
+ pub async fn logout(&self, sid: &str) {260
+ let user = { self.inner.sessions.read().await.get(sid).cloned() };261
+ if let Some(user) = user {262
+ // answers once the server has let go of the device, and the engine stops itself263
+ let _ = timeout(Duration::from_secs(5), user.handle.logout()).await;264
+ }265
+ self.remove_session(sid, Retain::Nothing).await;266
+ }267
+268
+ /// Forget an in-memory session, abort its engine and mirror tasks, and269
+ /// apply the given retention to its persisted secrets. Called both for270
+ /// explicit logout (always `Retain::Nothing`) and from the supervisor271
+ /// once an engine's `Termination` says what its record and store are272
+ /// still worth. Retention always runs, even if the session was already273
+ /// gone from the map, so a racing logout's `Nothing` still lands.274
+ pub async fn remove_session(&self, sid: &str, retain: Retain) {275
+ // A restore must not reopen the store between removing the map entry276
+ // and deleting its persisted credentials.277
+ let _init = self.inner.init_lock.lock().await;278
+ let user = self.inner.sessions.write().await.remove(sid);279
+ let sessions = match &user {280
+ Some(user) => user.sessions.clone(),281
+ None => self.session_store(sid),282
+ };283
+ if let Some(user) = &user {284
+ if let Some(task) = user.engine.lock().await.take() {285
+ task.abort();286
+ }287
+ if let Some(task) = user.mirror_task.lock().await.take() {288
+ task.abort();289
+ }290
+ }291
+ self.apply_retention(&sessions, retain).await;292
+ }293
+294
+ /// Graceful shutdown: ask every engine to stop, keep persisted records so295
+ /// the next boot restores them.296
+ pub async fn shutdown_gracefully(&self) {297
+ let users: Vec<Arc<UserSession>> =298
+ self.inner.sessions.read().await.values().cloned().collect();299
+ for user in &users {300
+ // the supervisor keeps waiting, so a shutdown can answer once `Stopped` is out301
+ let handle = user.handle.clone();302
+ tokio::spawn(async move {303
+ let _ = timeout(Duration::from_secs(5), handle.shutdown()).await;304
+ });305
+ }306
+ // Give engines a moment to emit `Stopped`; the supervisor then drops307
+ // each in-memory session while leaving persisted records behind.308
+ timeout(Duration::from_secs(5), async {309
+ loop {310
+ if self.inner.sessions.read().await.is_empty() {311
+ break;312
+ }313
+ tokio::time::sleep(Duration::from_millis(50)).await;314
+ }315
+ })316
+ .await317
+ .ok();318
+ for user in &users {319
+ if let Some(task) = user.engine.lock().await.take() {320
+ task.abort();321
+ }322
+ if let Some(task) = user.mirror_task.lock().await.take() {323
+ task.abort();324
+ }325
+ }326
+ self.inner.sessions.write().await.clear();327
+ }328
+}329
+330
+fn restore_error(error: AccountError) -> RestoreError {331
+ match error {332
+ AccountError::StoreInUse => {333
+ RestoreError::Unavailable("another connection is using this session".to_owned())334
+ }335
+ error => RestoreError::Stale(format!("restoring persisted session failed: {error}")),336
+ }337
+}338
+339
+async fn login_with(340
+ login: PasswordLogin,341
+ sessions: Arc<dyn SessionStore>,342
+) -> Result<Session, String> {343
+ match Session::login_password(login, sessions).await {344
+ Ok(session) => Ok(session),345
+ Err(AccountError::StoreInUse) => Err("another engine is using this session".to_owned()),346
+ Err(AccountError::Unsaved { session, .. }) => session347
+ .save()348
+ .await349
+ .map_err(|_| "could not save the session".to_owned()),350
+ Err(error) => Err(format!(351
+ "login failed: {}",352
+ malice_core::login::describe_error(&error)353
+ )),354
+ }355
+}356
+357
+fn username_matches(submitted: &str, user_id: &ruma::OwnedUserId) -> bool {358
+ let submitted = submitted.trim();359
+ submitted == user_id.as_str() || submitted == user_id.localpart()360
+}361
+362
+/// Wait for the mirror to learn the user id. Fails once the subscription363
+/// ends or the session leaves `Active` before that happens.364
+async fn wait_authenticated(mirror: &MirrorHandle) -> Option<String> {365
+ timeout(AUTH_TIMEOUT, async {366
+ let mut updates = mirror.subscribe();367
+ loop {368
+ let update = updates.next().await?;369
+ match update.change {370
+ StateChange::State(state) => {371
+ if let Some(user_id) = &state.user_id {372
+ return Some(user_id.to_string());373
+ }374
+ if state.session != SessionStatus::Active {375
+ return None;376
+ }377
+ }378
+ StateChange::Authenticated(user_id) => return Some(user_id.to_string()),379
+ StateChange::Session(status) if status != SessionStatus::Active => return None,380
+ _ => {}381
+ }382
+ }383
+ })384
+ .await385
+ .ok()386
+ .flatten()387
+}388
+389
+/// Awaits a session's engine, then its mirror, removes it from the map, and390
+/// applies the retention its `Termination` earns. Taking the engine391
+/// `JoinHandle` is the single point two racing teardowns (this task and an392
+/// explicit logout) arbitrate over: whichever finds it empty does nothing393
+/// further, so the engine and mirror are never aborted twice.394
+async fn supervise(state: AppState, user: Arc<UserSession>) {395
+ let Some(engine_task) = user.engine.lock().await.take() else {396
+ return;397
+ };398
+ let retain = match engine_task.await {399
+ Ok(Ok(termination)) => termination.retain(),400
+ Ok(Err(error)) => {401
+ tracing::warn!(%error, "sync engine ended with an error; keeping its session");402
+ Retain::RecordAndStore403
+ }404
+ Err(error) => {405
+ tracing::warn!(%error, "sync engine task did not finish cleanly; keeping its session");406
+ Retain::RecordAndStore407
+ }408
+ };409
+ if let Some(mirror_task) = user.mirror_task.lock().await.take() {410
+ let _ = mirror_task.await;411
+ }412
+ state.remove_session(&user.sid, retain).await;413
+}414
+415
+fn new_sid() -> String {416
+ let mut bytes = [0u8; 32];417
+ OsRng.fill_bytes(&mut bytes);418
+ hex::encode(bytes)419
+}420
+421
+/// Load the 32-byte master key, creating it with owner-only permissions when422
+/// absent. Secrets must never be world-readable.423
+pub fn load_or_create_master_key(data_dir: &Path) -> Result<[u8; 32], String> {424
+ create_private_dir(data_dir).map_err(|error| format!("creating data dir: {error}"))?;425
+ restrict_dir(data_dir);426
+ let path = data_dir.join("master.key");427
+ if path.exists() {428
+ let bytes = std::fs::read(&path).map_err(|error| format!("reading master key: {error}"))?;429
+ restrict_file(&path);430
+ if bytes.len() != 32 {431
+ return Err("master key has an unexpected length".to_owned());432
+ }433
+ let mut key = [0u8; 32];434
+ key.copy_from_slice(&bytes);435
+ return Ok(key);436
+ }437
+ let mut key = [0u8; 32];438
+ OsRng.fill_bytes(&mut key);439
+ let mut file =440
+ create_private_file(&path).map_err(|error| format!("creating master key: {error}"))?;441
+ if let Err(error) = std::io::Write::write_all(&mut file, &key).and_then(|_| file.sync_all()) {442
+ let _ = std::fs::remove_file(&path);443
+ return Err(format!("writing master key: {error}"));444
+ }445
+ Ok(key)446
+}447
+448
+fn create_private_dir(path: &Path) -> std::io::Result<()> {449
+ let mut builder = std::fs::DirBuilder::new();450
+ builder.recursive(true);451
+ #[cfg(unix)]452
+ {453
+ use std::os::unix::fs::DirBuilderExt;454
+ builder.mode(0o700);455
+ }456
+ builder.create(path)457
+}458
+459
+fn create_private_file(path: &Path) -> std::io::Result<std::fs::File> {460
+ let mut options = std::fs::OpenOptions::new();461
+ options.write(true).create_new(true);462
+ #[cfg(unix)]463
+ {464
+ use std::os::unix::fs::OpenOptionsExt;465
+ options.mode(0o600);466
+ }467
+ options.open(path)468
+}469
+470
+pub fn ensure_dirs(data_dir: &Path) {471
+ for dir in [472
+ data_dir.to_path_buf(),473
+ data_dir.join("sessions"),474
+ data_dir.join("stores"),475
+ ] {476
+ let _ = create_private_dir(&dir);477
+ restrict_dir(&dir);478
+ }479
+}480
+481
+#[cfg(unix)]482
+fn restrict_dir(path: &Path) {483
+ use std::os::unix::fs::PermissionsExt;484
+ let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));485
+}486
+487
+#[cfg(not(unix))]488
+fn restrict_dir(_path: &Path) {}489
+490
+#[cfg(unix)]491
+fn restrict_file(path: &Path) {492
+ use std::os::unix::fs::PermissionsExt;493
+ let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));494
+}495
+496
+#[cfg(not(unix))]497
+fn restrict_file(_path: &Path) {}@@ -0,0 +1,540 @@
1
+use std::{2
+ net::TcpListener,3
+ path::PathBuf,4
+ process::{Child, Command, Stdio},5
+ sync::atomic::{AtomicBool, Ordering},6
+ time::Duration,7
+};8
+9
+use matrix_sdk::test_utils::mocks::MatrixMockServer;10
+use reqwest::{Client, StatusCode, header};11
+use serde_json::{Value, json};12
+use tempfile::TempDir;13
+use wiremock::{14
+ Mock, ResponseTemplate,15
+ matchers::{method, path_regex},16
+};17
+18
+struct Server(Child);19
+20
+impl Drop for Server {21
+ fn drop(&mut self) {22
+ let _ = self.0.kill();23
+ let _ = self.0.wait();24
+ }25
+}26
+27
+fn server_binary() -> PathBuf {28
+ option_env!("CARGO_BIN_EXE_malice")29
+ .map(PathBuf::from)30
+ .unwrap_or_else(|| {31
+ std::env::current_exe()32
+ .expect("integration test executable")33
+ .parent()34
+ .expect("cargo deps directory")35
+ .parent()36
+ .expect("cargo target directory")37
+ .join("malice")38
+ })39
+}40
+41
+async fn start(port: u16, data_dir: &TempDir, client: &Client) -> Server {42
+ let child = Command::new(server_binary())43
+ .env("MALICE_BIND", format!("127.0.0.1:{port}"))44
+ .env("MALICE_DATA_DIR", data_dir.path())45
+ .env("MALICE_SESSION_COOKIE_SECURE", "false")46
+ .env("MALICE_ALLOW_HTTP_HOMESERVER", "true")47
+ .stdout(Stdio::null())48
+ .stderr(Stdio::inherit())49
+ .spawn()50
+ .expect("start Malice backend");51
+ let mut server = Server(child);52
+ tokio::time::timeout(Duration::from_secs(30), async {53
+ loop {54
+ if let Some(status) = server.0.try_wait().expect("poll backend") {55
+ panic!("Malice exited before health check: {status}");56
+ }57
+ if client58
+ .get(format!("http://127.0.0.1:{port}/api/health"))59
+ .send()60
+ .await61
+ .is_ok_and(|response| response.status().is_success())62
+ {63
+ break;64
+ }65
+ tokio::time::sleep(Duration::from_millis(100)).await;66
+ }67
+ })68
+ .await69
+ .expect("Malice did not start");70
+ server71
+}72
+73
+fn unused_port() -> u16 {74
+ TcpListener::bind("127.0.0.1:0")75
+ .expect("bind ephemeral port")76
+ .local_addr()77
+ .expect("read ephemeral port")78
+ .port()79
+}80
+81
+#[tokio::test]82
+async fn login_isolated_cookie_survives_restart_and_logout_revokes_it() {83
+ let matrix = MatrixMockServer::new().await;84
+ matrix.mock_versions().ok().mount().await;85
+ matrix.mock_login().ok().expect(1).mount().await;86
+ let first_room = AtomicBool::new(false);87
+ Mock::given(method("POST"))88
+ .and(path_regex("/sync$"))89
+ .respond_with(move |request: &wiremock::Request| {90
+ let body: Value = serde_json::from_slice(&request.body).unwrap();91
+ let rooms =92
+ if body["conn_id"] == "room-list" && !first_room.swap(true, Ordering::Relaxed) {93
+ json!({"!malice:localhost": {94
+ "initial": true,95
+ "name": "Malice test room",96
+ "joined_count": 1,97
+ "required_state": [{98
+ "type": "m.room.member",99
+ "state_key": "@cheeky_monkey:matrix.org",100
+ "sender": "@cheeky_monkey:matrix.org",101
+ "event_id": "$join:localhost",102
+ "origin_server_ts": 1,103
+ "content": {"membership": "join"}104
+ }],105
+ "timeline": [{106
+ "type": "m.room.message",107
+ "sender": "@cheeky_monkey:matrix.org",108
+ "event_id": "$recent:localhost",109
+ "origin_server_ts": 2,110
+ "content": {"msgtype":"m.text", "body":"recent channel message"}111
+ },{112
+ "type": "m.room.message",113
+ "sender": "@cheeky_monkey:matrix.org",114
+ "event_id": "$image:localhost",115
+ "origin_server_ts": 3,116
+ "content": {117
+ "msgtype": "m.image",118
+ "body": "image.png",119
+ "url": "mxc://localhost/avatar",120
+ "info": {"mimetype": "image/png", "size": 13}121
+ }122
+ }],123
+ "prev_batch": "older-page"124
+ }})125
+ } else {126
+ json!({})127
+ };128
+ ResponseTemplate::new(200)129
+ .set_body_json(json!({"pos":"1","lists":{},"rooms":rooms,"extensions":{}}))130
+ .set_delay(Duration::from_millis(100))131
+ })132
+ .mount(matrix.server())133
+ .await;134
+ Mock::given(method("GET"))135
+ .and(path_regex("/rooms/.*/messages$"))136
+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({137
+ "start": "older-page",138
+ "chunk": [{139
+ "type": "m.room.message",140
+ "room_id": "!malice:localhost",141
+ "sender": "@cheeky_monkey:matrix.org",142
+ "event_id": "$older:localhost",143
+ "origin_server_ts": 1,144
+ "content": {"msgtype":"m.text", "body":"older channel message"}145
+ }]146
+ })))147
+ .mount(matrix.server())148
+ .await;149
+ matrix150
+ .mock_room_state_encryption()151
+ .expect_access_token("abc123")152
+ .plain()153
+ .mount()154
+ .await;155
+ matrix156
+ .mock_room_send()157
+ .expect_access_token("abc123")158
+ .body_matches_partial_json(json!({"body":"backend room smoke"}))159
+ .ok(matrix_sdk::ruma::event_id!("$sent:localhost"))160
+ .expect(1)161
+ .mount()162
+ .await;163
+164
+ Mock::given(method("GET"))165
+ .and(path_regex("/download/[^/]+/avatar$"))166
+ .respond_with(ResponseTemplate::new(200).set_body_bytes(b"\x89PNG\r\n\x1a\nimage".to_vec()))167
+ .mount(matrix.server())168
+ .await;169
+ Mock::given(method("GET"))170
+ .and(path_regex("/download/[^/]+/svg$"))171
+ .respond_with(172
+ ResponseTemplate::new(200).set_body_bytes(b"<svg onload='alert(1)'/>".to_vec()),173
+ )174
+ .mount(matrix.server())175
+ .await;176
+177
+ let data_dir = tempfile::tempdir().expect("temporary user stores");178
+ let client = Client::new();179
+ let port = unused_port();180
+ let base = format!("http://127.0.0.1:{port}");181
+ let server = start(port, &data_dir, &client).await;182
+183
+ let guest: Value = client184
+ .get(format!("{base}/api/session"))185
+ .send()186
+ .await187
+ .unwrap()188
+ .json()189
+ .await190
+ .unwrap();191
+ assert_eq!(guest["user_id"], Value::Null);192
+ assert_eq!(193
+ client194
+ .get(format!("{base}/api/rooms"))195
+ .send()196
+ .await197
+ .unwrap()198
+ .status(),199
+ StatusCode::UNAUTHORIZED200
+ );201
+202
+ let cross_origin = client203
+ .post(format!("{base}/api/login"))204
+ .header(header::ORIGIN, "https://attacker.example")205
+ .json(&json!({206
+ "homeserver": matrix.uri(),207
+ "username": "alice",208
+ "password": "test-password"209
+ }))210
+ .send()211
+ .await212
+ .unwrap();213
+ assert_eq!(cross_origin.status(), StatusCode::FORBIDDEN);214
+215
+ let cross_port = client216
+ .post(format!("{base}/api/login"))217
+ .header(header::ORIGIN, format!("http://127.0.0.1:{}", port + 1))218
+ .json(&json!({219
+ "homeserver": matrix.uri(),220
+ "username": "alice",221
+ "password": "test-password"222
+ }))223
+ .send()224
+ .await225
+ .unwrap();226
+ assert_eq!(cross_port.status(), StatusCode::FORBIDDEN);227
+228
+ let login = client229
+ .post(format!("{base}/api/login"))230
+ .header(header::ORIGIN, &base)231
+ .json(&json!({232
+ "homeserver": matrix.uri(),233
+ "username": "alice",234
+ "password": "test-password"235
+ }))236
+ .send()237
+ .await238
+ .unwrap();239
+ assert!(240
+ login.status().is_success(),241
+ "login returned {}",242
+ login.status()243
+ );244
+ let cookie = login245
+ .headers()246
+ .get(header::SET_COOKIE)247
+ .expect("opaque session cookie")248
+ .to_str()249
+ .unwrap()250
+ .split(';')251
+ .next()252
+ .unwrap()253
+ .to_owned();254
+ let logged_in: Value = login.json().await.unwrap();255
+ assert!(logged_in["user_id"].as_str().is_some());256
+ #[cfg(unix)]257
+ {258
+ use std::os::unix::fs::PermissionsExt;259
+ let sid = cookie.split_once('=').unwrap().1;260
+ let mode = |path: std::path::PathBuf| {261
+ std::fs::metadata(path).unwrap().permissions().mode() & 0o777262
+ };263
+ assert_eq!(mode(data_dir.path().join("master.key")), 0o600);264
+ assert_eq!(mode(data_dir.path().join("sessions")), 0o700);265
+ assert_eq!(266
+ mode(data_dir.path().join("sessions").join(format!("{sid}.bin"))),267
+ 0o600268
+ );269
+ }270
+ assert_eq!(271
+ client272
+ .get(format!("{base}/api/rooms"))273
+ .send()274
+ .await275
+ .unwrap()276
+ .status(),277
+ StatusCode::UNAUTHORIZED,278
+ "one browser must not inherit another browser's Matrix session"279
+ );280
+ assert_eq!(281
+ client282
+ .get(format!("{base}/api/media"))283
+ .query(&[("mxc", "mxc://localhost/avatar")])284
+ .send()285
+ .await286
+ .unwrap()287
+ .status(),288
+ StatusCode::UNAUTHORIZED,289
+ );290
+ assert_eq!(291
+ client292
+ .get(format!("{base}/api/media"))293
+ .header(header::COOKIE, &cookie)294
+ .query(&[("mxc", "not-a-mxc")])295
+ .send()296
+ .await297
+ .unwrap()298
+ .status(),299
+ StatusCode::BAD_REQUEST,300
+ );301
+ let avatar = client302
+ .get(format!("{base}/api/media"))303
+ .header(header::COOKIE, &cookie)304
+ .query(&[("mxc", "mxc://localhost/avatar")])305
+ .send()306
+ .await307
+ .unwrap();308
+ assert_eq!(avatar.status(), StatusCode::OK);309
+ assert_eq!(310
+ avatar.headers().get(header::CONTENT_TYPE).unwrap(),311
+ "image/png"312
+ );313
+ assert_eq!(314
+ avatar.bytes().await.unwrap().as_ref(),315
+ b"\x89PNG\r\n\x1a\nimage"316
+ );317
+ assert_eq!(318
+ client319
+ .get(format!("{base}/api/media"))320
+ .header(header::COOKIE, &cookie)321
+ .query(&[("mxc", "mxc://localhost/svg")])322
+ .send()323
+ .await324
+ .unwrap()325
+ .status(),326
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,327
+ );328
+ let rooms = client329
+ .get(format!("{base}/api/rooms"))330
+ .header(header::COOKIE, &cookie)331
+ .send()332
+ .await333
+ .unwrap();334
+ assert!(rooms.status().is_success());335
+ let rooms: Value = rooms.json().await.unwrap();336
+ assert!(rooms["rooms"].is_array());337
+ tokio::time::timeout(Duration::from_secs(5), async {338
+ loop {339
+ let response: Value = client340
+ .get(format!("{base}/api/rooms"))341
+ .header(header::COOKIE, &cookie)342
+ .send()343
+ .await344
+ .unwrap()345
+ .json()346
+ .await347
+ .unwrap();348
+ if response["rooms"]349
+ .as_array()350
+ .unwrap()351
+ .iter()352
+ .any(|room| room["room_id"] == "!malice:localhost")353
+ {354
+ break;355
+ }356
+ tokio::time::sleep(Duration::from_millis(50)).await;357
+ }358
+ })359
+ .await360
+ .expect("joined room appeared in live room list");361
+ let open = client362
+ .post(format!("{base}/api/rooms/open"))363
+ .header(header::ORIGIN, &base)364
+ .header(header::COOKIE, &cookie)365
+ .json(&json!({"room_id":"!malice:localhost"}))366
+ .send()367
+ .await368
+ .unwrap();369
+ assert_eq!(open.status(), StatusCode::NO_CONTENT);370
+ let media_id = tokio::time::timeout(Duration::from_secs(10), async {371
+ loop {372
+ let mut stream = client373
+ .get(format!("{base}/api/events"))374
+ .header(header::COOKIE, &cookie)375
+ .send()376
+ .await377
+ .unwrap();378
+ let frame = stream.chunk().await.unwrap().expect("SSE snapshot");379
+ let text = std::str::from_utf8(&frame).unwrap();380
+ let payload = text381
+ .lines()382
+ .find_map(|line| line.strip_prefix("data: "))383
+ .unwrap();384
+ let snapshot: Value = serde_json::from_str(payload).unwrap();385
+ if let Some(id) = snapshot["timelines"]["!malice:localhost"]386
+ .as_array()387
+ .and_then(|items| {388
+ items.iter().find_map(|item| {389
+ item["Event"]["content"]["Message"]["media"]["id"].as_str()390
+ })391
+ })392
+ {393
+ break id.to_owned();394
+ }395
+ tokio::time::sleep(Duration::from_millis(50)).await;396
+ }397
+ })398
+ .await399
+ .expect("opened room registered image attachment");400
+ assert_eq!(401
+ client402
+ .get(format!("{base}/api/message-media"))403
+ .query(&[("media_id", media_id.as_str()), ("size", "Full")])404
+ .send()405
+ .await406
+ .unwrap()407
+ .status(),408
+ StatusCode::UNAUTHORIZED,409
+ );410
+ let attachment = client411
+ .get(format!("{base}/api/message-media"))412
+ .header(header::COOKIE, &cookie)413
+ .query(&[("media_id", media_id.as_str()), ("size", "Full")])414
+ .send()415
+ .await416
+ .unwrap();417
+ assert_eq!(attachment.status(), StatusCode::OK);418
+ assert_eq!(419
+ attachment.headers().get(header::CONTENT_TYPE).unwrap(),420
+ "image/png"421
+ );422
+ assert_eq!(423
+ attachment.bytes().await.unwrap().as_ref(),424
+ b"\x89PNG\r\n\x1a\nimage"425
+ );426
+ let sent = client427
+ .post(format!("{base}/api/rooms/send"))428
+ .header(header::ORIGIN, &base)429
+ .header(header::COOKIE, &cookie)430
+ .json(&json!({"room_id":"!malice:localhost", "body":"backend room smoke"}))431
+ .send()432
+ .await433
+ .unwrap();434
+ assert_eq!(sent.status(), StatusCode::NO_CONTENT);435
+ tokio::time::timeout(Duration::from_secs(5), async {436
+ loop {437
+ if matrix438
+ .received_requests()439
+ .await440
+ .unwrap()441
+ .iter()442
+ .any(|request| request.url.path().contains("/send/m.room.message/"))443
+ {444
+ break;445
+ }446
+ tokio::time::sleep(Duration::from_millis(50)).await;447
+ }448
+ })449
+ .await450
+ .expect("message reached Matrix send endpoint");451
+ tokio::time::timeout(Duration::from_secs(15), async {452
+ loop {453
+ let mut stream = client454
+ .get(format!("{base}/api/events"))455
+ .header(header::COOKIE, &cookie)456
+ .send()457
+ .await458
+ .unwrap();459
+ let frame = stream.chunk().await.unwrap().expect("SSE snapshot");460
+ let text = std::str::from_utf8(&frame).unwrap();461
+ let payload = text462
+ .lines()463
+ .find_map(|line| line.strip_prefix("data: "))464
+ .unwrap();465
+ let snapshot: Value = serde_json::from_str(payload).unwrap();466
+ if snapshot["connection"] == "online"467
+ && snapshot["timelines"]["!malice:localhost"]468
+ .as_array()469
+ .is_some_and(|items| {470
+ items.iter().any(|item| {471
+ item["Event"]["content"]["Message"]["body"] == "older channel message"472
+ && item["Event"]["event_id"] == "$older:localhost"473
+ }) && items.iter().any(|item| {474
+ item["Event"]["content"]["Message"]["body"] == "backend room smoke"475
+ && item["Event"]["event_id"] == "$sent:localhost"476
+ && item["Event"]["send_state"] == "Sent"477
+ })478
+ })479
+ {480
+ break;481
+ }482
+ tokio::time::sleep(Duration::from_millis(50)).await;483
+ }484
+ })485
+ .await486
+ .expect("server-confirmed message appeared in online timeline SSE snapshot");487
+488
+ drop(server);489
+ let _restored = start(port, &data_dir, &client).await;490
+ let session: Value = client491
+ .get(format!("{base}/api/session"))492
+ .header(header::COOKIE, &cookie)493
+ .send()494
+ .await495
+ .unwrap()496
+ .json()497
+ .await498
+ .unwrap();499
+ assert_eq!(session["user_id"], logged_in["user_id"]);500
+ let mut events = client501
+ .get(format!("{base}/api/events"))502
+ .header(header::COOKIE, &cookie)503
+ .send()504
+ .await505
+ .unwrap();506
+ assert_eq!(events.status(), StatusCode::OK);507
+ let initial = events.chunk().await.unwrap().expect("initial SSE frame");508
+ assert!(String::from_utf8_lossy(&initial).contains("\"type\":\"snapshot\""));509
+510
+ matrix511
+ .mock_logout()512
+ .expect_access_token("abc123")513
+ .ok()514
+ .expect(1)515
+ .mount()516
+ .await;517
+ let logout = client518
+ .post(format!("{base}/api/logout"))519
+ .header(header::ORIGIN, &base)520
+ .header(header::COOKIE, &cookie)521
+ .send()522
+ .await523
+ .unwrap();524
+ assert_eq!(logout.status(), StatusCode::NO_CONTENT);525
+ tokio::time::timeout(Duration::from_secs(2), async {526
+ while events.chunk().await.unwrap().is_some() {}527
+ })528
+ .await529
+ .expect("logout must close the authenticated event stream");530
+ let after_logout: Value = client531
+ .get(format!("{base}/api/session"))532
+ .header(header::COOKIE, &cookie)533
+ .send()534
+ .await535
+ .unwrap()536
+ .json()537
+ .await538
+ .unwrap();539
+ assert_eq!(after_logout["user_id"], Value::Null);540
+}@@ -0,0 +1,139 @@
1
+use std::{2
+ net::TcpListener,3
+ path::PathBuf,4
+ process::{Child, Command, Stdio},5
+ time::Duration,6
+};7
+8
+use reqwest::{Client, StatusCode, header};9
+use tempfile::TempDir;10
+11
+struct Server(Child);12
+13
+impl Drop for Server {14
+ fn drop(&mut self) {15
+ let _ = self.0.kill();16
+ let _ = self.0.wait();17
+ }18
+}19
+20
+fn server_binary() -> PathBuf {21
+ option_env!("CARGO_BIN_EXE_malice")22
+ .map(PathBuf::from)23
+ .unwrap_or_else(|| {24
+ std::env::current_exe()25
+ .expect("integration test executable")26
+ .parent()27
+ .expect("cargo deps directory")28
+ .parent()29
+ .expect("cargo target directory")30
+ .join("malice")31
+ })32
+}33
+34
+fn unused_port() -> u16 {35
+ TcpListener::bind("127.0.0.1:0")36
+ .expect("bind ephemeral port")37
+ .local_addr()38
+ .expect("read ephemeral port")39
+ .port()40
+}41
+42
+async fn start(port: u16, web_dir: &TempDir, idle_secs: u64, client: &Client) -> Server {43
+ let child = Command::new(server_binary())44
+ .env("MALICE_BIND", format!("127.0.0.1:{port}"))45
+ .env("MALICE_STATIC_ONLY", "1")46
+ .env("MALICE_WEB_DIR", web_dir.path())47
+ .env("MALICE_IDLE_EXIT_SECS", idle_secs.to_string())48
+ .stdout(Stdio::null())49
+ .stderr(Stdio::inherit())50
+ .spawn()51
+ .expect("start Malice backend");52
+ let mut server = Server(child);53
+ tokio::time::timeout(Duration::from_secs(30), async {54
+ loop {55
+ if let Some(status) = server.0.try_wait().expect("poll backend") {56
+ panic!("Malice exited before it started serving: {status}");57
+ }58
+ if client59
+ .get(format!("http://127.0.0.1:{port}/"))60
+ .send()61
+ .await62
+ .is_ok_and(|response| response.status().is_success())63
+ {64
+ break;65
+ }66
+ tokio::time::sleep(Duration::from_millis(100)).await;67
+ }68
+ })69
+ .await70
+ .expect("Malice did not start");71
+ server72
+}73
+74
+#[tokio::test]75
+async fn static_only_serves_the_bundle_and_exits_when_idle() {76
+ let web_dir = tempfile::tempdir().expect("temporary web dir");77
+ std::fs::write(web_dir.path().join("index.html"), "<html>malice</html>")78
+ .expect("write index.html");79
+ std::fs::write(web_dir.path().join("sw.js"), "// service worker").expect("write sw.js");80
+ std::fs::write(web_dir.path().join("style.css"), "body {}").expect("write style.css");81
+82
+ let client = Client::new();83
+ let port = unused_port();84
+ let base = format!("http://127.0.0.1:{port}");85
+ let mut server = start(port, &web_dir, 1, &client).await;86
+87
+ let index = client.get(&base).send().await.unwrap();88
+ assert_eq!(index.status(), StatusCode::OK);89
+ assert_eq!(90
+ index.headers().get(header::CACHE_CONTROL).unwrap(),91
+ "no-cache"92
+ );93
+ assert_eq!(index.headers().get("service-worker-allowed").unwrap(), "/");94
+ assert_eq!(index.text().await.unwrap(), "<html>malice</html>");95
+96
+ let sw = client.get(format!("{base}/sw.js")).send().await.unwrap();97
+ assert_eq!(sw.status(), StatusCode::OK);98
+ assert_eq!(sw.headers().get(header::CACHE_CONTROL).unwrap(), "no-cache");99
+ assert_eq!(sw.headers().get("service-worker-allowed").unwrap(), "/");100
+101
+ let asset = client102
+ .get(format!("{base}/style.css"))103
+ .send()104
+ .await105
+ .unwrap();106
+ assert_eq!(asset.status(), StatusCode::OK);107
+ assert!(asset.headers().get(header::CACHE_CONTROL).is_none());108
+109
+ assert_eq!(110
+ client111
+ .get(format!("{base}/api/rooms"))112
+ .send()113
+ .await114
+ .unwrap()115
+ .status(),116
+ StatusCode::NOT_FOUND,117
+ );118
+ assert_eq!(119
+ client120
+ .get(format!("{base}/api/"))121
+ .send()122
+ .await123
+ .unwrap()124
+ .status(),125
+ StatusCode::NOT_FOUND,126
+ );127
+128
+ let status = tokio::time::timeout(Duration::from_secs(10), async {129
+ loop {130
+ if let Some(status) = server.0.try_wait().expect("poll backend") {131
+ return status;132
+ }133
+ tokio::time::sleep(Duration::from_millis(50)).await;134
+ }135
+ })136
+ .await137
+ .expect("Malice did not exit once idle");138
+ assert!(status.success(), "idle exit should be clean: {status}");139
+}@@ -0,0 +1,3 @@
1
+[target.wasm32-unknown-unknown]2
+runner = "wasm-bindgen-test-runner"3
+rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]@@ -0,0 +1,4532 @@
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.1.0",12
+ "proc-macro2",13
+ "quote",14
+ "syn 3.0.6",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 = "assign"111
+version = "1.1.1"112
+source = "registry+https://github.com/rust-lang/crates.io-index"113
+checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002"114
+115
+[[package]]116
+name = "async-channel"117
+version = "2.5.0"118
+source = "registry+https://github.com/rust-lang/crates.io-index"119
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"120
+dependencies = [121
+ "concurrent-queue",122
+ "event-listener-strategy",123
+ "futures-core",124
+ "pin-project-lite",125
+]126
+127
+[[package]]128
+name = "async-compression"129
+version = "0.4.48"130
+source = "registry+https://github.com/rust-lang/crates.io-index"131
+checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"132
+dependencies = [133
+ "compression-codecs",134
+ "compression-core",135
+ "pin-project-lite",136
+ "tokio",137
+]138
+139
+[[package]]140
+name = "async-once-cell"141
+version = "0.5.4"142
+source = "registry+https://github.com/rust-lang/crates.io-index"143
+checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"144
+145
+[[package]]146
+name = "async-rx"147
+version = "0.2.1"148
+source = "registry+https://github.com/rust-lang/crates.io-index"149
+checksum = "a82c1a761cd7b772912fd2500f2c0a850c0c72f5f66543038393ac6cfaa3659a"150
+dependencies = [151
+ "futures-core",152
+ "pin-project-lite",153
+]154
+155
+[[package]]156
+name = "async-stream"157
+version = "0.3.6"158
+source = "registry+https://github.com/rust-lang/crates.io-index"159
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"160
+dependencies = [161
+ "async-stream-impl",162
+ "futures-core",163
+ "pin-project-lite",164
+]165
+166
+[[package]]167
+name = "async-stream-impl"168
+version = "0.3.6"169
+source = "registry+https://github.com/rust-lang/crates.io-index"170
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"171
+dependencies = [172
+ "proc-macro2",173
+ "quote",174
+ "syn 2.0.119",175
+]176
+177
+[[package]]178
+name = "async-trait"179
+version = "0.1.92"180
+source = "registry+https://github.com/rust-lang/crates.io-index"181
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"182
+dependencies = [183
+ "proc-macro2",184
+ "quote",185
+ "syn 3.0.6",186
+]187
+188
+[[package]]189
+name = "async_cell"190
+version = "0.2.3"191
+source = "registry+https://github.com/rust-lang/crates.io-index"192
+checksum = "447ab28afbb345f5408b120702a44e5529ebf90b1796ec76e9528df8e288e6c2"193
+dependencies = [194
+ "loom",195
+]196
+197
+[[package]]198
+name = "atomic-waker"199
+version = "1.1.2"200
+source = "registry+https://github.com/rust-lang/crates.io-index"201
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"202
+203
+[[package]]204
+name = "autocfg"205
+version = "1.5.1"206
+source = "registry+https://github.com/rust-lang/crates.io-index"207
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"208
+209
+[[package]]210
+name = "aws-lc-rs"211
+version = "1.18.1"212
+source = "registry+https://github.com/rust-lang/crates.io-index"213
+checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"214
+dependencies = [215
+ "aws-lc-sys",216
+ "zeroize",217
+]218
+219
+[[package]]220
+name = "aws-lc-sys"221
+version = "0.45.0"222
+source = "registry+https://github.com/rust-lang/crates.io-index"223
+checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"224
+dependencies = [225
+ "cc",226
+ "cmake",227
+ "dunce",228
+ "fs_extra",229
+ "pkg-config",230
+]231
+232
+[[package]]233
+name = "backon"234
+version = "1.6.0"235
+source = "registry+https://github.com/rust-lang/crates.io-index"236
+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"237
+dependencies = [238
+ "fastrand",239
+ "gloo-timers",240
+ "tokio",241
+]242
+243
+[[package]]244
+name = "base64"245
+version = "0.22.1"246
+source = "registry+https://github.com/rust-lang/crates.io-index"247
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"248
+249
+[[package]]250
+name = "base64"251
+version = "0.23.1"252
+source = "registry+https://github.com/rust-lang/crates.io-index"253
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"254
+255
+[[package]]256
+name = "base64ct"257
+version = "1.8.3"258
+source = "registry+https://github.com/rust-lang/crates.io-index"259
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"260
+261
+[[package]]262
+name = "bitflags"263
+version = "2.13.2"264
+source = "registry+https://github.com/rust-lang/crates.io-index"265
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"266
+dependencies = [267
+ "serde_core",268
+]269
+270
+[[package]]271
+name = "bitmaps"272
+version = "3.2.1"273
+source = "registry+https://github.com/rust-lang/crates.io-index"274
+checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"275
+276
+[[package]]277
+name = "blake3"278
+version = "1.8.7"279
+source = "registry+https://github.com/rust-lang/crates.io-index"280
+checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"281
+dependencies = [282
+ "arrayvec",283
+ "cc",284
+ "cfg-if",285
+ "constant_time_eq",286
+ "cpufeatures 0.3.1",287
+]288
+289
+[[package]]290
+name = "block-buffer"291
+version = "0.10.4"292
+source = "registry+https://github.com/rust-lang/crates.io-index"293
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"294
+dependencies = [295
+ "generic-array",296
+]297
+298
+[[package]]299
+name = "block-padding"300
+version = "0.3.3"301
+source = "registry+https://github.com/rust-lang/crates.io-index"302
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"303
+dependencies = [304
+ "generic-array",305
+]306
+307
+[[package]]308
+name = "bs58"309
+version = "0.5.1"310
+source = "registry+https://github.com/rust-lang/crates.io-index"311
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"312
+dependencies = [313
+ "tinyvec",314
+]315
+316
+[[package]]317
+name = "bumpalo"318
+version = "3.20.3"319
+source = "registry+https://github.com/rust-lang/crates.io-index"320
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"321
+322
+[[package]]323
+name = "byteorder"324
+version = "1.5.0"325
+source = "registry+https://github.com/rust-lang/crates.io-index"326
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"327
+328
+[[package]]329
+name = "bytes"330
+version = "1.12.1"331
+source = "registry+https://github.com/rust-lang/crates.io-index"332
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"333
+334
+[[package]]335
+name = "bytesize"336
+version = "2.7.0"337
+source = "registry+https://github.com/rust-lang/crates.io-index"338
+checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b"339
+340
+[[package]]341
+name = "cast"342
+version = "0.3.0"343
+source = "registry+https://github.com/rust-lang/crates.io-index"344
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"345
+346
+[[package]]347
+name = "cbc"348
+version = "0.1.2"349
+source = "registry+https://github.com/rust-lang/crates.io-index"350
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"351
+dependencies = [352
+ "cipher",353
+]354
+355
+[[package]]356
+name = "cc"357
+version = "1.5.1"358
+source = "registry+https://github.com/rust-lang/crates.io-index"359
+checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"360
+dependencies = [361
+ "find-msvc-tools",362
+ "jobserver",363
+ "libc",364
+ "shlex",365
+]366
+367
+[[package]]368
+name = "cfg-if"369
+version = "1.0.5"370
+source = "registry+https://github.com/rust-lang/crates.io-index"371
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"372
+373
+[[package]]374
+name = "cfg_aliases"375
+version = "0.2.2"376
+source = "registry+https://github.com/rust-lang/crates.io-index"377
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"378
+379
+[[package]]380
+name = "chacha20"381
+version = "0.9.1"382
+source = "registry+https://github.com/rust-lang/crates.io-index"383
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"384
+dependencies = [385
+ "cfg-if",386
+ "cipher",387
+ "cpufeatures 0.2.17",388
+]389
+390
+[[package]]391
+name = "chacha20"392
+version = "0.10.2"393
+source = "registry+https://github.com/rust-lang/crates.io-index"394
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"395
+dependencies = [396
+ "cfg-if",397
+ "cpufeatures 0.3.1",398
+ "rand_core 0.10.1",399
+]400
+401
+[[package]]402
+name = "chacha20poly1305"403
+version = "0.10.1"404
+source = "registry+https://github.com/rust-lang/crates.io-index"405
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"406
+dependencies = [407
+ "aead",408
+ "chacha20 0.9.1",409
+ "cipher",410
+ "poly1305",411
+ "zeroize",412
+]413
+414
+[[package]]415
+name = "chrono"416
+version = "0.4.45"417
+source = "registry+https://github.com/rust-lang/crates.io-index"418
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"419
+dependencies = [420
+ "iana-time-zone",421
+ "js-sys",422
+ "num-traits",423
+ "serde",424
+ "wasm-bindgen",425
+ "windows-link",426
+]427
+428
+[[package]]429
+name = "cipher"430
+version = "0.4.4"431
+source = "registry+https://github.com/rust-lang/crates.io-index"432
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"433
+dependencies = [434
+ "crypto-common",435
+ "inout",436
+ "zeroize",437
+]438
+439
+[[package]]440
+name = "cmake"441
+version = "0.1.58"442
+source = "registry+https://github.com/rust-lang/crates.io-index"443
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"444
+dependencies = [445
+ "cc",446
+]447
+448
+[[package]]449
+name = "combine"450
+version = "4.6.8"451
+source = "registry+https://github.com/rust-lang/crates.io-index"452
+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"453
+dependencies = [454
+ "bytes",455
+ "memchr",456
+]457
+458
+[[package]]459
+name = "compression-codecs"460
+version = "0.4.43"461
+source = "registry+https://github.com/rust-lang/crates.io-index"462
+checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"463
+dependencies = [464
+ "compression-core",465
+ "flate2",466
+ "memchr",467
+]468
+469
+[[package]]470
+name = "compression-core"471
+version = "0.4.33"472
+source = "registry+https://github.com/rust-lang/crates.io-index"473
+checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"474
+475
+[[package]]476
+name = "concurrent-queue"477
+version = "2.5.0"478
+source = "registry+https://github.com/rust-lang/crates.io-index"479
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"480
+dependencies = [481
+ "crossbeam-utils",482
+]483
+484
+[[package]]485
+name = "const-oid"486
+version = "0.9.6"487
+source = "registry+https://github.com/rust-lang/crates.io-index"488
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"489
+490
+[[package]]491
+name = "const_panic"492
+version = "0.2.17"493
+source = "registry+https://github.com/rust-lang/crates.io-index"494
+checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3"495
+dependencies = [496
+ "typewit",497
+]498
+499
+[[package]]500
+name = "constant_time_eq"501
+version = "0.4.2"502
+source = "registry+https://github.com/rust-lang/crates.io-index"503
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"504
+505
+[[package]]506
+name = "core-foundation"507
+version = "0.10.1"508
+source = "registry+https://github.com/rust-lang/crates.io-index"509
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"510
+dependencies = [511
+ "core-foundation-sys",512
+ "libc",513
+]514
+515
+[[package]]516
+name = "core-foundation-sys"517
+version = "0.8.7"518
+source = "registry+https://github.com/rust-lang/crates.io-index"519
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"520
+521
+[[package]]522
+name = "cpufeatures"523
+version = "0.2.17"524
+source = "registry+https://github.com/rust-lang/crates.io-index"525
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"526
+dependencies = [527
+ "libc",528
+]529
+530
+[[package]]531
+name = "cpufeatures"532
+version = "0.3.1"533
+source = "registry+https://github.com/rust-lang/crates.io-index"534
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"535
+dependencies = [536
+ "libc",537
+]538
+539
+[[package]]540
+name = "crc32fast"541
+version = "1.5.2"542
+source = "registry+https://github.com/rust-lang/crates.io-index"543
+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"544
+dependencies = [545
+ "cfg-if",546
+]547
+548
+[[package]]549
+name = "crossbeam-utils"550
+version = "0.8.23"551
+source = "registry+https://github.com/rust-lang/crates.io-index"552
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"553
+554
+[[package]]555
+name = "crypto-common"556
+version = "0.1.7"557
+source = "registry+https://github.com/rust-lang/crates.io-index"558
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"559
+dependencies = [560
+ "generic-array",561
+ "rand_core 0.6.4",562
+ "typenum",563
+]564
+565
+[[package]]566
+name = "ctr"567
+version = "0.9.2"568
+source = "registry+https://github.com/rust-lang/crates.io-index"569
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"570
+dependencies = [571
+ "cipher",572
+]573
+574
+[[package]]575
+name = "curve25519-dalek"576
+version = "4.1.3"577
+source = "registry+https://github.com/rust-lang/crates.io-index"578
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"579
+dependencies = [580
+ "cfg-if",581
+ "cpufeatures 0.2.17",582
+ "curve25519-dalek-derive",583
+ "digest",584
+ "fiat-crypto",585
+ "rustc_version",586
+ "serde",587
+ "subtle",588
+ "zeroize",589
+]590
+591
+[[package]]592
+name = "curve25519-dalek-derive"593
+version = "0.1.1"594
+source = "registry+https://github.com/rust-lang/crates.io-index"595
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"596
+dependencies = [597
+ "proc-macro2",598
+ "quote",599
+ "syn 2.0.119",600
+]601
+602
+[[package]]603
+name = "date_header"604
+version = "1.0.5"605
+source = "registry+https://github.com/rust-lang/crates.io-index"606
+checksum = "0c03c416ed1a30fbb027ef484ba6ab6f80e1eada675e1a2b92fd673c045a1f1d"607
+608
+[[package]]609
+name = "deadpool"610
+version = "0.13.1"611
+source = "registry+https://github.com/rust-lang/crates.io-index"612
+checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e"613
+dependencies = [614
+ "deadpool-runtime",615
+ "tokio",616
+]617
+618
+[[package]]619
+name = "deadpool-runtime"620
+version = "0.3.1"621
+source = "registry+https://github.com/rust-lang/crates.io-index"622
+checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae"623
+dependencies = [624
+ "tokio",625
+]626
+627
+[[package]]628
+name = "deadpool-sync"629
+version = "0.2.0"630
+source = "registry+https://github.com/rust-lang/crates.io-index"631
+checksum = "e385cc95d3d582c328b36d1ff90feac061102b001894b555e6b465a2e0eaabbf"632
+dependencies = [633
+ "deadpool-runtime",634
+]635
+636
+[[package]]637
+name = "decancer"638
+version = "3.3.3"639
+source = "registry+https://github.com/rust-lang/crates.io-index"640
+checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"641
+642
+[[package]]643
+name = "delegate-display"644
+version = "3.0.1"645
+source = "registry+https://github.com/rust-lang/crates.io-index"646
+checksum = "54e3a499943fd5180aeffcb708164ffb22125ceeccb05491b5297981d37d6249"647
+dependencies = [648
+ "impartial-ord",649
+ "itoa",650
+ "macroific 3.1.0",651
+ "proc-macro2",652
+ "quote",653
+ "syn 3.0.6",654
+]655
+656
+[[package]]657
+name = "der"658
+version = "0.7.10"659
+source = "registry+https://github.com/rust-lang/crates.io-index"660
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"661
+dependencies = [662
+ "const-oid",663
+ "zeroize",664
+]665
+666
+[[package]]667
+name = "deranged"668
+version = "0.5.8"669
+source = "registry+https://github.com/rust-lang/crates.io-index"670
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"671
+672
+[[package]]673
+name = "derivative"674
+version = "2.2.0"675
+source = "registry+https://github.com/rust-lang/crates.io-index"676
+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"677
+dependencies = [678
+ "proc-macro2",679
+ "quote",680
+ "syn 1.0.109",681
+]682
+683
+[[package]]684
+name = "derive_more"685
+version = "1.0.0"686
+source = "registry+https://github.com/rust-lang/crates.io-index"687
+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"688
+dependencies = [689
+ "derive_more-impl 1.0.0",690
+]691
+692
+[[package]]693
+name = "derive_more"694
+version = "2.1.1"695
+source = "registry+https://github.com/rust-lang/crates.io-index"696
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"697
+dependencies = [698
+ "derive_more-impl 2.1.1",699
+]700
+701
+[[package]]702
+name = "derive_more-impl"703
+version = "1.0.0"704
+source = "registry+https://github.com/rust-lang/crates.io-index"705
+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"706
+dependencies = [707
+ "proc-macro2",708
+ "quote",709
+ "syn 2.0.119",710
+]711
+712
+[[package]]713
+name = "derive_more-impl"714
+version = "2.1.1"715
+source = "registry+https://github.com/rust-lang/crates.io-index"716
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"717
+dependencies = [718
+ "proc-macro2",719
+ "quote",720
+ "rustc_version",721
+ "syn 2.0.119",722
+ "unicode-xid",723
+]724
+725
+[[package]]726
+name = "digest"727
+version = "0.10.7"728
+source = "registry+https://github.com/rust-lang/crates.io-index"729
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"730
+dependencies = [731
+ "block-buffer",732
+ "crypto-common",733
+ "subtle",734
+]735
+736
+[[package]]737
+name = "displaydoc"738
+version = "0.2.7"739
+source = "registry+https://github.com/rust-lang/crates.io-index"740
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"741
+dependencies = [742
+ "proc-macro2",743
+ "quote",744
+ "syn 3.0.6",745
+]746
+747
+[[package]]748
+name = "dunce"749
+version = "1.0.5"750
+source = "registry+https://github.com/rust-lang/crates.io-index"751
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"752
+753
+[[package]]754
+name = "ed25519"755
+version = "2.2.3"756
+source = "registry+https://github.com/rust-lang/crates.io-index"757
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"758
+dependencies = [759
+ "pkcs8",760
+ "serde",761
+ "signature",762
+]763
+764
+[[package]]765
+name = "ed25519-dalek"766
+version = "2.2.0"767
+source = "registry+https://github.com/rust-lang/crates.io-index"768
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"769
+dependencies = [770
+ "curve25519-dalek",771
+ "ed25519",772
+ "rand_core 0.6.4",773
+ "serde",774
+ "sha2",775
+ "subtle",776
+ "zeroize",777
+]778
+779
+[[package]]780
+name = "either"781
+version = "1.18.0"782
+source = "registry+https://github.com/rust-lang/crates.io-index"783
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"784
+785
+[[package]]786
+name = "emojis"787
+version = "0.8.2"788
+source = "registry+https://github.com/rust-lang/crates.io-index"789
+checksum = "0a4d5d50b0b58df5173d8ff1192b4d1422ceae5d981b30d4b6f8ed1d673a2bc4"790
+dependencies = [791
+ "phf",792
+]793
+794
+[[package]]795
+name = "equivalent"796
+version = "1.0.2"797
+source = "registry+https://github.com/rust-lang/crates.io-index"798
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"799
+800
+[[package]]801
+name = "errno"802
+version = "0.3.14"803
+source = "registry+https://github.com/rust-lang/crates.io-index"804
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"805
+dependencies = [806
+ "libc",807
+ "windows-sys 0.61.2",808
+]809
+810
+[[package]]811
+name = "event-listener"812
+version = "5.4.2"813
+source = "registry+https://github.com/rust-lang/crates.io-index"814
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"815
+dependencies = [816
+ "parking",817
+ "pin-project-lite",818
+]819
+820
+[[package]]821
+name = "event-listener-strategy"822
+version = "0.5.4"823
+source = "registry+https://github.com/rust-lang/crates.io-index"824
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"825
+dependencies = [826
+ "event-listener",827
+ "pin-project-lite",828
+]829
+830
+[[package]]831
+name = "eyeball"832
+version = "0.8.8"833
+source = "registry+https://github.com/rust-lang/crates.io-index"834
+checksum = "d93bd0ebf93d61d6332d3c09a96e97975968a44e19a64c947bde06e6baff383f"835
+dependencies = [836
+ "futures-core",837
+ "readlock",838
+ "readlock-tokio",839
+ "tokio",840
+ "tokio-util",841
+ "tracing",842
+]843
+844
+[[package]]845
+name = "eyeball-im"846
+version = "0.8.0"847
+source = "registry+https://github.com/rust-lang/crates.io-index"848
+checksum = "4790c03df183c2b46665c1a58118c04fd3e3976ec2fe16a0aa00e00c9eea7754"849
+dependencies = [850
+ "futures-core",851
+ "imbl",852
+ "tokio",853
+ "tracing",854
+]855
+856
+[[package]]857
+name = "eyeball-im-util"858
+version = "0.10.0"859
+source = "registry+https://github.com/rust-lang/crates.io-index"860
+checksum = "809fa2e8e59c25a41f5b2440aa937f1e84df6ae48b707e65f69ce259b1c44bda"861
+dependencies = [862
+ "arrayvec",863
+ "eyeball-im",864
+ "futures-core",865
+ "imbl",866
+ "pin-project-lite",867
+ "smallvec",868
+]869
+870
+[[package]]871
+name = "fallible-iterator"872
+version = "0.3.0"873
+source = "registry+https://github.com/rust-lang/crates.io-index"874
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"875
+876
+[[package]]877
+name = "fallible-streaming-iterator"878
+version = "0.1.9"879
+source = "registry+https://github.com/rust-lang/crates.io-index"880
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"881
+882
+[[package]]883
+name = "fancy_constructor"884
+version = "2.1.1"885
+source = "registry+https://github.com/rust-lang/crates.io-index"886
+checksum = "4a8211ab12c36b63c269e17873331dc1da196a3edb7a5c79015677ef972da34a"887
+dependencies = [888
+ "macroific 3.1.0",889
+ "proc-macro2",890
+ "quote",891
+ "syn 3.0.6",892
+]893
+894
+[[package]]895
+name = "fastrand"896
+version = "2.5.0"897
+source = "registry+https://github.com/rust-lang/crates.io-index"898
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"899
+900
+[[package]]901
+name = "fiat-crypto"902
+version = "0.2.9"903
+source = "registry+https://github.com/rust-lang/crates.io-index"904
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"905
+906
+[[package]]907
+name = "find-msvc-tools"908
+version = "0.1.14"909
+source = "registry+https://github.com/rust-lang/crates.io-index"910
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"911
+912
+[[package]]913
+name = "flate2"914
+version = "1.1.10"915
+source = "registry+https://github.com/rust-lang/crates.io-index"916
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"917
+dependencies = [918
+ "crc32fast",919
+ "miniz_oxide",920
+ "zlib-rs",921
+]922
+923
+[[package]]924
+name = "fnv"925
+version = "1.0.7"926
+source = "registry+https://github.com/rust-lang/crates.io-index"927
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"928
+929
+[[package]]930
+name = "foldhash"931
+version = "0.1.5"932
+source = "registry+https://github.com/rust-lang/crates.io-index"933
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"934
+935
+[[package]]936
+name = "form_urlencoded"937
+version = "1.2.2"938
+source = "registry+https://github.com/rust-lang/crates.io-index"939
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"940
+dependencies = [941
+ "percent-encoding",942
+]943
+944
+[[package]]945
+name = "fs_extra"946
+version = "1.3.0"947
+source = "registry+https://github.com/rust-lang/crates.io-index"948
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"949
+950
+[[package]]951
+name = "futures-channel"952
+version = "0.3.34"953
+source = "registry+https://github.com/rust-lang/crates.io-index"954
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"955
+dependencies = [956
+ "futures-core",957
+]958
+959
+[[package]]960
+name = "futures-core"961
+version = "0.3.34"962
+source = "registry+https://github.com/rust-lang/crates.io-index"963
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"964
+965
+[[package]]966
+name = "futures-executor"967
+version = "0.3.34"968
+source = "registry+https://github.com/rust-lang/crates.io-index"969
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"970
+dependencies = [971
+ "futures-core",972
+ "futures-task",973
+ "futures-util",974
+]975
+976
+[[package]]977
+name = "futures-io"978
+version = "0.3.34"979
+source = "registry+https://github.com/rust-lang/crates.io-index"980
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"981
+982
+[[package]]983
+name = "futures-macro"984
+version = "0.3.34"985
+source = "registry+https://github.com/rust-lang/crates.io-index"986
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"987
+dependencies = [988
+ "proc-macro2",989
+ "quote",990
+ "syn 3.0.6",991
+]992
+993
+[[package]]994
+name = "futures-sink"995
+version = "0.3.34"996
+source = "registry+https://github.com/rust-lang/crates.io-index"997
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"998
+999
+[[package]]1000
+name = "futures-task"1001
+version = "0.3.34"1002
+source = "registry+https://github.com/rust-lang/crates.io-index"1003
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"1004
+1005
+[[package]]1006
+name = "futures-util"1007
+version = "0.3.34"1008
+source = "registry+https://github.com/rust-lang/crates.io-index"1009
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"1010
+dependencies = [1011
+ "futures-channel",1012
+ "futures-core",1013
+ "futures-io",1014
+ "futures-macro",1015
+ "futures-sink",1016
+ "futures-task",1017
+ "memchr",1018
+ "pin-project-lite",1019
+ "slab",1020
+]1021
+1022
+[[package]]1023
+name = "fuzzy-matcher"1024
+version = "0.3.7"1025
+source = "registry+https://github.com/rust-lang/crates.io-index"1026
+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"1027
+dependencies = [1028
+ "thread_local",1029
+]1030
+1031
+[[package]]1032
+name = "generator"1033
+version = "0.8.10"1034
+source = "registry+https://github.com/rust-lang/crates.io-index"1035
+checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551"1036
+dependencies = [1037
+ "cc",1038
+ "cfg-if",1039
+ "libc",1040
+ "log",1041
+ "rustversion",1042
+ "windows-link",1043
+ "windows-result",1044
+]1045
+1046
+[[package]]1047
+name = "generic-array"1048
+version = "0.14.7"1049
+source = "registry+https://github.com/rust-lang/crates.io-index"1050
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"1051
+dependencies = [1052
+ "typenum",1053
+ "version_check",1054
+]1055
+1056
+[[package]]1057
+name = "getrandom"1058
+version = "0.2.17"1059
+source = "registry+https://github.com/rust-lang/crates.io-index"1060
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"1061
+dependencies = [1062
+ "cfg-if",1063
+ "js-sys",1064
+ "libc",1065
+ "wasi",1066
+ "wasm-bindgen",1067
+]1068
+1069
+[[package]]1070
+name = "getrandom"1071
+version = "0.3.4"1072
+source = "registry+https://github.com/rust-lang/crates.io-index"1073
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"1074
+dependencies = [1075
+ "cfg-if",1076
+ "js-sys",1077
+ "libc",1078
+ "r-efi 5.3.0",1079
+ "wasip2",1080
+ "wasm-bindgen",1081
+]1082
+1083
+[[package]]1084
+name = "getrandom"1085
+version = "0.4.3"1086
+source = "registry+https://github.com/rust-lang/crates.io-index"1087
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"1088
+dependencies = [1089
+ "cfg-if",1090
+ "js-sys",1091
+ "libc",1092
+ "r-efi 6.0.0",1093
+ "rand_core 0.10.1",1094
+ "wasm-bindgen",1095
+]1096
+1097
+[[package]]1098
+name = "gloo-timers"1099
+version = "0.3.0"1100
+source = "registry+https://github.com/rust-lang/crates.io-index"1101
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"1102
+dependencies = [1103
+ "futures-channel",1104
+ "futures-core",1105
+ "js-sys",1106
+ "wasm-bindgen",1107
+]1108
+1109
+[[package]]1110
+name = "gloo-utils"1111
+version = "0.2.0"1112
+source = "registry+https://github.com/rust-lang/crates.io-index"1113
+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"1114
+dependencies = [1115
+ "js-sys",1116
+ "serde",1117
+ "serde_json",1118
+ "wasm-bindgen",1119
+ "web-sys",1120
+]1121
+1122
+[[package]]1123
+name = "growable-bloom-filter"1124
+version = "2.1.1"1125
+source = "registry+https://github.com/rust-lang/crates.io-index"1126
+checksum = "d174ccb4ba660d431329e7f0797870d0a4281e36353ec4b4a3c5eab6c2cfb6f1"1127
+dependencies = [1128
+ "serde",1129
+ "serde_bytes",1130
+ "serde_derive",1131
+ "xxhash-rust",1132
+]1133
+1134
+[[package]]1135
+name = "h2"1136
+version = "0.4.19"1137
+source = "registry+https://github.com/rust-lang/crates.io-index"1138
+checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"1139
+dependencies = [1140
+ "atomic-waker",1141
+ "bytes",1142
+ "fnv",1143
+ "futures-core",1144
+ "futures-sink",1145
+ "http",1146
+ "indexmap",1147
+ "slab",1148
+ "tokio",1149
+ "tokio-util",1150
+ "tracing",1151
+]1152
+1153
+[[package]]1154
+name = "hashbrown"1155
+version = "0.15.5"1156
+source = "registry+https://github.com/rust-lang/crates.io-index"1157
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"1158
+dependencies = [1159
+ "foldhash",1160
+]1161
+1162
+[[package]]1163
+name = "hashbrown"1164
+version = "0.17.1"1165
+source = "registry+https://github.com/rust-lang/crates.io-index"1166
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"1167
+1168
+[[package]]1169
+name = "hashlink"1170
+version = "0.10.0"1171
+source = "registry+https://github.com/rust-lang/crates.io-index"1172
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"1173
+dependencies = [1174
+ "hashbrown 0.15.5",1175
+]1176
+1177
+[[package]]1178
+name = "hermit-abi"1179
+version = "0.5.3"1180
+source = "registry+https://github.com/rust-lang/crates.io-index"1181
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"1182
+1183
+[[package]]1184
+name = "hkdf"1185
+version = "0.12.4"1186
+source = "registry+https://github.com/rust-lang/crates.io-index"1187
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"1188
+dependencies = [1189
+ "hmac",1190
+]1191
+1192
+[[package]]1193
+name = "hmac"1194
+version = "0.12.1"1195
+source = "registry+https://github.com/rust-lang/crates.io-index"1196
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"1197
+dependencies = [1198
+ "digest",1199
+]1200
+1201
+[[package]]1202
+name = "html5ever"1203
+version = "0.39.0"1204
+source = "registry+https://github.com/rust-lang/crates.io-index"1205
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"1206
+dependencies = [1207
+ "log",1208
+ "markup5ever",1209
+]1210
+1211
+[[package]]1212
+name = "http"1213
+version = "1.5.0"1214
+source = "registry+https://github.com/rust-lang/crates.io-index"1215
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"1216
+dependencies = [1217
+ "bytes",1218
+ "itoa",1219
+]1220
+1221
+[[package]]1222
+name = "http-body"1223
+version = "1.1.0"1224
+source = "registry+https://github.com/rust-lang/crates.io-index"1225
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"1226
+dependencies = [1227
+ "bytes",1228
+ "http",1229
+]1230
+1231
+[[package]]1232
+name = "http-body-util"1233
+version = "0.1.5"1234
+source = "registry+https://github.com/rust-lang/crates.io-index"1235
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"1236
+dependencies = [1237
+ "bytes",1238
+ "futures-core",1239
+ "http",1240
+ "http-body",1241
+ "pin-project-lite",1242
+]1243
+1244
+[[package]]1245
+name = "httparse"1246
+version = "1.10.1"1247
+source = "registry+https://github.com/rust-lang/crates.io-index"1248
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"1249
+1250
+[[package]]1251
+name = "hyper"1252
+version = "1.11.1"1253
+source = "registry+https://github.com/rust-lang/crates.io-index"1254
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"1255
+dependencies = [1256
+ "atomic-waker",1257
+ "bytes",1258
+ "futures-channel",1259
+ "futures-core",1260
+ "h2",1261
+ "http",1262
+ "http-body",1263
+ "httparse",1264
+ "itoa",1265
+ "pin-project-lite",1266
+ "smallvec",1267
+ "tokio",1268
+ "want",1269
+]1270
+1271
+[[package]]1272
+name = "hyper-rustls"1273
+version = "0.27.10"1274
+source = "registry+https://github.com/rust-lang/crates.io-index"1275
+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"1276
+dependencies = [1277
+ "http",1278
+ "hyper",1279
+ "hyper-util",1280
+ "rustls",1281
+ "tokio",1282
+ "tokio-rustls",1283
+ "tower-service",1284
+]1285
+1286
+[[package]]1287
+name = "hyper-util"1288
+version = "0.1.21"1289
+source = "registry+https://github.com/rust-lang/crates.io-index"1290
+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"1291
+dependencies = [1292
+ "base64 0.23.1",1293
+ "bytes",1294
+ "futures-channel",1295
+ "futures-util",1296
+ "http",1297
+ "http-body",1298
+ "httparse",1299
+ "hyper",1300
+ "ipnet",1301
+ "libc",1302
+ "percent-encoding",1303
+ "pin-project-lite",1304
+ "socket2",1305
+ "tokio",1306
+ "tower-service",1307
+ "tracing",1308
+]1309
+1310
+[[package]]1311
+name = "iana-time-zone"1312
+version = "0.1.65"1313
+source = "registry+https://github.com/rust-lang/crates.io-index"1314
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"1315
+dependencies = [1316
+ "android_system_properties",1317
+ "core-foundation-sys",1318
+ "iana-time-zone-haiku",1319
+ "js-sys",1320
+ "log",1321
+ "wasm-bindgen",1322
+ "windows-core",1323
+]1324
+1325
+[[package]]1326
+name = "iana-time-zone-haiku"1327
+version = "0.1.2"1328
+source = "registry+https://github.com/rust-lang/crates.io-index"1329
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"1330
+dependencies = [1331
+ "cc",1332
+]1333
+1334
+[[package]]1335
+name = "icu_collections"1336
+version = "2.3.0"1337
+source = "registry+https://github.com/rust-lang/crates.io-index"1338
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"1339
+dependencies = [1340
+ "displaydoc",1341
+ "potential_utf",1342
+ "utf8_iter",1343
+ "yoke",1344
+ "zerofrom",1345
+ "zerovec",1346
+]1347
+1348
+[[package]]1349
+name = "icu_locale_core"1350
+version = "2.3.0"1351
+source = "registry+https://github.com/rust-lang/crates.io-index"1352
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"1353
+dependencies = [1354
+ "displaydoc",1355
+ "litemap",1356
+ "tinystr",1357
+ "writeable",1358
+ "zerovec",1359
+]1360
+1361
+[[package]]1362
+name = "icu_normalizer"1363
+version = "2.3.0"1364
+source = "registry+https://github.com/rust-lang/crates.io-index"1365
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"1366
+dependencies = [1367
+ "icu_collections",1368
+ "icu_normalizer_data",1369
+ "icu_properties",1370
+ "icu_provider",1371
+ "smallvec",1372
+ "zerovec",1373
+]1374
+1375
+[[package]]1376
+name = "icu_normalizer_data"1377
+version = "2.3.0"1378
+source = "registry+https://github.com/rust-lang/crates.io-index"1379
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"1380
+1381
+[[package]]1382
+name = "icu_properties"1383
+version = "2.3.0"1384
+source = "registry+https://github.com/rust-lang/crates.io-index"1385
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"1386
+dependencies = [1387
+ "displaydoc",1388
+ "icu_collections",1389
+ "icu_locale_core",1390
+ "icu_properties_data",1391
+ "icu_provider",1392
+ "zerotrie",1393
+ "zerovec",1394
+]1395
+1396
+[[package]]1397
+name = "icu_properties_data"1398
+version = "2.3.0"1399
+source = "registry+https://github.com/rust-lang/crates.io-index"1400
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"1401
+1402
+[[package]]1403
+name = "icu_provider"1404
+version = "2.3.1"1405
+source = "registry+https://github.com/rust-lang/crates.io-index"1406
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"1407
+dependencies = [1408
+ "displaydoc",1409
+ "icu_locale_core",1410
+ "writeable",1411
+ "yoke",1412
+ "zerofrom",1413
+ "zerotrie",1414
+ "zerovec",1415
+]1416
+1417
+[[package]]1418
+name = "idna"1419
+version = "1.1.0"1420
+source = "registry+https://github.com/rust-lang/crates.io-index"1421
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"1422
+dependencies = [1423
+ "idna_adapter",1424
+ "smallvec",1425
+ "utf8_iter",1426
+]1427
+1428
+[[package]]1429
+name = "idna_adapter"1430
+version = "1.2.2"1431
+source = "registry+https://github.com/rust-lang/crates.io-index"1432
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"1433
+dependencies = [1434
+ "icu_normalizer",1435
+ "icu_properties",1436
+]1437
+1438
+[[package]]1439
+name = "imbl"1440
+version = "6.1.0"1441
+source = "registry+https://github.com/rust-lang/crates.io-index"1442
+checksum = "0fade8ae6828627ad1fa094a891eccfb25150b383047190a3648d66d06186501"1443
+dependencies = [1444
+ "archery",1445
+ "bitmaps",1446
+ "imbl-sized-chunks",1447
+ "rand_core 0.9.5",1448
+ "rand_xoshiro",1449
+ "serde",1450
+ "version_check",1451
+]1452
+1453
+[[package]]1454
+name = "imbl-sized-chunks"1455
+version = "0.1.3"1456
+source = "registry+https://github.com/rust-lang/crates.io-index"1457
+checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d"1458
+dependencies = [1459
+ "bitmaps",1460
+]1461
+1462
+[[package]]1463
+name = "impartial-ord"1464
+version = "2.0.0"1465
+source = "registry+https://github.com/rust-lang/crates.io-index"1466
+checksum = "bc680e03d1a08bdc1c01b60e0c39723430ced63d8cabd93cee0449b6ba916a25"1467
+dependencies = [1468
+ "proc-macro2",1469
+ "quote",1470
+ "syn 3.0.6",1471
+]1472
+1473
+[[package]]1474
+name = "include_dir"1475
+version = "0.7.4"1476
+source = "registry+https://github.com/rust-lang/crates.io-index"1477
+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"1478
+dependencies = [1479
+ "include_dir_macros",1480
+]1481
+1482
+[[package]]1483
+name = "include_dir_macros"1484
+version = "0.7.4"1485
+source = "registry+https://github.com/rust-lang/crates.io-index"1486
+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"1487
+dependencies = [1488
+ "proc-macro2",1489
+ "quote",1490
+]1491
+1492
+[[package]]1493
+name = "indexmap"1494
+version = "2.14.2"1495
+source = "registry+https://github.com/rust-lang/crates.io-index"1496
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"1497
+dependencies = [1498
+ "equivalent",1499
+ "hashbrown 0.17.1",1500
+ "serde",1501
+ "serde_core",1502
+]1503
+1504
+[[package]]1505
+name = "inout"1506
+version = "0.1.4"1507
+source = "registry+https://github.com/rust-lang/crates.io-index"1508
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"1509
+dependencies = [1510
+ "block-padding",1511
+ "generic-array",1512
+]1513
+1514
+[[package]]1515
+name = "ipnet"1516
+version = "2.12.2"1517
+source = "registry+https://github.com/rust-lang/crates.io-index"1518
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"1519
+1520
+[[package]]1521
+name = "itertools"1522
+version = "0.10.5"1523
+source = "registry+https://github.com/rust-lang/crates.io-index"1524
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"1525
+dependencies = [1526
+ "either",1527
+]1528
+1529
+[[package]]1530
+name = "itertools"1531
+version = "0.14.0"1532
+source = "registry+https://github.com/rust-lang/crates.io-index"1533
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"1534
+dependencies = [1535
+ "either",1536
+]1537
+1538
+[[package]]1539
+name = "itoa"1540
+version = "1.0.18"1541
+source = "registry+https://github.com/rust-lang/crates.io-index"1542
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"1543
+1544
+[[package]]1545
+name = "jni"1546
+version = "0.22.4"1547
+source = "registry+https://github.com/rust-lang/crates.io-index"1548
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"1549
+dependencies = [1550
+ "cfg-if",1551
+ "combine",1552
+ "jni-macros",1553
+ "jni-sys",1554
+ "log",1555
+ "simd_cesu8",1556
+ "thiserror 2.0.21",1557
+ "walkdir",1558
+ "windows-link",1559
+]1560
+1561
+[[package]]1562
+name = "jni-macros"1563
+version = "0.22.4"1564
+source = "registry+https://github.com/rust-lang/crates.io-index"1565
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"1566
+dependencies = [1567
+ "proc-macro2",1568
+ "quote",1569
+ "rustc_version",1570
+ "simd_cesu8",1571
+ "syn 2.0.119",1572
+]1573
+1574
+[[package]]1575
+name = "jni-sys"1576
+version = "0.4.1"1577
+source = "registry+https://github.com/rust-lang/crates.io-index"1578
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"1579
+dependencies = [1580
+ "jni-sys-macros",1581
+]1582
+1583
+[[package]]1584
+name = "jni-sys-macros"1585
+version = "0.4.1"1586
+source = "registry+https://github.com/rust-lang/crates.io-index"1587
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"1588
+dependencies = [1589
+ "quote",1590
+ "syn 2.0.119",1591
+]1592
+1593
+[[package]]1594
+name = "jobserver"1595
+version = "0.1.35"1596
+source = "registry+https://github.com/rust-lang/crates.io-index"1597
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"1598
+dependencies = [1599
+ "getrandom 0.4.3",1600
+ "libc",1601
+]1602
+1603
+[[package]]1604
+name = "js-sys"1605
+version = "0.3.105"1606
+source = "registry+https://github.com/rust-lang/crates.io-index"1607
+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"1608
+dependencies = [1609
+ "cfg-if",1610
+ "futures-util",1611
+ "wasm-bindgen",1612
+]1613
+1614
+[[package]]1615
+name = "js_int"1616
+version = "0.2.2"1617
+source = "registry+https://github.com/rust-lang/crates.io-index"1618
+checksum = "d937f95470b270ce8b8950207715d71aa8e153c0d44c6684d59397ed4949160a"1619
+dependencies = [1620
+ "serde",1621
+]1622
+1623
+[[package]]1624
+name = "js_option"1625
+version = "0.2.0"1626
+source = "registry+https://github.com/rust-lang/crates.io-index"1627
+checksum = "c7dd3e281add16813cf673bf74a32249b0aa0d1c8117519a17b3ada5e8552b3c"1628
+dependencies = [1629
+ "serde_core",1630
+]1631
+1632
+[[package]]1633
+name = "konst"1634
+version = "0.4.3"1635
+source = "registry+https://github.com/rust-lang/crates.io-index"1636
+checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d"1637
+dependencies = [1638
+ "const_panic",1639
+ "typewit",1640
+]1641
+1642
+[[package]]1643
+name = "language-tags"1644
+version = "0.3.2"1645
+source = "registry+https://github.com/rust-lang/crates.io-index"1646
+checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"1647
+1648
+[[package]]1649
+name = "lazy_static"1650
+version = "1.5.0"1651
+source = "registry+https://github.com/rust-lang/crates.io-index"1652
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"1653
+1654
+[[package]]1655
+name = "libc"1656
+version = "0.2.189"1657
+source = "registry+https://github.com/rust-lang/crates.io-index"1658
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"1659
+1660
+[[package]]1661
+name = "libm"1662
+version = "0.2.16"1663
+source = "registry+https://github.com/rust-lang/crates.io-index"1664
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"1665
+1666
+[[package]]1667
+name = "libsqlite3-sys"1668
+version = "0.35.0"1669
+source = "registry+https://github.com/rust-lang/crates.io-index"1670
+checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"1671
+dependencies = [1672
+ "pkg-config",1673
+ "vcpkg",1674
+]1675
+1676
+[[package]]1677
+name = "linux-raw-sys"1678
+version = "0.12.1"1679
+source = "registry+https://github.com/rust-lang/crates.io-index"1680
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"1681
+1682
+[[package]]1683
+name = "litemap"1684
+version = "0.8.3"1685
+source = "registry+https://github.com/rust-lang/crates.io-index"1686
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"1687
+1688
+[[package]]1689
+name = "lock_api"1690
+version = "0.4.14"1691
+source = "registry+https://github.com/rust-lang/crates.io-index"1692
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"1693
+dependencies = [1694
+ "scopeguard",1695
+]1696
+1697
+[[package]]1698
+name = "log"1699
+version = "0.4.34"1700
+source = "registry+https://github.com/rust-lang/crates.io-index"1701
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"1702
+1703
+[[package]]1704
+name = "loom"1705
+version = "0.7.2"1706
+source = "registry+https://github.com/rust-lang/crates.io-index"1707
+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"1708
+dependencies = [1709
+ "cfg-if",1710
+ "generator",1711
+ "scoped-tls",1712
+ "tracing",1713
+ "tracing-subscriber",1714
+]1715
+1716
+[[package]]1717
+name = "lru-slab"1718
+version = "0.1.3"1719
+source = "registry+https://github.com/rust-lang/crates.io-index"1720
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"1721
+1722
+[[package]]1723
+name = "macltok"1724
+version = "0.1.0"1725
+dependencies = [1726
+ "futures-util",1727
+ "getrandom 0.2.17",1728
+ "getrandom 0.3.4",1729
+ "getrandom 0.4.3",1730
+ "http",1731
+ "imbl",1732
+ "js-sys",1733
+ "matrix-sdk",1734
+ "matrix-sdk-crypto",1735
+ "matrix-sdk-store-encryption",1736
+ "matrix-sdk-ui",1737
+ "matrix_indexed_db_futures",1738
+ "mime",1739
+ "reqwest",1740
+ "ruma",1741
+ "serde",1742
+ "sha2",1743
+ "thiserror 2.0.21",1744
+ "tokio",1745
+ "tracing",1746
+ "url",1747
+ "uuid",1748
+ "wasm-bindgen",1749
+ "wasm-bindgen-futures",1750
+ "web-sys",1751
+]1752
+1753
+[[package]]1754
+name = "macroific"1755
+version = "2.0.0"1756
+source = "registry+https://github.com/rust-lang/crates.io-index"1757
+checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285"1758
+dependencies = [1759
+ "macroific_attr_parse 2.0.0",1760
+ "macroific_core 2.0.0",1761
+ "macroific_macro 2.0.0",1762
+]1763
+1764
+[[package]]1765
+name = "macroific"1766
+version = "3.1.0"1767
+source = "registry+https://github.com/rust-lang/crates.io-index"1768
+checksum = "cc8ab95c98294764c0f1eacebdb5b027849201b71fbc383871fe991a1de7eb83"1769
+dependencies = [1770
+ "macroific_attr_parse 3.1.0",1771
+ "macroific_core 3.1.0",1772
+ "macroific_macro 3.1.0",1773
+]1774
+1775
+[[package]]1776
+name = "macroific_attr_parse"1777
+version = "2.0.0"1778
+source = "registry+https://github.com/rust-lang/crates.io-index"1779
+checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9"1780
+dependencies = [1781
+ "proc-macro2",1782
+ "quote",1783
+ "sealed 0.6.0",1784
+ "syn 2.0.119",1785
+]1786
+1787
+[[package]]1788
+name = "macroific_attr_parse"1789
+version = "3.1.0"1790
+source = "registry+https://github.com/rust-lang/crates.io-index"1791
+checksum = "bf9b7bdb92825e6a507392b12f0570ade17480be20f4ca951b9071b40deba1d1"1792
+dependencies = [1793
+ "proc-macro2",1794
+ "quote",1795
+ "sealed 0.7.0",1796
+ "syn 3.0.6",1797
+]1798
+1799
+[[package]]1800
+name = "macroific_core"1801
+version = "2.0.0"1802
+source = "registry+https://github.com/rust-lang/crates.io-index"1803
+checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3"1804
+dependencies = [1805
+ "proc-macro2",1806
+ "quote",1807
+ "sealed 0.6.0",1808
+ "syn 2.0.119",1809
+]1810
+1811
+[[package]]1812
+name = "macroific_core"1813
+version = "3.1.0"1814
+source = "registry+https://github.com/rust-lang/crates.io-index"1815
+checksum = "edc880da2bc00d6d87f748805e242d670e248d1b9b6fcef3190503210b6cf056"1816
+dependencies = [1817
+ "proc-macro2",1818
+ "quote",1819
+ "sealed 0.7.0",1820
+ "syn 3.0.6",1821
+]1822
+1823
+[[package]]1824
+name = "macroific_macro"1825
+version = "2.0.0"1826
+source = "registry+https://github.com/rust-lang/crates.io-index"1827
+checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b"1828
+dependencies = [1829
+ "macroific_attr_parse 2.0.0",1830
+ "macroific_core 2.0.0",1831
+ "proc-macro2",1832
+ "quote",1833
+ "syn 2.0.119",1834
+]1835
+1836
+[[package]]1837
+name = "macroific_macro"1838
+version = "3.1.0"1839
+source = "registry+https://github.com/rust-lang/crates.io-index"1840
+checksum = "201faf668c264432727fb64e44407bcfcf1f5f8153bf4ec248eb6bbe4a39b144"1841
+dependencies = [1842
+ "macroific_attr_parse 3.1.0",1843
+ "macroific_core 3.1.0",1844
+ "proc-macro2",1845
+ "quote",1846
+ "syn 3.0.6",1847
+]1848
+1849
+[[package]]1850
+name = "malice-core"1851
+version = "0.1.0"1852
+dependencies = [1853
+ "futures-util",1854
+ "http",1855
+ "macltok",1856
+ "ruma",1857
+ "serde",1858
+ "serde_json",1859
+]1860
+1861
+[[package]]1862
+name = "malice-worker"1863
+version = "0.1.0"1864
+dependencies = [1865
+ "futures-channel",1866
+ "futures-util",1867
+ "gloo-timers",1868
+ "js-sys",1869
+ "macltok",1870
+ "malice-core",1871
+ "ruma",1872
+ "serde_json",1873
+ "wasm-bindgen",1874
+ "wasm-bindgen-futures",1875
+ "wasm-bindgen-test",1876
+ "web-sys",1877
+]1878
+1879
+[[package]]1880
+name = "maplit"1881
+version = "1.0.2"1882
+source = "registry+https://github.com/rust-lang/crates.io-index"1883
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"1884
+1885
+[[package]]1886
+name = "markup5ever"1887
+version = "0.39.0"1888
+source = "registry+https://github.com/rust-lang/crates.io-index"1889
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"1890
+dependencies = [1891
+ "log",1892
+ "tendril",1893
+ "web_atoms",1894
+]1895
+1896
+[[package]]1897
+name = "matchers"1898
+version = "0.2.0"1899
+source = "registry+https://github.com/rust-lang/crates.io-index"1900
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"1901
+dependencies = [1902
+ "regex-automata",1903
+]1904
+1905
+[[package]]1906
+name = "matrix-pickle"1907
+version = "0.2.3"1908
+source = "registry+https://github.com/rust-lang/crates.io-index"1909
+checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0"1910
+dependencies = [1911
+ "matrix-pickle-derive",1912
+ "thiserror 2.0.21",1913
+]1914
+1915
+[[package]]1916
+name = "matrix-pickle-derive"1917
+version = "0.2.3"1918
+source = "registry+https://github.com/rust-lang/crates.io-index"1919
+checksum = "414b5e4c34009f2bc3fe35dd018f25755ca38858096574841c7332f99e2c7e77"1920
+dependencies = [1921
+ "proc-macro-crate",1922
+ "proc-macro2",1923
+ "quote",1924
+ "syn 2.0.119",1925
+]1926
+1927
+[[package]]1928
+name = "matrix-sdk"1929
+version = "0.18.0"1930
+source = "registry+https://github.com/rust-lang/crates.io-index"1931
+checksum = "7083d580527511ac5d9369e03b9f2b20902e76949f1b3964051f978e4d3756ae"1932
+dependencies = [1933
+ "anymap2",1934
+ "aquamarine",1935
+ "as_variant",1936
+ "async-channel",1937
+ "async-once-cell",1938
+ "async-stream",1939
+ "async-trait",1940
+ "backon",1941
+ "bytes",1942
+ "bytesize",1943
+ "cfg-if",1944
+ "event-listener",1945
+ "eyeball",1946
+ "eyeball-im",1947
+ "futures-core",1948
+ "futures-util",1949
+ "gloo-timers",1950
+ "http",1951
+ "imbl",1952
+ "indexmap",1953
+ "itertools 0.14.0",1954
+ "js_int",1955
+ "language-tags",1956
+ "matrix-sdk-base",1957
+ "matrix-sdk-common",1958
+ "matrix-sdk-indexeddb",1959
+ "matrix-sdk-sqlite",1960
+ "mime",1961
+ "mime2ext",1962
+ "oauth2",1963
+ "oauth2-reqwest",1964
+ "percent-encoding",1965
+ "pin-project-lite",1966
+ "reqwest",1967
+ "ruma",1968
+ "rustls",1969
+ "rustls-native-certs",1970
+ "rustls-pki-types",1971
+ "serde",1972
+ "serde_html_form",1973
+ "serde_json",1974
+ "sha2",1975
+ "tempfile",1976
+ "thiserror 2.0.21",1977
+ "tokio",1978
+ "tokio-stream",1979
+ "tokio-util",1980
+ "tracing",1981
+ "url",1982
+ "urlencoding",1983
+ "vodozemac",1984
+ "webpki-roots",1985
+ "zeroize",1986
+]1987
+1988
+[[package]]1989
+name = "matrix-sdk-base"1990
+version = "0.18.0"1991
+source = "registry+https://github.com/rust-lang/crates.io-index"1992
+checksum = "e09a917eb1f7643d9d9a06b2f131e2ceb39df6910a7b830dac18bfd6db37a1f5"1993
+dependencies = [1994
+ "as_variant",1995
+ "async-trait",1996
+ "bitflags",1997
+ "decancer",1998
+ "eyeball",1999
+ "eyeball-im",2000
+ "futures-util",2001
+ "growable-bloom-filter",2002
+ "matrix-sdk-common",2003
+ "matrix-sdk-crypto",2004
+ "matrix-sdk-store-encryption",2005
+ "regex",2006
+ "ruma",2007
+ "serde",2008
+ "serde_json",2009
+ "thiserror 2.0.21",2010
+ "tokio",2011
+ "tracing",2012
+ "unicode-normalization",2013
+]2014
+2015
+[[package]]2016
+name = "matrix-sdk-common"2017
+version = "0.18.0"2018
+source = "registry+https://github.com/rust-lang/crates.io-index"2019
+checksum = "2b9d1e0fee0f090180ef9457034adc547e80de9e6e3eb2e63aaac68a8476f836"2020
+dependencies = [2021
+ "eyeball-im",2022
+ "futures-core",2023
+ "futures-executor",2024
+ "futures-util",2025
+ "gloo-timers",2026
+ "imbl",2027
+ "ruma",2028
+ "serde",2029
+ "serde_json",2030
+ "thiserror 2.0.21",2031
+ "tokio",2032
+ "tracing",2033
+ "tracing-subscriber",2034
+ "wasm-bindgen",2035
+ "wasm-bindgen-futures",2036
+ "web-sys",2037
+]2038
+2039
+[[package]]2040
+name = "matrix-sdk-crypto"2041
+version = "0.18.0"2042
+source = "registry+https://github.com/rust-lang/crates.io-index"2043
+checksum = "c54afd2a326f51c13a6ad44ec86315a688fffeb3f1e287fb343e0e1836a3bdaf"2044
+dependencies = [2045
+ "aes",2046
+ "aquamarine",2047
+ "as_variant",2048
+ "async-trait",2049
+ "bs58",2050
+ "byteorder",2051
+ "cfg-if",2052
+ "ctr",2053
+ "eyeball",2054
+ "futures-core",2055
+ "futures-util",2056
+ "hkdf",2057
+ "hmac",2058
+ "itertools 0.14.0",2059
+ "js_option",2060
+ "matrix-sdk-common",2061
+ "pbkdf2",2062
+ "rand 0.10.3",2063
+ "rmp-serde",2064
+ "ruma",2065
+ "serde",2066
+ "serde_json",2067
+ "sha2",2068
+ "subtle",2069
+ "thiserror 2.0.21",2070
+ "time",2071
+ "tokio",2072
+ "tokio-stream",2073
+ "tracing",2074
+ "ulid",2075
+ "url",2076
+ "vodozemac",2077
+ "zeroize",2078
+]2079
+2080
+[[package]]2081
+name = "matrix-sdk-indexeddb"2082
+version = "0.18.0"2083
+source = "registry+https://github.com/rust-lang/crates.io-index"2084
+checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47"2085
+dependencies = [2086
+ "async-trait",2087
+ "base64 0.22.1",2088
+ "futures-util",2089
+ "getrandom 0.4.3",2090
+ "gloo-utils",2091
+ "growable-bloom-filter",2092
+ "hkdf",2093
+ "js-sys",2094
+ "matrix-sdk-base",2095
+ "matrix-sdk-crypto",2096
+ "matrix-sdk-store-encryption",2097
+ "matrix_indexed_db_futures",2098
+ "rmp-serde",2099
+ "ruma",2100
+ "serde",2101
+ "serde-wasm-bindgen",2102
+ "serde_json",2103
+ "sha2",2104
+ "thiserror 2.0.21",2105
+ "tokio",2106
+ "tracing",2107
+ "uuid",2108
+ "wasm-bindgen",2109
+ "web-sys",2110
+ "zeroize",2111
+]2112
+2113
+[[package]]2114
+name = "matrix-sdk-sqlite"2115
+version = "0.18.0"2116
+source = "registry+https://github.com/rust-lang/crates.io-index"2117
+checksum = "a49133429271005745f8d5a05362d2ba6567274777250236799927ca30dc0670"2118
+dependencies = [2119
+ "as_variant",2120
+ "async-trait",2121
+ "deadpool",2122
+ "deadpool-sync",2123
+ "itertools 0.14.0",2124
+ "matrix-sdk-base",2125
+ "matrix-sdk-crypto",2126
+ "matrix-sdk-store-encryption",2127
+ "num_cpus",2128
+ "rmp-serde",2129
+ "ruma",2130
+ "rusqlite",2131
+ "serde",2132
+ "serde_json",2133
+ "serde_path_to_error",2134
+ "thiserror 2.0.21",2135
+ "tokio",2136
+ "tracing",2137
+ "vodozemac",2138
+ "zeroize",2139
+]2140
+2141
+[[package]]2142
+name = "matrix-sdk-store-encryption"2143
+version = "0.18.0"2144
+source = "registry+https://github.com/rust-lang/crates.io-index"2145
+checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e"2146
+dependencies = [2147
+ "base64 0.22.1",2148
+ "blake3",2149
+ "chacha20poly1305",2150
+ "getrandom 0.2.17",2151
+ "getrandom 0.4.3",2152
+ "hmac",2153
+ "pbkdf2",2154
+ "rand 0.10.3",2155
+ "rmp-serde",2156
+ "serde",2157
+ "serde_json",2158
+ "sha2",2159
+ "thiserror 2.0.21",2160
+ "zeroize",2161
+]2162
+2163
+[[package]]2164
+name = "matrix-sdk-ui"2165
+version = "0.18.0"2166
+source = "registry+https://github.com/rust-lang/crates.io-index"2167
+checksum = "6d1f2b8a8cf57164d7cee4eb61c0b9f074b2e11d766a9391b7db0b9f2cbd83d8"2168
+dependencies = [2169
+ "as_variant",2170
+ "async-rx",2171
+ "async-stream",2172
+ "async_cell",2173
+ "bitflags",2174
+ "chrono",2175
+ "emojis",2176
+ "eyeball",2177
+ "eyeball-im",2178
+ "eyeball-im-util",2179
+ "futures-core",2180
+ "futures-util",2181
+ "fuzzy-matcher",2182
+ "growable-bloom-filter",2183
+ "imbl",2184
+ "indexmap",2185
+ "itertools 0.14.0",2186
+ "matrix-sdk",2187
+ "matrix-sdk-base",2188
+ "matrix-sdk-common",2189
+ "mime",2190
+ "pin-project-lite",2191
+ "ruma",2192
+ "serde",2193
+ "serde_json",2194
+ "thiserror 2.0.21",2195
+ "tokio",2196
+ "tokio-stream",2197
+ "tracing",2198
+ "unicode-normalization",2199
+ "unicode-segmentation",2200
+]2201
+2202
+[[package]]2203
+name = "matrix_indexed_db_futures"2204
+version = "0.7.0"2205
+source = "registry+https://github.com/rust-lang/crates.io-index"2206
+checksum = "245ff6a224b4df7b0c90dda2dd5a6eb46112708d49e8bdd8b007fccb09fea8e4"2207
+dependencies = [2208
+ "accessory",2209
+ "cfg-if",2210
+ "delegate-display",2211
+ "derive_more 2.1.1",2212
+ "fancy_constructor",2213
+ "futures-core",2214
+ "js-sys",2215
+ "matrix_indexed_db_futures_macros_internal",2216
+ "sealed 0.6.0",2217
+ "serde",2218
+ "serde-wasm-bindgen",2219
+ "smallvec",2220
+ "thiserror 2.0.21",2221
+ "tokio",2222
+ "wasm-bindgen",2223
+ "wasm-bindgen-futures",2224
+ "wasm_evt_listener",2225
+ "web-sys",2226
+ "web-time",2227
+]2228
+2229
+[[package]]2230
+name = "matrix_indexed_db_futures_macros_internal"2231
+version = "1.0.0"2232
+source = "registry+https://github.com/rust-lang/crates.io-index"2233
+checksum = "b428aee5c0fe9e5babd29e99d289b7f64718c444989aac0442d1fd6d3e3f66d1"2234
+dependencies = [2235
+ "macroific 2.0.0",2236
+ "proc-macro2",2237
+ "quote",2238
+ "syn 2.0.119",2239
+]2240
+2241
+[[package]]2242
+name = "memchr"2243
+version = "2.8.3"2244
+source = "registry+https://github.com/rust-lang/crates.io-index"2245
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"2246
+2247
+[[package]]2248
+name = "mime"2249
+version = "0.3.17"2250
+source = "registry+https://github.com/rust-lang/crates.io-index"2251
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"2252
+2253
+[[package]]2254
+name = "mime2ext"2255
+version = "0.1.54"2256
+source = "registry+https://github.com/rust-lang/crates.io-index"2257
+checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc"2258
+2259
+[[package]]2260
+name = "minicov"2261
+version = "0.3.8"2262
+source = "registry+https://github.com/rust-lang/crates.io-index"2263
+checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"2264
+dependencies = [2265
+ "cc",2266
+ "walkdir",2267
+]2268
+2269
+[[package]]2270
+name = "miniz_oxide"2271
+version = "0.9.1"2272
+source = "registry+https://github.com/rust-lang/crates.io-index"2273
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"2274
+dependencies = [2275
+ "adler2",2276
+ "simd-adler32",2277
+]2278
+2279
+[[package]]2280
+name = "mio"2281
+version = "1.2.3"2282
+source = "registry+https://github.com/rust-lang/crates.io-index"2283
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"2284
+dependencies = [2285
+ "libc",2286
+ "wasi",2287
+ "windows-sys 0.61.2",2288
+]2289
+2290
+[[package]]2291
+name = "new_debug_unreachable"2292
+version = "1.0.6"2293
+source = "registry+https://github.com/rust-lang/crates.io-index"2294
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"2295
+2296
+[[package]]2297
+name = "nu-ansi-term"2298
+version = "0.50.3"2299
+source = "registry+https://github.com/rust-lang/crates.io-index"2300
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"2301
+dependencies = [2302
+ "windows-sys 0.61.2",2303
+]2304
+2305
+[[package]]2306
+name = "num-conv"2307
+version = "0.2.2"2308
+source = "registry+https://github.com/rust-lang/crates.io-index"2309
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"2310
+2311
+[[package]]2312
+name = "num-traits"2313
+version = "0.2.19"2314
+source = "registry+https://github.com/rust-lang/crates.io-index"2315
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"2316
+dependencies = [2317
+ "autocfg",2318
+ "libm",2319
+]2320
+2321
+[[package]]2322
+name = "num_cpus"2323
+version = "1.17.0"2324
+source = "registry+https://github.com/rust-lang/crates.io-index"2325
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"2326
+dependencies = [2327
+ "hermit-abi",2328
+ "libc",2329
+]2330
+2331
+[[package]]2332
+name = "oauth2"2333
+version = "5.0.0"2334
+source = "registry+https://github.com/rust-lang/crates.io-index"2335
+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"2336
+dependencies = [2337
+ "base64 0.22.1",2338
+ "chrono",2339
+ "getrandom 0.2.17",2340
+ "http",2341
+ "rand 0.8.8",2342
+ "serde",2343
+ "serde_json",2344
+ "serde_path_to_error",2345
+ "sha2",2346
+ "thiserror 1.0.69",2347
+ "url",2348
+]2349
+2350
+[[package]]2351
+name = "oauth2-reqwest"2352
+version = "0.1.0-alpha.3"2353
+source = "registry+https://github.com/rust-lang/crates.io-index"2354
+checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2"2355
+dependencies = [2356
+ "oauth2",2357
+ "reqwest",2358
+]2359
+2360
+[[package]]2361
+name = "once_cell"2362
+version = "1.21.4"2363
+source = "registry+https://github.com/rust-lang/crates.io-index"2364
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"2365
+2366
+[[package]]2367
+name = "oorandom"2368
+version = "11.1.5"2369
+source = "registry+https://github.com/rust-lang/crates.io-index"2370
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"2371
+2372
+[[package]]2373
+name = "opaque-debug"2374
+version = "0.3.1"2375
+source = "registry+https://github.com/rust-lang/crates.io-index"2376
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"2377
+2378
+[[package]]2379
+name = "openssl-probe"2380
+version = "0.2.1"2381
+source = "registry+https://github.com/rust-lang/crates.io-index"2382
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"2383
+2384
+[[package]]2385
+name = "parking"2386
+version = "2.2.1"2387
+source = "registry+https://github.com/rust-lang/crates.io-index"2388
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"2389
+2390
+[[package]]2391
+name = "parking_lot"2392
+version = "0.12.5"2393
+source = "registry+https://github.com/rust-lang/crates.io-index"2394
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"2395
+dependencies = [2396
+ "lock_api",2397
+ "parking_lot_core",2398
+]2399
+2400
+[[package]]2401
+name = "parking_lot_core"2402
+version = "0.9.12"2403
+source = "registry+https://github.com/rust-lang/crates.io-index"2404
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"2405
+dependencies = [2406
+ "cfg-if",2407
+ "libc",2408
+ "redox_syscall",2409
+ "smallvec",2410
+ "windows-link",2411
+]2412
+2413
+[[package]]2414
+name = "pbkdf2"2415
+version = "0.12.2"2416
+source = "registry+https://github.com/rust-lang/crates.io-index"2417
+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"2418
+dependencies = [2419
+ "digest",2420
+]2421
+2422
+[[package]]2423
+name = "percent-encoding"2424
+version = "2.3.2"2425
+source = "registry+https://github.com/rust-lang/crates.io-index"2426
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"2427
+2428
+[[package]]2429
+name = "phf"2430
+version = "0.13.1"2431
+source = "registry+https://github.com/rust-lang/crates.io-index"2432
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"2433
+dependencies = [2434
+ "phf_shared",2435
+ "serde",2436
+]2437
+2438
+[[package]]2439
+name = "phf_codegen"2440
+version = "0.13.1"2441
+source = "registry+https://github.com/rust-lang/crates.io-index"2442
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"2443
+dependencies = [2444
+ "phf_generator",2445
+ "phf_shared",2446
+]2447
+2448
+[[package]]2449
+name = "phf_generator"2450
+version = "0.13.1"2451
+source = "registry+https://github.com/rust-lang/crates.io-index"2452
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"2453
+dependencies = [2454
+ "fastrand",2455
+ "phf_shared",2456
+]2457
+2458
+[[package]]2459
+name = "phf_shared"2460
+version = "0.13.1"2461
+source = "registry+https://github.com/rust-lang/crates.io-index"2462
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"2463
+dependencies = [2464
+ "siphasher",2465
+]2466
+2467
+[[package]]2468
+name = "pin-project-lite"2469
+version = "0.2.17"2470
+source = "registry+https://github.com/rust-lang/crates.io-index"2471
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"2472
+2473
+[[package]]2474
+name = "pkcs8"2475
+version = "0.10.2"2476
+source = "registry+https://github.com/rust-lang/crates.io-index"2477
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"2478
+dependencies = [2479
+ "der",2480
+ "spki",2481
+]2482
+2483
+[[package]]2484
+name = "pkg-config"2485
+version = "0.3.34"2486
+source = "registry+https://github.com/rust-lang/crates.io-index"2487
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"2488
+2489
+[[package]]2490
+name = "poly1305"2491
+version = "0.8.0"2492
+source = "registry+https://github.com/rust-lang/crates.io-index"2493
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"2494
+dependencies = [2495
+ "cpufeatures 0.2.17",2496
+ "opaque-debug",2497
+ "universal-hash",2498
+]2499
+2500
+[[package]]2501
+name = "potential_utf"2502
+version = "0.1.6"2503
+source = "registry+https://github.com/rust-lang/crates.io-index"2504
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"2505
+dependencies = [2506
+ "zerovec",2507
+]2508
+2509
+[[package]]2510
+name = "powerfmt"2511
+version = "0.2.0"2512
+source = "registry+https://github.com/rust-lang/crates.io-index"2513
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"2514
+2515
+[[package]]2516
+name = "ppv-lite86"2517
+version = "0.2.21"2518
+source = "registry+https://github.com/rust-lang/crates.io-index"2519
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"2520
+dependencies = [2521
+ "zerocopy",2522
+]2523
+2524
+[[package]]2525
+name = "precomputed-hash"2526
+version = "0.1.1"2527
+source = "registry+https://github.com/rust-lang/crates.io-index"2528
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"2529
+2530
+[[package]]2531
+name = "proc-macro-crate"2532
+version = "3.5.0"2533
+source = "registry+https://github.com/rust-lang/crates.io-index"2534
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"2535
+dependencies = [2536
+ "toml_edit",2537
+]2538
+2539
+[[package]]2540
+name = "proc-macro-error-attr2"2541
+version = "2.0.0"2542
+source = "registry+https://github.com/rust-lang/crates.io-index"2543
+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"2544
+dependencies = [2545
+ "proc-macro2",2546
+ "quote",2547
+]2548
+2549
+[[package]]2550
+name = "proc-macro-error2"2551
+version = "2.0.1"2552
+source = "registry+https://github.com/rust-lang/crates.io-index"2553
+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"2554
+dependencies = [2555
+ "proc-macro-error-attr2",2556
+ "proc-macro2",2557
+ "quote",2558
+]2559
+2560
+[[package]]2561
+name = "proc-macro2"2562
+version = "1.0.107"2563
+source = "registry+https://github.com/rust-lang/crates.io-index"2564
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"2565
+dependencies = [2566
+ "unicode-ident",2567
+]2568
+2569
+[[package]]2570
+name = "prost"2571
+version = "0.14.4"2572
+source = "registry+https://github.com/rust-lang/crates.io-index"2573
+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"2574
+dependencies = [2575
+ "bytes",2576
+ "prost-derive",2577
+]2578
+2579
+[[package]]2580
+name = "prost-derive"2581
+version = "0.14.4"2582
+source = "registry+https://github.com/rust-lang/crates.io-index"2583
+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"2584
+dependencies = [2585
+ "anyhow",2586
+ "itertools 0.14.0",2587
+ "proc-macro2",2588
+ "quote",2589
+ "syn 2.0.119",2590
+]2591
+2592
+[[package]]2593
+name = "quinn"2594
+version = "0.11.12"2595
+source = "registry+https://github.com/rust-lang/crates.io-index"2596
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"2597
+dependencies = [2598
+ "bytes",2599
+ "cfg_aliases",2600
+ "pin-project-lite",2601
+ "quinn-proto",2602
+ "quinn-udp",2603
+ "rustc-hash",2604
+ "rustls",2605
+ "socket2",2606
+ "thiserror 2.0.21",2607
+ "tokio",2608
+ "tracing",2609
+ "web-time",2610
+]2611
+2612
+[[package]]2613
+name = "quinn-proto"2614
+version = "0.11.18"2615
+source = "registry+https://github.com/rust-lang/crates.io-index"2616
+checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"2617
+dependencies = [2618
+ "aws-lc-rs",2619
+ "bytes",2620
+ "getrandom 0.4.3",2621
+ "lru-slab",2622
+ "rand 0.10.3",2623
+ "rand_pcg",2624
+ "ring",2625
+ "rustc-hash",2626
+ "rustls",2627
+ "rustls-pki-types",2628
+ "slab",2629
+ "thiserror 2.0.21",2630
+ "tinyvec",2631
+ "tracing",2632
+ "web-time",2633
+]2634
+2635
+[[package]]2636
+name = "quinn-udp"2637
+version = "0.5.15"2638
+source = "registry+https://github.com/rust-lang/crates.io-index"2639
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"2640
+dependencies = [2641
+ "cfg_aliases",2642
+ "libc",2643
+ "once_cell",2644
+ "socket2",2645
+ "tracing",2646
+ "windows-sys 0.61.2",2647
+]2648
+2649
+[[package]]2650
+name = "quote"2651
+version = "1.0.47"2652
+source = "registry+https://github.com/rust-lang/crates.io-index"2653
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"2654
+dependencies = [2655
+ "proc-macro2",2656
+]2657
+2658
+[[package]]2659
+name = "r-efi"2660
+version = "5.3.0"2661
+source = "registry+https://github.com/rust-lang/crates.io-index"2662
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"2663
+2664
+[[package]]2665
+name = "r-efi"2666
+version = "6.0.0"2667
+source = "registry+https://github.com/rust-lang/crates.io-index"2668
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"2669
+2670
+[[package]]2671
+name = "rand"2672
+version = "0.8.8"2673
+source = "registry+https://github.com/rust-lang/crates.io-index"2674
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"2675
+dependencies = [2676
+ "libc",2677
+ "rand_chacha 0.3.1",2678
+ "rand_core 0.6.4",2679
+]2680
+2681
+[[package]]2682
+name = "rand"2683
+version = "0.9.5"2684
+source = "registry+https://github.com/rust-lang/crates.io-index"2685
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"2686
+dependencies = [2687
+ "rand_chacha 0.9.0",2688
+ "rand_core 0.9.5",2689
+]2690
+2691
+[[package]]2692
+name = "rand"2693
+version = "0.10.3"2694
+source = "registry+https://github.com/rust-lang/crates.io-index"2695
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"2696
+dependencies = [2697
+ "chacha20 0.10.2",2698
+ "getrandom 0.4.3",2699
+ "rand_core 0.10.1",2700
+]2701
+2702
+[[package]]2703
+name = "rand_chacha"2704
+version = "0.3.1"2705
+source = "registry+https://github.com/rust-lang/crates.io-index"2706
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"2707
+dependencies = [2708
+ "ppv-lite86",2709
+ "rand_core 0.6.4",2710
+]2711
+2712
+[[package]]2713
+name = "rand_chacha"2714
+version = "0.9.0"2715
+source = "registry+https://github.com/rust-lang/crates.io-index"2716
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"2717
+dependencies = [2718
+ "ppv-lite86",2719
+ "rand_core 0.9.5",2720
+]2721
+2722
+[[package]]2723
+name = "rand_core"2724
+version = "0.6.4"2725
+source = "registry+https://github.com/rust-lang/crates.io-index"2726
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"2727
+dependencies = [2728
+ "getrandom 0.2.17",2729
+]2730
+2731
+[[package]]2732
+name = "rand_core"2733
+version = "0.9.5"2734
+source = "registry+https://github.com/rust-lang/crates.io-index"2735
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"2736
+dependencies = [2737
+ "getrandom 0.3.4",2738
+]2739
+2740
+[[package]]2741
+name = "rand_core"2742
+version = "0.10.1"2743
+source = "registry+https://github.com/rust-lang/crates.io-index"2744
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"2745
+2746
+[[package]]2747
+name = "rand_pcg"2748
+version = "0.10.2"2749
+source = "registry+https://github.com/rust-lang/crates.io-index"2750
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"2751
+dependencies = [2752
+ "rand_core 0.10.1",2753
+]2754
+2755
+[[package]]2756
+name = "rand_xoshiro"2757
+version = "0.7.0"2758
+source = "registry+https://github.com/rust-lang/crates.io-index"2759
+checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"2760
+dependencies = [2761
+ "rand_core 0.9.5",2762
+]2763
+2764
+[[package]]2765
+name = "readlock"2766
+version = "0.1.11"2767
+source = "registry+https://github.com/rust-lang/crates.io-index"2768
+checksum = "6da6f291b23556edd9edaf655a0be2ad8ef8002ff5f1bca62b264f3f58b53f34"2769
+2770
+[[package]]2771
+name = "readlock-tokio"2772
+version = "0.1.6"2773
+source = "registry+https://github.com/rust-lang/crates.io-index"2774
+checksum = "fc7e264f9ec4f3d112e8e2f214e8e7cb5cf3b83278f3570b7e00bfe13d3bd8ff"2775
+dependencies = [2776
+ "tokio",2777
+]2778
+2779
+[[package]]2780
+name = "redox_syscall"2781
+version = "0.5.18"2782
+source = "registry+https://github.com/rust-lang/crates.io-index"2783
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"2784
+dependencies = [2785
+ "bitflags",2786
+]2787
+2788
+[[package]]2789
+name = "regex"2790
+version = "1.13.1"2791
+source = "registry+https://github.com/rust-lang/crates.io-index"2792
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"2793
+dependencies = [2794
+ "aho-corasick",2795
+ "memchr",2796
+ "regex-automata",2797
+ "regex-syntax",2798
+]2799
+2800
+[[package]]2801
+name = "regex-automata"2802
+version = "0.4.18"2803
+source = "registry+https://github.com/rust-lang/crates.io-index"2804
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"2805
+dependencies = [2806
+ "aho-corasick",2807
+ "memchr",2808
+ "regex-syntax",2809
+]2810
+2811
+[[package]]2812
+name = "regex-syntax"2813
+version = "0.8.11"2814
+source = "registry+https://github.com/rust-lang/crates.io-index"2815
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"2816
+2817
+[[package]]2818
+name = "reqwest"2819
+version = "0.13.5"2820
+source = "registry+https://github.com/rust-lang/crates.io-index"2821
+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"2822
+dependencies = [2823
+ "base64 0.23.1",2824
+ "bytes",2825
+ "futures-core",2826
+ "futures-util",2827
+ "h2",2828
+ "http",2829
+ "http-body",2830
+ "http-body-util",2831
+ "hyper",2832
+ "hyper-rustls",2833
+ "hyper-util",2834
+ "js-sys",2835
+ "log",2836
+ "percent-encoding",2837
+ "pin-project-lite",2838
+ "quinn",2839
+ "rustls",2840
+ "rustls-pki-types",2841
+ "rustls-platform-verifier",2842
+ "sync_wrapper",2843
+ "tokio",2844
+ "tokio-rustls",2845
+ "tokio-util",2846
+ "tower",2847
+ "tower-http",2848
+ "tower-service",2849
+ "url",2850
+ "wasm-bindgen",2851
+ "wasm-bindgen-futures",2852
+ "wasm-streams",2853
+ "web-sys",2854
+]2855
+2856
+[[package]]2857
+name = "ring"2858
+version = "0.17.14"2859
+source = "registry+https://github.com/rust-lang/crates.io-index"2860
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"2861
+dependencies = [2862
+ "cc",2863
+ "cfg-if",2864
+ "getrandom 0.2.17",2865
+ "libc",2866
+ "untrusted",2867
+ "windows-sys 0.52.0",2868
+]2869
+2870
+[[package]]2871
+name = "rmp"2872
+version = "0.8.15"2873
+source = "registry+https://github.com/rust-lang/crates.io-index"2874
+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"2875
+dependencies = [2876
+ "num-traits",2877
+]2878
+2879
+[[package]]2880
+name = "rmp-serde"2881
+version = "1.3.1"2882
+source = "registry+https://github.com/rust-lang/crates.io-index"2883
+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"2884
+dependencies = [2885
+ "rmp",2886
+ "serde",2887
+]2888
+2889
+[[package]]2890
+name = "ruma"2891
+version = "0.16.0"2892
+source = "registry+https://github.com/rust-lang/crates.io-index"2893
+checksum = "ee4fe5bfacdb0e95e733da3b6c37d98edf46447a4a8e8dea824e0da266d8ad59"2894
+dependencies = [2895
+ "assign",2896
+ "js_int",2897
+ "js_option",2898
+ "ruma-client-api",2899
+ "ruma-common",2900
+ "ruma-events",2901
+ "ruma-html",2902
+ "web-time",2903
+]2904
+2905
+[[package]]2906
+name = "ruma-client-api"2907
+version = "0.24.0"2908
+source = "registry+https://github.com/rust-lang/crates.io-index"2909
+checksum = "bf7ca43a888ca569168d7e3901f4dd14a777b860bb19f4c08e35414162eb261c"2910
+dependencies = [2911
+ "as_variant",2912
+ "assign",2913
+ "bytes",2914
+ "http",2915
+ "js_int",2916
+ "js_option",2917
+ "maplit",2918
+ "ruma-common",2919
+ "ruma-events",2920
+ "serde",2921
+ "serde_html_form",2922
+ "serde_json",2923
+ "thiserror 2.0.21",2924
+ "url",2925
+ "web-time",2926
+]2927
+2928
+[[package]]2929
+name = "ruma-common"2930
+version = "0.19.0"2931
+source = "registry+https://github.com/rust-lang/crates.io-index"2932
+checksum = "2c3b4f00112791b490acce57df1ce3eb3f88899b045bebcff8a29f75369640cc"2933
+dependencies = [2934
+ "as_variant",2935
+ "base64 0.22.1",2936
+ "bytes",2937
+ "date_header",2938
+ "form_urlencoded",2939
+ "getrandom 0.4.3",2940
+ "http",2941
+ "indexmap",2942
+ "js_int",2943
+ "konst",2944
+ "percent-encoding",2945
+ "rand 0.10.3",2946
+ "regex",2947
+ "ruma-identifiers-validation",2948
+ "ruma-macros",2949
+ "serde",2950
+ "serde_html_form",2951
+ "serde_json",2952
+ "thiserror 2.0.21",2953
+ "time",2954
+ "tracing",2955
+ "url",2956
+ "uuid",2957
+ "web-time",2958
+ "wildmatch",2959
+ "zeroize",2960
+]2961
+2962
+[[package]]2963
+name = "ruma-events"2964
+version = "0.34.0"2965
+source = "registry+https://github.com/rust-lang/crates.io-index"2966
+checksum = "85d2f90830fc131691349b96a69ff53444eb6c3e8dc7869c77961b43cfaf3344"2967
+dependencies = [2968
+ "as_variant",2969
+ "indexmap",2970
+ "js_int",2971
+ "js_option",2972
+ "ruma-common",2973
+ "ruma-html",2974
+ "ruma-macros",2975
+ "serde",2976
+ "serde_json",2977
+ "thiserror 2.0.21",2978
+ "tracing",2979
+ "web-time",2980
+ "wildmatch",2981
+ "zeroize",2982
+]2983
+2984
+[[package]]2985
+name = "ruma-html"2986
+version = "0.8.0"2987
+source = "registry+https://github.com/rust-lang/crates.io-index"2988
+checksum = "48d33a944650f4bbd2188dd204d39dd87a9a1498f14b3a13252910c90ed7cd43"2989
+dependencies = [2990
+ "as_variant",2991
+ "html5ever",2992
+ "tracing",2993
+ "wildmatch",2994
+]2995
+2996
+[[package]]2997
+name = "ruma-identifiers-validation"2998
+version = "0.12.1"2999
+source = "registry+https://github.com/rust-lang/crates.io-index"3000
+checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432"3001
+dependencies = [3002
+ "js_int",3003
+ "thiserror 2.0.21",3004
+]3005
+3006
+[[package]]3007
+name = "ruma-macros"3008
+version = "0.19.0"3009
+source = "registry+https://github.com/rust-lang/crates.io-index"3010
+checksum = "c8cfb39eaa9b9fd389126ff941e060b496add5cbbfef559d80c46e571dda459c"3011
+dependencies = [3012
+ "as_variant",3013
+ "cfg-if",3014
+ "proc-macro-crate",3015
+ "proc-macro2",3016
+ "quote",3017
+ "ruma-identifiers-validation",3018
+ "serde",3019
+ "syn 2.0.119",3020
+ "toml",3021
+]3022
+3023
+[[package]]3024
+name = "rusqlite"3025
+version = "0.37.0"3026
+source = "registry+https://github.com/rust-lang/crates.io-index"3027
+checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"3028
+dependencies = [3029
+ "bitflags",3030
+ "fallible-iterator",3031
+ "fallible-streaming-iterator",3032
+ "hashlink",3033
+ "libsqlite3-sys",3034
+ "smallvec",3035
+]3036
+3037
+[[package]]3038
+name = "rustc-hash"3039
+version = "2.1.3"3040
+source = "registry+https://github.com/rust-lang/crates.io-index"3041
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"3042
+3043
+[[package]]3044
+name = "rustc_version"3045
+version = "0.4.1"3046
+source = "registry+https://github.com/rust-lang/crates.io-index"3047
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"3048
+dependencies = [3049
+ "semver",3050
+]3051
+3052
+[[package]]3053
+name = "rustix"3054
+version = "1.1.5"3055
+source = "registry+https://github.com/rust-lang/crates.io-index"3056
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"3057
+dependencies = [3058
+ "bitflags",3059
+ "errno",3060
+ "libc",3061
+ "linux-raw-sys",3062
+ "windows-sys 0.61.2",3063
+]3064
+3065
+[[package]]3066
+name = "rustls"3067
+version = "0.23.45"3068
+source = "registry+https://github.com/rust-lang/crates.io-index"3069
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"3070
+dependencies = [3071
+ "aws-lc-rs",3072
+ "log",3073
+ "once_cell",3074
+ "rustls-pki-types",3075
+ "rustls-webpki",3076
+ "subtle",3077
+ "zeroize",3078
+]3079
+3080
+[[package]]3081
+name = "rustls-native-certs"3082
+version = "0.8.4"3083
+source = "registry+https://github.com/rust-lang/crates.io-index"3084
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"3085
+dependencies = [3086
+ "openssl-probe",3087
+ "rustls-pki-types",3088
+ "schannel",3089
+ "security-framework",3090
+]3091
+3092
+[[package]]3093
+name = "rustls-pki-types"3094
+version = "1.15.1"3095
+source = "registry+https://github.com/rust-lang/crates.io-index"3096
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"3097
+dependencies = [3098
+ "web-time",3099
+ "zeroize",3100
+]3101
+3102
+[[package]]3103
+name = "rustls-platform-verifier"3104
+version = "0.7.1"3105
+source = "registry+https://github.com/rust-lang/crates.io-index"3106
+checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"3107
+dependencies = [3108
+ "core-foundation",3109
+ "core-foundation-sys",3110
+ "jni",3111
+ "log",3112
+ "once_cell",3113
+ "rustls",3114
+ "rustls-native-certs",3115
+ "rustls-platform-verifier-android",3116
+ "rustls-webpki",3117
+ "security-framework",3118
+ "security-framework-sys",3119
+ "webpki-root-certs",3120
+ "windows-sys 0.61.2",3121
+]3122
+3123
+[[package]]3124
+name = "rustls-platform-verifier-android"3125
+version = "0.2.0"3126
+source = "registry+https://github.com/rust-lang/crates.io-index"3127
+checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"3128
+3129
+[[package]]3130
+name = "rustls-webpki"3131
+version = "0.103.15"3132
+source = "registry+https://github.com/rust-lang/crates.io-index"3133
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"3134
+dependencies = [3135
+ "aws-lc-rs",3136
+ "ring",3137
+ "rustls-pki-types",3138
+ "untrusted",3139
+]3140
+3141
+[[package]]3142
+name = "rustversion"3143
+version = "1.0.23"3144
+source = "registry+https://github.com/rust-lang/crates.io-index"3145
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"3146
+3147
+[[package]]3148
+name = "same-file"3149
+version = "1.0.6"3150
+source = "registry+https://github.com/rust-lang/crates.io-index"3151
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"3152
+dependencies = [3153
+ "winapi-util",3154
+]3155
+3156
+[[package]]3157
+name = "schannel"3158
+version = "0.1.29"3159
+source = "registry+https://github.com/rust-lang/crates.io-index"3160
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"3161
+dependencies = [3162
+ "windows-sys 0.61.2",3163
+]3164
+3165
+[[package]]3166
+name = "scoped-tls"3167
+version = "1.0.1"3168
+source = "registry+https://github.com/rust-lang/crates.io-index"3169
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"3170
+3171
+[[package]]3172
+name = "scopeguard"3173
+version = "1.2.0"3174
+source = "registry+https://github.com/rust-lang/crates.io-index"3175
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"3176
+3177
+[[package]]3178
+name = "sealed"3179
+version = "0.6.0"3180
+source = "registry+https://github.com/rust-lang/crates.io-index"3181
+checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"3182
+dependencies = [3183
+ "proc-macro2",3184
+ "quote",3185
+ "syn 2.0.119",3186
+]3187
+3188
+[[package]]3189
+name = "sealed"3190
+version = "0.7.0"3191
+source = "registry+https://github.com/rust-lang/crates.io-index"3192
+checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"3193
+dependencies = [3194
+ "proc-macro2",3195
+ "quote",3196
+ "syn 3.0.6",3197
+]3198
+3199
+[[package]]3200
+name = "security-framework"3201
+version = "3.7.0"3202
+source = "registry+https://github.com/rust-lang/crates.io-index"3203
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"3204
+dependencies = [3205
+ "bitflags",3206
+ "core-foundation",3207
+ "core-foundation-sys",3208
+ "libc",3209
+ "security-framework-sys",3210
+]3211
+3212
+[[package]]3213
+name = "security-framework-sys"3214
+version = "2.17.0"3215
+source = "registry+https://github.com/rust-lang/crates.io-index"3216
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"3217
+dependencies = [3218
+ "core-foundation-sys",3219
+ "libc",3220
+]3221
+3222
+[[package]]3223
+name = "semver"3224
+version = "1.0.28"3225
+source = "registry+https://github.com/rust-lang/crates.io-index"3226
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"3227
+3228
+[[package]]3229
+name = "serde"3230
+version = "1.0.229"3231
+source = "registry+https://github.com/rust-lang/crates.io-index"3232
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"3233
+dependencies = [3234
+ "serde_core",3235
+ "serde_derive",3236
+]3237
+3238
+[[package]]3239
+name = "serde-wasm-bindgen"3240
+version = "0.6.5"3241
+source = "registry+https://github.com/rust-lang/crates.io-index"3242
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"3243
+dependencies = [3244
+ "js-sys",3245
+ "serde",3246
+ "wasm-bindgen",3247
+]3248
+3249
+[[package]]3250
+name = "serde_bytes"3251
+version = "0.11.19"3252
+source = "registry+https://github.com/rust-lang/crates.io-index"3253
+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"3254
+dependencies = [3255
+ "serde",3256
+ "serde_core",3257
+]3258
+3259
+[[package]]3260
+name = "serde_core"3261
+version = "1.0.229"3262
+source = "registry+https://github.com/rust-lang/crates.io-index"3263
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"3264
+dependencies = [3265
+ "serde_derive",3266
+]3267
+3268
+[[package]]3269
+name = "serde_derive"3270
+version = "1.0.229"3271
+source = "registry+https://github.com/rust-lang/crates.io-index"3272
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"3273
+dependencies = [3274
+ "proc-macro2",3275
+ "quote",3276
+ "syn 3.0.6",3277
+]3278
+3279
+[[package]]3280
+name = "serde_html_form"3281
+version = "0.4.1"3282
+source = "registry+https://github.com/rust-lang/crates.io-index"3283
+checksum = "8f0346d7a342ab90f405cfc08f25d15075f944f42fcabbc5eac923829fa6d228"3284
+dependencies = [3285
+ "form_urlencoded",3286
+ "indexmap",3287
+ "itoa",3288
+ "serde_core",3289
+ "zmij",3290
+]3291
+3292
+[[package]]3293
+name = "serde_json"3294
+version = "1.0.151"3295
+source = "registry+https://github.com/rust-lang/crates.io-index"3296
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"3297
+dependencies = [3298
+ "itoa",3299
+ "memchr",3300
+ "serde",3301
+ "serde_core",3302
+ "zmij",3303
+]3304
+3305
+[[package]]3306
+name = "serde_path_to_error"3307
+version = "0.1.20"3308
+source = "registry+https://github.com/rust-lang/crates.io-index"3309
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"3310
+dependencies = [3311
+ "itoa",3312
+ "serde",3313
+ "serde_core",3314
+]3315
+3316
+[[package]]3317
+name = "serde_spanned"3318
+version = "1.1.1"3319
+source = "registry+https://github.com/rust-lang/crates.io-index"3320
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"3321
+dependencies = [3322
+ "serde_core",3323
+]3324
+3325
+[[package]]3326
+name = "sha2"3327
+version = "0.10.9"3328
+source = "registry+https://github.com/rust-lang/crates.io-index"3329
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"3330
+dependencies = [3331
+ "cfg-if",3332
+ "cpufeatures 0.2.17",3333
+ "digest",3334
+]3335
+3336
+[[package]]3337
+name = "sharded-slab"3338
+version = "0.1.7"3339
+source = "registry+https://github.com/rust-lang/crates.io-index"3340
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"3341
+dependencies = [3342
+ "lazy_static",3343
+]3344
+3345
+[[package]]3346
+name = "shlex"3347
+version = "2.0.1"3348
+source = "registry+https://github.com/rust-lang/crates.io-index"3349
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"3350
+3351
+[[package]]3352
+name = "signature"3353
+version = "2.2.0"3354
+source = "registry+https://github.com/rust-lang/crates.io-index"3355
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"3356
+dependencies = [3357
+ "rand_core 0.6.4",3358
+]3359
+3360
+[[package]]3361
+name = "simd-adler32"3362
+version = "0.3.10"3363
+source = "registry+https://github.com/rust-lang/crates.io-index"3364
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"3365
+3366
+[[package]]3367
+name = "simd_cesu8"3368
+version = "1.2.0"3369
+source = "registry+https://github.com/rust-lang/crates.io-index"3370
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"3371
+dependencies = [3372
+ "rustc_version",3373
+ "simdutf8",3374
+]3375
+3376
+[[package]]3377
+name = "simdutf8"3378
+version = "0.1.5"3379
+source = "registry+https://github.com/rust-lang/crates.io-index"3380
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"3381
+3382
+[[package]]3383
+name = "siphasher"3384
+version = "1.0.4"3385
+source = "registry+https://github.com/rust-lang/crates.io-index"3386
+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"3387
+3388
+[[package]]3389
+name = "slab"3390
+version = "0.4.12"3391
+source = "registry+https://github.com/rust-lang/crates.io-index"3392
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"3393
+3394
+[[package]]3395
+name = "smallvec"3396
+version = "1.16.2"3397
+source = "registry+https://github.com/rust-lang/crates.io-index"3398
+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"3399
+dependencies = [3400
+ "serde",3401
+]3402
+3403
+[[package]]3404
+name = "socket2"3405
+version = "0.6.5"3406
+source = "registry+https://github.com/rust-lang/crates.io-index"3407
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"3408
+dependencies = [3409
+ "libc",3410
+ "windows-sys 0.61.2",3411
+]3412
+3413
+[[package]]3414
+name = "spki"3415
+version = "0.7.3"3416
+source = "registry+https://github.com/rust-lang/crates.io-index"3417
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"3418
+dependencies = [3419
+ "base64ct",3420
+ "der",3421
+]3422
+3423
+[[package]]3424
+name = "stable_deref_trait"3425
+version = "1.2.1"3426
+source = "registry+https://github.com/rust-lang/crates.io-index"3427
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"3428
+3429
+[[package]]3430
+name = "string_cache"3431
+version = "0.9.0"3432
+source = "registry+https://github.com/rust-lang/crates.io-index"3433
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"3434
+dependencies = [3435
+ "new_debug_unreachable",3436
+ "parking_lot",3437
+ "phf_shared",3438
+ "precomputed-hash",3439
+]3440
+3441
+[[package]]3442
+name = "string_cache_codegen"3443
+version = "0.6.1"3444
+source = "registry+https://github.com/rust-lang/crates.io-index"3445
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"3446
+dependencies = [3447
+ "phf_generator",3448
+ "phf_shared",3449
+ "proc-macro2",3450
+ "quote",3451
+]3452
+3453
+[[package]]3454
+name = "subtle"3455
+version = "2.6.1"3456
+source = "registry+https://github.com/rust-lang/crates.io-index"3457
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"3458
+3459
+[[package]]3460
+name = "syn"3461
+version = "1.0.109"3462
+source = "registry+https://github.com/rust-lang/crates.io-index"3463
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"3464
+dependencies = [3465
+ "proc-macro2",3466
+ "quote",3467
+ "unicode-ident",3468
+]3469
+3470
+[[package]]3471
+name = "syn"3472
+version = "2.0.119"3473
+source = "registry+https://github.com/rust-lang/crates.io-index"3474
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"3475
+dependencies = [3476
+ "proc-macro2",3477
+ "quote",3478
+ "unicode-ident",3479
+]3480
+3481
+[[package]]3482
+name = "syn"3483
+version = "3.0.6"3484
+source = "registry+https://github.com/rust-lang/crates.io-index"3485
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"3486
+dependencies = [3487
+ "proc-macro2",3488
+ "quote",3489
+ "unicode-ident",3490
+]3491
+3492
+[[package]]3493
+name = "sync_wrapper"3494
+version = "1.0.2"3495
+source = "registry+https://github.com/rust-lang/crates.io-index"3496
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"3497
+dependencies = [3498
+ "futures-core",3499
+]3500
+3501
+[[package]]3502
+name = "synstructure"3503
+version = "0.14.0"3504
+source = "registry+https://github.com/rust-lang/crates.io-index"3505
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"3506
+dependencies = [3507
+ "proc-macro2",3508
+ "quote",3509
+ "syn 3.0.6",3510
+]3511
+3512
+[[package]]3513
+name = "tempfile"3514
+version = "3.27.0"3515
+source = "registry+https://github.com/rust-lang/crates.io-index"3516
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"3517
+dependencies = [3518
+ "fastrand",3519
+ "once_cell",3520
+ "rustix",3521
+ "windows-sys 0.61.2",3522
+]3523
+3524
+[[package]]3525
+name = "tendril"3526
+version = "0.5.1"3527
+source = "registry+https://github.com/rust-lang/crates.io-index"3528
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"3529
+dependencies = [3530
+ "new_debug_unreachable",3531
+]3532
+3533
+[[package]]3534
+name = "thiserror"3535
+version = "1.0.69"3536
+source = "registry+https://github.com/rust-lang/crates.io-index"3537
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"3538
+dependencies = [3539
+ "thiserror-impl 1.0.69",3540
+]3541
+3542
+[[package]]3543
+name = "thiserror"3544
+version = "2.0.21"3545
+source = "registry+https://github.com/rust-lang/crates.io-index"3546
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"3547
+dependencies = [3548
+ "thiserror-impl 2.0.21",3549
+]3550
+3551
+[[package]]3552
+name = "thiserror-impl"3553
+version = "1.0.69"3554
+source = "registry+https://github.com/rust-lang/crates.io-index"3555
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"3556
+dependencies = [3557
+ "proc-macro2",3558
+ "quote",3559
+ "syn 2.0.119",3560
+]3561
+3562
+[[package]]3563
+name = "thiserror-impl"3564
+version = "2.0.21"3565
+source = "registry+https://github.com/rust-lang/crates.io-index"3566
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"3567
+dependencies = [3568
+ "proc-macro2",3569
+ "quote",3570
+ "syn 3.0.6",3571
+]3572
+3573
+[[package]]3574
+name = "thread_local"3575
+version = "1.1.10"3576
+source = "registry+https://github.com/rust-lang/crates.io-index"3577
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"3578
+dependencies = [3579
+ "cfg-if",3580
+]3581
+3582
+[[package]]3583
+name = "time"3584
+version = "0.3.55"3585
+source = "registry+https://github.com/rust-lang/crates.io-index"3586
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"3587
+dependencies = [3588
+ "deranged",3589
+ "num-conv",3590
+ "powerfmt",3591
+ "serde_core",3592
+ "time-core",3593
+ "time-macros",3594
+]3595
+3596
+[[package]]3597
+name = "time-core"3598
+version = "0.1.9"3599
+source = "registry+https://github.com/rust-lang/crates.io-index"3600
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"3601
+3602
+[[package]]3603
+name = "time-macros"3604
+version = "0.2.32"3605
+source = "registry+https://github.com/rust-lang/crates.io-index"3606
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"3607
+dependencies = [3608
+ "num-conv",3609
+ "time-core",3610
+]3611
+3612
+[[package]]3613
+name = "tinystr"3614
+version = "0.8.4"3615
+source = "registry+https://github.com/rust-lang/crates.io-index"3616
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"3617
+dependencies = [3618
+ "displaydoc",3619
+ "zerovec",3620
+]3621
+3622
+[[package]]3623
+name = "tinyvec"3624
+version = "1.13.3"3625
+source = "registry+https://github.com/rust-lang/crates.io-index"3626
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"3627
+3628
+[[package]]3629
+name = "tokio"3630
+version = "1.53.1"3631
+source = "registry+https://github.com/rust-lang/crates.io-index"3632
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"3633
+dependencies = [3634
+ "bytes",3635
+ "libc",3636
+ "mio",3637
+ "pin-project-lite",3638
+ "socket2",3639
+ "tokio-macros",3640
+ "windows-sys 0.61.2",3641
+]3642
+3643
+[[package]]3644
+name = "tokio-macros"3645
+version = "2.7.2"3646
+source = "registry+https://github.com/rust-lang/crates.io-index"3647
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"3648
+dependencies = [3649
+ "proc-macro2",3650
+ "quote",3651
+ "syn 3.0.6",3652
+]3653
+3654
+[[package]]3655
+name = "tokio-rustls"3656
+version = "0.26.5"3657
+source = "registry+https://github.com/rust-lang/crates.io-index"3658
+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"3659
+dependencies = [3660
+ "rustls",3661
+ "tokio",3662
+]3663
+3664
+[[package]]3665
+name = "tokio-stream"3666
+version = "0.1.19"3667
+source = "registry+https://github.com/rust-lang/crates.io-index"3668
+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"3669
+dependencies = [3670
+ "futures-core",3671
+ "pin-project-lite",3672
+ "tokio",3673
+ "tokio-util",3674
+]3675
+3676
+[[package]]3677
+name = "tokio-util"3678
+version = "0.7.19"3679
+source = "registry+https://github.com/rust-lang/crates.io-index"3680
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"3681
+dependencies = [3682
+ "bytes",3683
+ "futures-core",3684
+ "futures-sink",3685
+ "libc",3686
+ "pin-project-lite",3687
+ "tokio",3688
+]3689
+3690
+[[package]]3691
+name = "toml"3692
+version = "1.1.6+spec-1.1.0"3693
+source = "registry+https://github.com/rust-lang/crates.io-index"3694
+checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"3695
+dependencies = [3696
+ "serde_core",3697
+ "serde_spanned",3698
+ "toml_datetime",3699
+ "toml_parser",3700
+ "winnow",3701
+]3702
+3703
+[[package]]3704
+name = "toml_datetime"3705
+version = "1.1.1+spec-1.1.0"3706
+source = "registry+https://github.com/rust-lang/crates.io-index"3707
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"3708
+dependencies = [3709
+ "serde_core",3710
+]3711
+3712
+[[package]]3713
+name = "toml_edit"3714
+version = "0.25.15+spec-1.1.0"3715
+source = "registry+https://github.com/rust-lang/crates.io-index"3716
+checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"3717
+dependencies = [3718
+ "indexmap",3719
+ "toml_datetime",3720
+ "toml_parser",3721
+ "winnow",3722
+]3723
+3724
+[[package]]3725
+name = "toml_parser"3726
+version = "1.1.3+spec-1.1.0"3727
+source = "registry+https://github.com/rust-lang/crates.io-index"3728
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"3729
+dependencies = [3730
+ "winnow",3731
+]3732
+3733
+[[package]]3734
+name = "tower"3735
+version = "0.5.3"3736
+source = "registry+https://github.com/rust-lang/crates.io-index"3737
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"3738
+dependencies = [3739
+ "futures-core",3740
+ "futures-util",3741
+ "pin-project-lite",3742
+ "sync_wrapper",3743
+ "tokio",3744
+ "tower-layer",3745
+ "tower-service",3746
+]3747
+3748
+[[package]]3749
+name = "tower-http"3750
+version = "0.6.11"3751
+source = "registry+https://github.com/rust-lang/crates.io-index"3752
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"3753
+dependencies = [3754
+ "async-compression",3755
+ "bitflags",3756
+ "bytes",3757
+ "futures-core",3758
+ "futures-util",3759
+ "http",3760
+ "http-body",3761
+ "http-body-util",3762
+ "pin-project-lite",3763
+ "tokio",3764
+ "tokio-util",3765
+ "tower",3766
+ "tower-layer",3767
+ "tower-service",3768
+ "url",3769
+]3770
+3771
+[[package]]3772
+name = "tower-layer"3773
+version = "0.3.3"3774
+source = "registry+https://github.com/rust-lang/crates.io-index"3775
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"3776
+3777
+[[package]]3778
+name = "tower-service"3779
+version = "0.3.3"3780
+source = "registry+https://github.com/rust-lang/crates.io-index"3781
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"3782
+3783
+[[package]]3784
+name = "tracing"3785
+version = "0.1.44"3786
+source = "registry+https://github.com/rust-lang/crates.io-index"3787
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"3788
+dependencies = [3789
+ "pin-project-lite",3790
+ "tracing-attributes",3791
+ "tracing-core",3792
+]3793
+3794
+[[package]]3795
+name = "tracing-attributes"3796
+version = "0.1.31"3797
+source = "registry+https://github.com/rust-lang/crates.io-index"3798
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"3799
+dependencies = [3800
+ "proc-macro2",3801
+ "quote",3802
+ "syn 2.0.119",3803
+]3804
+3805
+[[package]]3806
+name = "tracing-core"3807
+version = "0.1.36"3808
+source = "registry+https://github.com/rust-lang/crates.io-index"3809
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"3810
+dependencies = [3811
+ "once_cell",3812
+ "valuable",3813
+]3814
+3815
+[[package]]3816
+name = "tracing-log"3817
+version = "0.2.0"3818
+source = "registry+https://github.com/rust-lang/crates.io-index"3819
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"3820
+dependencies = [3821
+ "log",3822
+ "once_cell",3823
+ "tracing-core",3824
+]3825
+3826
+[[package]]3827
+name = "tracing-subscriber"3828
+version = "0.3.23"3829
+source = "registry+https://github.com/rust-lang/crates.io-index"3830
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"3831
+dependencies = [3832
+ "matchers",3833
+ "nu-ansi-term",3834
+ "once_cell",3835
+ "regex-automata",3836
+ "sharded-slab",3837
+ "smallvec",3838
+ "thread_local",3839
+ "tracing",3840
+ "tracing-core",3841
+ "tracing-log",3842
+]3843
+3844
+[[package]]3845
+name = "try-lock"3846
+version = "0.2.5"3847
+source = "registry+https://github.com/rust-lang/crates.io-index"3848
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"3849
+3850
+[[package]]3851
+name = "typenum"3852
+version = "1.20.1"3853
+source = "registry+https://github.com/rust-lang/crates.io-index"3854
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"3855
+3856
+[[package]]3857
+name = "typewit"3858
+version = "1.15.2"3859
+source = "registry+https://github.com/rust-lang/crates.io-index"3860
+checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6"3861
+3862
+[[package]]3863
+name = "ulid"3864
+version = "1.2.1"3865
+source = "registry+https://github.com/rust-lang/crates.io-index"3866
+checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"3867
+dependencies = [3868
+ "rand 0.9.5",3869
+ "web-time",3870
+]3871
+3872
+[[package]]3873
+name = "unicode-ident"3874
+version = "1.0.26"3875
+source = "registry+https://github.com/rust-lang/crates.io-index"3876
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"3877
+3878
+[[package]]3879
+name = "unicode-normalization"3880
+version = "0.1.25"3881
+source = "registry+https://github.com/rust-lang/crates.io-index"3882
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"3883
+dependencies = [3884
+ "tinyvec",3885
+]3886
+3887
+[[package]]3888
+name = "unicode-segmentation"3889
+version = "1.13.3"3890
+source = "registry+https://github.com/rust-lang/crates.io-index"3891
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"3892
+3893
+[[package]]3894
+name = "unicode-xid"3895
+version = "0.2.6"3896
+source = "registry+https://github.com/rust-lang/crates.io-index"3897
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"3898
+3899
+[[package]]3900
+name = "universal-hash"3901
+version = "0.5.1"3902
+source = "registry+https://github.com/rust-lang/crates.io-index"3903
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"3904
+dependencies = [3905
+ "crypto-common",3906
+ "subtle",3907
+]3908
+3909
+[[package]]3910
+name = "untrusted"3911
+version = "0.9.0"3912
+source = "registry+https://github.com/rust-lang/crates.io-index"3913
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"3914
+3915
+[[package]]3916
+name = "url"3917
+version = "2.5.8"3918
+source = "registry+https://github.com/rust-lang/crates.io-index"3919
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"3920
+dependencies = [3921
+ "form_urlencoded",3922
+ "idna",3923
+ "percent-encoding",3924
+ "serde",3925
+ "serde_derive",3926
+]3927
+3928
+[[package]]3929
+name = "urlencoding"3930
+version = "2.1.3"3931
+source = "registry+https://github.com/rust-lang/crates.io-index"3932
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"3933
+3934
+[[package]]3935
+name = "utf8_iter"3936
+version = "1.0.4"3937
+source = "registry+https://github.com/rust-lang/crates.io-index"3938
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"3939
+3940
+[[package]]3941
+name = "uuid"3942
+version = "1.26.1"3943
+source = "registry+https://github.com/rust-lang/crates.io-index"3944
+checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"3945
+dependencies = [3946
+ "getrandom 0.4.3",3947
+ "js-sys",3948
+ "serde_core",3949
+ "wasm-bindgen",3950
+]3951
+3952
+[[package]]3953
+name = "valuable"3954
+version = "0.1.1"3955
+source = "registry+https://github.com/rust-lang/crates.io-index"3956
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"3957
+3958
+[[package]]3959
+name = "vcpkg"3960
+version = "0.2.15"3961
+source = "registry+https://github.com/rust-lang/crates.io-index"3962
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"3963
+3964
+[[package]]3965
+name = "version_check"3966
+version = "0.9.5"3967
+source = "registry+https://github.com/rust-lang/crates.io-index"3968
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"3969
+3970
+[[package]]3971
+name = "vodozemac"3972
+version = "0.10.0"3973
+source = "registry+https://github.com/rust-lang/crates.io-index"3974
+checksum = "b98bf83c0992966775b8012f194b07b44928996163e5a05b741b43891571ae5b"3975
+dependencies = [3976
+ "aes",3977
+ "arrayvec",3978
+ "base64 0.22.1",3979
+ "base64ct",3980
+ "cbc",3981
+ "chacha20poly1305",3982
+ "curve25519-dalek",3983
+ "ed25519-dalek",3984
+ "getrandom 0.2.17",3985
+ "hkdf",3986
+ "hmac",3987
+ "matrix-pickle",3988
+ "prost",3989
+ "rand 0.8.8",3990
+ "serde",3991
+ "serde_bytes",3992
+ "serde_json",3993
+ "sha2",3994
+ "subtle",3995
+ "thiserror 2.0.21",3996
+ "x25519-dalek",3997
+ "zeroize",3998
+]3999
+4000
+[[package]]4001
+name = "walkdir"4002
+version = "2.5.0"4003
+source = "registry+https://github.com/rust-lang/crates.io-index"4004
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"4005
+dependencies = [4006
+ "same-file",4007
+ "winapi-util",4008
+]4009
+4010
+[[package]]4011
+name = "want"4012
+version = "0.3.1"4013
+source = "registry+https://github.com/rust-lang/crates.io-index"4014
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"4015
+dependencies = [4016
+ "try-lock",4017
+]4018
+4019
+[[package]]4020
+name = "wasi"4021
+version = "0.11.1+wasi-snapshot-preview1"4022
+source = "registry+https://github.com/rust-lang/crates.io-index"4023
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"4024
+4025
+[[package]]4026
+name = "wasip2"4027
+version = "1.0.4+wasi-0.2.12"4028
+source = "registry+https://github.com/rust-lang/crates.io-index"4029
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"4030
+dependencies = [4031
+ "wit-bindgen",4032
+]4033
+4034
+[[package]]4035
+name = "wasm-bindgen"4036
+version = "0.2.128"4037
+source = "registry+https://github.com/rust-lang/crates.io-index"4038
+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"4039
+dependencies = [4040
+ "cfg-if",4041
+ "once_cell",4042
+ "rustversion",4043
+ "wasm-bindgen-macro",4044
+ "wasm-bindgen-shared",4045
+]4046
+4047
+[[package]]4048
+name = "wasm-bindgen-futures"4049
+version = "0.4.78"4050
+source = "registry+https://github.com/rust-lang/crates.io-index"4051
+checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"4052
+dependencies = [4053
+ "js-sys",4054
+ "wasm-bindgen",4055
+]4056
+4057
+[[package]]4058
+name = "wasm-bindgen-macro"4059
+version = "0.2.128"4060
+source = "registry+https://github.com/rust-lang/crates.io-index"4061
+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"4062
+dependencies = [4063
+ "quote",4064
+ "wasm-bindgen-macro-support",4065
+]4066
+4067
+[[package]]4068
+name = "wasm-bindgen-macro-support"4069
+version = "0.2.128"4070
+source = "registry+https://github.com/rust-lang/crates.io-index"4071
+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"4072
+dependencies = [4073
+ "bumpalo",4074
+ "proc-macro2",4075
+ "quote",4076
+ "syn 3.0.6",4077
+ "wasm-bindgen-shared",4078
+]4079
+4080
+[[package]]4081
+name = "wasm-bindgen-shared"4082
+version = "0.2.128"4083
+source = "registry+https://github.com/rust-lang/crates.io-index"4084
+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"4085
+dependencies = [4086
+ "unicode-ident",4087
+]4088
+4089
+[[package]]4090
+name = "wasm-bindgen-test"4091
+version = "0.3.78"4092
+source = "registry+https://github.com/rust-lang/crates.io-index"4093
+checksum = "45863ef0bef521c12124eb39d9a38513c47db75f22e503c06beacd40afeb35db"4094
+dependencies = [4095
+ "async-trait",4096
+ "cast",4097
+ "js-sys",4098
+ "libm",4099
+ "minicov",4100
+ "nu-ansi-term",4101
+ "num-traits",4102
+ "oorandom",4103
+ "serde",4104
+ "serde_json",4105
+ "wasm-bindgen",4106
+ "wasm-bindgen-futures",4107
+ "wasm-bindgen-test-macro",4108
+ "wasm-bindgen-test-shared",4109
+]4110
+4111
+[[package]]4112
+name = "wasm-bindgen-test-macro"4113
+version = "0.3.78"4114
+source = "registry+https://github.com/rust-lang/crates.io-index"4115
+checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12"4116
+dependencies = [4117
+ "proc-macro2",4118
+ "quote",4119
+ "syn 3.0.6",4120
+]4121
+4122
+[[package]]4123
+name = "wasm-bindgen-test-shared"4124
+version = "0.2.128"4125
+source = "registry+https://github.com/rust-lang/crates.io-index"4126
+checksum = "f37b4f992cebe528ef34964ae69681ac0fe7080071e7298e46008f9d380302af"4127
+4128
+[[package]]4129
+name = "wasm-streams"4130
+version = "0.5.0"4131
+source = "registry+https://github.com/rust-lang/crates.io-index"4132
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"4133
+dependencies = [4134
+ "futures-util",4135
+ "js-sys",4136
+ "wasm-bindgen",4137
+ "wasm-bindgen-futures",4138
+ "web-sys",4139
+]4140
+4141
+[[package]]4142
+name = "wasm_evt_listener"4143
+version = "0.1.0"4144
+source = "registry+https://github.com/rust-lang/crates.io-index"4145
+checksum = "dc92d6378b411ed94839112a36d9dbc77143451d85b05dfb0cce93a78dab1963"4146
+dependencies = [4147
+ "accessory",4148
+ "derivative",4149
+ "derive_more 1.0.0",4150
+ "fancy_constructor",4151
+ "futures-core",4152
+ "js-sys",4153
+ "smallvec",4154
+ "tokio",4155
+ "wasm-bindgen",4156
+ "web-sys",4157
+]4158
+4159
+[[package]]4160
+name = "web-sys"4161
+version = "0.3.105"4162
+source = "registry+https://github.com/rust-lang/crates.io-index"4163
+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"4164
+dependencies = [4165
+ "js-sys",4166
+ "wasm-bindgen",4167
+]4168
+4169
+[[package]]4170
+name = "web-time"4171
+version = "1.1.0"4172
+source = "registry+https://github.com/rust-lang/crates.io-index"4173
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"4174
+dependencies = [4175
+ "js-sys",4176
+ "serde",4177
+ "wasm-bindgen",4178
+]4179
+4180
+[[package]]4181
+name = "web_atoms"4182
+version = "0.2.6"4183
+source = "registry+https://github.com/rust-lang/crates.io-index"4184
+checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"4185
+dependencies = [4186
+ "phf",4187
+ "phf_codegen",4188
+ "string_cache",4189
+ "string_cache_codegen",4190
+]4191
+4192
+[[package]]4193
+name = "webpki-root-certs"4194
+version = "1.0.9"4195
+source = "registry+https://github.com/rust-lang/crates.io-index"4196
+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"4197
+dependencies = [4198
+ "rustls-pki-types",4199
+]4200
+4201
+[[package]]4202
+name = "webpki-roots"4203
+version = "1.0.9"4204
+source = "registry+https://github.com/rust-lang/crates.io-index"4205
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"4206
+dependencies = [4207
+ "rustls-pki-types",4208
+]4209
+4210
+[[package]]4211
+name = "wildmatch"4212
+version = "2.6.1"4213
+source = "registry+https://github.com/rust-lang/crates.io-index"4214
+checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68"4215
+4216
+[[package]]4217
+name = "winapi-util"4218
+version = "0.1.11"4219
+source = "registry+https://github.com/rust-lang/crates.io-index"4220
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"4221
+dependencies = [4222
+ "windows-sys 0.61.2",4223
+]4224
+4225
+[[package]]4226
+name = "windows-core"4227
+version = "0.62.2"4228
+source = "registry+https://github.com/rust-lang/crates.io-index"4229
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"4230
+dependencies = [4231
+ "windows-implement",4232
+ "windows-interface",4233
+ "windows-link",4234
+ "windows-result",4235
+ "windows-strings",4236
+]4237
+4238
+[[package]]4239
+name = "windows-implement"4240
+version = "0.60.2"4241
+source = "registry+https://github.com/rust-lang/crates.io-index"4242
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"4243
+dependencies = [4244
+ "proc-macro2",4245
+ "quote",4246
+ "syn 2.0.119",4247
+]4248
+4249
+[[package]]4250
+name = "windows-interface"4251
+version = "0.59.3"4252
+source = "registry+https://github.com/rust-lang/crates.io-index"4253
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"4254
+dependencies = [4255
+ "proc-macro2",4256
+ "quote",4257
+ "syn 2.0.119",4258
+]4259
+4260
+[[package]]4261
+name = "windows-link"4262
+version = "0.2.1"4263
+source = "registry+https://github.com/rust-lang/crates.io-index"4264
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"4265
+4266
+[[package]]4267
+name = "windows-result"4268
+version = "0.4.1"4269
+source = "registry+https://github.com/rust-lang/crates.io-index"4270
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"4271
+dependencies = [4272
+ "windows-link",4273
+]4274
+4275
+[[package]]4276
+name = "windows-strings"4277
+version = "0.5.1"4278
+source = "registry+https://github.com/rust-lang/crates.io-index"4279
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"4280
+dependencies = [4281
+ "windows-link",4282
+]4283
+4284
+[[package]]4285
+name = "windows-sys"4286
+version = "0.52.0"4287
+source = "registry+https://github.com/rust-lang/crates.io-index"4288
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"4289
+dependencies = [4290
+ "windows-targets",4291
+]4292
+4293
+[[package]]4294
+name = "windows-sys"4295
+version = "0.61.2"4296
+source = "registry+https://github.com/rust-lang/crates.io-index"4297
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"4298
+dependencies = [4299
+ "windows-link",4300
+]4301
+4302
+[[package]]4303
+name = "windows-targets"4304
+version = "0.52.6"4305
+source = "registry+https://github.com/rust-lang/crates.io-index"4306
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"4307
+dependencies = [4308
+ "windows_aarch64_gnullvm",4309
+ "windows_aarch64_msvc",4310
+ "windows_i686_gnu",4311
+ "windows_i686_gnullvm",4312
+ "windows_i686_msvc",4313
+ "windows_x86_64_gnu",4314
+ "windows_x86_64_gnullvm",4315
+ "windows_x86_64_msvc",4316
+]4317
+4318
+[[package]]4319
+name = "windows_aarch64_gnullvm"4320
+version = "0.52.6"4321
+source = "registry+https://github.com/rust-lang/crates.io-index"4322
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"4323
+4324
+[[package]]4325
+name = "windows_aarch64_msvc"4326
+version = "0.52.6"4327
+source = "registry+https://github.com/rust-lang/crates.io-index"4328
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"4329
+4330
+[[package]]4331
+name = "windows_i686_gnu"4332
+version = "0.52.6"4333
+source = "registry+https://github.com/rust-lang/crates.io-index"4334
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"4335
+4336
+[[package]]4337
+name = "windows_i686_gnullvm"4338
+version = "0.52.6"4339
+source = "registry+https://github.com/rust-lang/crates.io-index"4340
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"4341
+4342
+[[package]]4343
+name = "windows_i686_msvc"4344
+version = "0.52.6"4345
+source = "registry+https://github.com/rust-lang/crates.io-index"4346
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"4347
+4348
+[[package]]4349
+name = "windows_x86_64_gnu"4350
+version = "0.52.6"4351
+source = "registry+https://github.com/rust-lang/crates.io-index"4352
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"4353
+4354
+[[package]]4355
+name = "windows_x86_64_gnullvm"4356
+version = "0.52.6"4357
+source = "registry+https://github.com/rust-lang/crates.io-index"4358
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"4359
+4360
+[[package]]4361
+name = "windows_x86_64_msvc"4362
+version = "0.52.6"4363
+source = "registry+https://github.com/rust-lang/crates.io-index"4364
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"4365
+4366
+[[package]]4367
+name = "winnow"4368
+version = "1.0.4"4369
+source = "registry+https://github.com/rust-lang/crates.io-index"4370
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"4371
+dependencies = [4372
+ "memchr",4373
+]4374
+4375
+[[package]]4376
+name = "wit-bindgen"4377
+version = "0.57.1"4378
+source = "registry+https://github.com/rust-lang/crates.io-index"4379
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"4380
+4381
+[[package]]4382
+name = "writeable"4383
+version = "0.6.4"4384
+source = "registry+https://github.com/rust-lang/crates.io-index"4385
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"4386
+4387
+[[package]]4388
+name = "x25519-dalek"4389
+version = "2.0.1"4390
+source = "registry+https://github.com/rust-lang/crates.io-index"4391
+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"4392
+dependencies = [4393
+ "curve25519-dalek",4394
+ "rand_core 0.6.4",4395
+ "serde",4396
+ "zeroize",4397
+]4398
+4399
+[[package]]4400
+name = "xxhash-rust"4401
+version = "0.8.18"4402
+source = "registry+https://github.com/rust-lang/crates.io-index"4403
+checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"4404
+4405
+[[package]]4406
+name = "yoke"4407
+version = "0.8.3"4408
+source = "registry+https://github.com/rust-lang/crates.io-index"4409
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"4410
+dependencies = [4411
+ "stable_deref_trait",4412
+ "yoke-derive",4413
+ "zerofrom",4414
+]4415
+4416
+[[package]]4417
+name = "yoke-derive"4418
+version = "0.8.3"4419
+source = "registry+https://github.com/rust-lang/crates.io-index"4420
+checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"4421
+dependencies = [4422
+ "proc-macro2",4423
+ "quote",4424
+ "syn 3.0.6",4425
+ "synstructure",4426
+]4427
+4428
+[[package]]4429
+name = "zerocopy"4430
+version = "0.8.59"4431
+source = "registry+https://github.com/rust-lang/crates.io-index"4432
+checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"4433
+dependencies = [4434
+ "zerocopy-derive",4435
+]4436
+4437
+[[package]]4438
+name = "zerocopy-derive"4439
+version = "0.8.59"4440
+source = "registry+https://github.com/rust-lang/crates.io-index"4441
+checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"4442
+dependencies = [4443
+ "proc-macro2",4444
+ "quote",4445
+ "syn 2.0.119",4446
+]4447
+4448
+[[package]]4449
+name = "zerofrom"4450
+version = "0.1.8"4451
+source = "registry+https://github.com/rust-lang/crates.io-index"4452
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"4453
+dependencies = [4454
+ "zerofrom-derive",4455
+]4456
+4457
+[[package]]4458
+name = "zerofrom-derive"4459
+version = "0.1.8"4460
+source = "registry+https://github.com/rust-lang/crates.io-index"4461
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"4462
+dependencies = [4463
+ "proc-macro2",4464
+ "quote",4465
+ "syn 3.0.6",4466
+ "synstructure",4467
+]4468
+4469
+[[package]]4470
+name = "zeroize"4471
+version = "1.9.0"4472
+source = "registry+https://github.com/rust-lang/crates.io-index"4473
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"4474
+dependencies = [4475
+ "zeroize_derive",4476
+]4477
+4478
+[[package]]4479
+name = "zeroize_derive"4480
+version = "1.5.0"4481
+source = "registry+https://github.com/rust-lang/crates.io-index"4482
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"4483
+dependencies = [4484
+ "proc-macro2",4485
+ "quote",4486
+ "syn 2.0.119",4487
+]4488
+4489
+[[package]]4490
+name = "zerotrie"4491
+version = "0.2.5"4492
+source = "registry+https://github.com/rust-lang/crates.io-index"4493
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"4494
+dependencies = [4495
+ "displaydoc",4496
+ "yoke",4497
+ "zerofrom",4498
+]4499
+4500
+[[package]]4501
+name = "zerovec"4502
+version = "0.11.8"4503
+source = "registry+https://github.com/rust-lang/crates.io-index"4504
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"4505
+dependencies = [4506
+ "yoke",4507
+ "zerofrom",4508
+ "zerovec-derive",4509
+]4510
+4511
+[[package]]4512
+name = "zerovec-derive"4513
+version = "0.11.6"4514
+source = "registry+https://github.com/rust-lang/crates.io-index"4515
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"4516
+dependencies = [4517
+ "proc-macro2",4518
+ "quote",4519
+ "syn 3.0.6",4520
+]4521
+4522
+[[package]]4523
+name = "zlib-rs"4524
+version = "0.6.8"4525
+source = "registry+https://github.com/rust-lang/crates.io-index"4526
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"4527
+4528
+[[package]]4529
+name = "zmij"4530
+version = "1.0.23"4531
+source = "registry+https://github.com/rust-lang/crates.io-index"4532
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"@@ -0,0 +1,47 @@
1
+[package]2
+name = "malice-worker"3
+version = "0.1.0"4
+edition = "2024"5
+publish = false6
+7
+# malice-worker's wasm profile needs are workspace-wide settings (debug info off8
+# for dev, panic=abort and LTO for release) that would otherwise leak into9
+# malice-server's native build if this crate stayed in the root workspace, so it10
+# gets its own, like macltok's harness/web.11
+[workspace]12
+13
+[lib]14
+crate-type = ["cdylib", "rlib"]15
+16
+[target.'cfg(target_family = "wasm")'.dependencies]17
+macltok = { path = "../../vendor/macltok", features = ["indexeddb", "serde", "mirror"] }18
+malice-core = { path = "../malice-core" }19
+ruma = { version = "0.16", default-features = false }20
+futures-util = { version = "0.3", default-features = false, features = ["std"] }21
+futures-channel = "0.3"22
+serde_json = "1"23
+# pinned exactly so flake.nix's wasmBindgenCli, built for this locked version, matches24
+# what wasm-bindgen-test-runner needs at test time25
+wasm-bindgen = "=0.2.128"26
+wasm-bindgen-futures = "=0.4.78"27
+js-sys = "=0.3.105"28
+29
+[dev-dependencies]30
+wasm-bindgen-test = "0.3"31
+web-sys = { version = "0.3", features = ["SharedWorkerGlobalScope"] }32
+gloo-timers = { version = "0.3", features = ["futures"] }33
+34
+[lints.rust]35
+unsafe_code = "forbid"36
+37
+# with debuginfo the test wasm balloons and can OOM the test runner, per macltok's38
+# own harness/web/Cargo.toml39
+[profile.dev]40
+debug = false41
+42
+[profile.release]43
+opt-level = "z"44
+lto = true45
+codegen-units = 146
+panic = "abort"47
+strip = true@@ -0,0 +1,92 @@
1
+# drives malice-worker's Host in a headless firefox shared worker against a throwaway2
+# continuwuity, `nix develop -c nu crates/malice-worker/run.nu`3
+4
+const crate = path self .5
+const root = path self ../..6
+const username = "malice-harness"7
+const password = "malice-harness-password"8
+const room_name = "malice-harness"9
+const seeded = "seeded by run.nu"10
+11
+# the shared build wrapper sets CARGO_TARGET_DIR, the nix sandbox does not12
+def target [] { $env.CARGO_TARGET_DIR? | default ($root | path join target) }13
+14
+def scratch [] { target | path join harness }15
+16
+def start-homeserver [] {17
+ let dir = scratch18
+ rm -rf $dir19
+ mkdir $dir20
+ let port = 626721
+ let config = $dir | path join homeserver.toml22
+ let log = $dir | path join homeserver.log23
+ {24
+ global: {25
+ server_name: "localhost"26
+ database_path: ($dir | path join db)27
+ address: ["127.0.0.1"]28
+ port: $port29
+ allow_federation: false30
+ new_user_displayname_suffix: ""31
+ }32
+ } | to toml | save $config33
+ # the first account otherwise needs a one-time token that is only ever printed to the log34
+ let create = $"users create-user ($username) ($password)"35
+ let pid = ^bash -c 'conduwuit -c "$1" --execute "$2" > "$3" 2>&1 & echo $!' _ $config $create $log | str trim36
+ let url = $'http://127.0.0.1:($port)'37
+ for _ in 1..100 {38
+ if (try { login $url | ignore; true } catch { false }) {39
+ return { pid: $pid, url: $url }40
+ }41
+ sleep 100ms42
+ }43
+ try { stop $pid }44
+ print (open --raw $log | lines | last 20 | str join "\n")45
+ error make { msg: "homeserver did not come up" }46
+}47
+48
+def login [url: string] {49
+ let body = { type: "m.login.password", identifier: { type: "m.id.user", user: $username }, password: $password }50
+ http post --content-type application/json $'($url)/_matrix/client/v3/login' $body51
+}52
+53
+def seed [url: string] {54
+ let auth = [Authorization $'Bearer ((login $url).access_token)']55
+ let room = http post --headers $auth --content-type application/json $'($url)/_matrix/client/v3/createRoom' { name: $room_name }56
+ let room_id = $room.room_id | url encode --all57
+ let message = { msgtype: "m.text", body: $seeded }58
+ http put --headers $auth --content-type application/json $'($url)/_matrix/client/v3/rooms/($room_id)/send/m.room.message/seed' $message | ignore59
+}60
+61
+def stop [pid: string] {62
+ ^kill -9 $pid63
+}64
+65
+def main [] {66
+ let server = start-homeserver67
+ let result = try {68
+ let versions = http get $'($server.url)/_matrix/client/versions'69
+ if not ($versions.unstable_features | get -o 'org.matrix.simplified_msc3575' | default false) {70
+ error make { msg: "homeserver does not advertise simplified sliding sync" }71
+ }72
+ seed $server.url73
+ cd $crate74
+ with-env {75
+ MALICE_HOMESERVER: $server.url76
+ MALICE_USERNAME: $username77
+ MALICE_PASSWORD: $password78
+ MALICE_ROOM_NAME: $room_name79
+ MALICE_SEEDED: $seeded80
+ GECKODRIVER: (which geckodriver | get 0.path)81
+ WASM_BINDGEN_TEST_TIMEOUT: "180"82
+ } {83
+ ^cargo test --target wasm32-unknown-unknown --test host84
+ }85
+ null86
+ } catch {|error| $error }87
+ stop $server.pid88
+ if $result != null {89
+ print $'homeserver log at (scratch)/homeserver.log'90
+ error make { msg: $result.msg }91
+ }92
+}@@ -0,0 +1,206 @@
1
+use std::rc::Rc;2
+use std::sync::Arc;3
+4
+use futures_util::StreamExt;5
+use macltok::{6
+ AccountError, ClientHandle, EngineConfig, Mirror, MirrorHandle, PasswordLogin, Retain, Session,7
+ SessionStatus, SessionStore, StateChange,8
+};9
+10
+use crate::HostInner;11
+12
+pub struct Account {13
+ pub handle: ClientHandle,14
+ pub mirror: MirrorHandle,15
+ pub sessions: Arc<dyn SessionStore>,16
+ /// dropping this cancels the account's forwarding task, releasing its17
+ /// `ClientHandle` clone; see the note on `Drop for Host`18
+ _cancel: futures_channel::oneshot::Sender<()>,19
+ engine_stopped: futures_channel::oneshot::Receiver<()>,20
+ mirror_stopped: futures_channel::oneshot::Receiver<()>,21
+}22
+23
+impl Account {24
+ /// The store lock frees only once the engine task has returned and every25
+ /// `ClientHandle` clone is gone, so this waits on both spawned tasks.26
+ pub async fn shutdown(self) {27
+ let _ = self.handle.shutdown().await;28
+ let Account {29
+ handle,30
+ _cancel,31
+ engine_stopped,32
+ mirror_stopped,33
+ ..34
+ } = self;35
+ drop((handle, _cancel));36
+ let _ = engine_stopped.await;37
+ let _ = mirror_stopped.await;38
+ }39
+}40
+41
+pub struct LoginFailure {42
+ pub kind: &'static str,43
+ pub message: String,44
+}45
+46
+impl LoginFailure {47
+ fn new(kind: &'static str, message: impl Into<String>) -> Self {48
+ Self {49
+ kind,50
+ message: message.into(),51
+ }52
+ }53
+}54
+55
+pub fn store_name(account: &str) -> String {56
+ format!("malice-{account}")57
+}58
+59
+pub fn username_matches(submitted: &str, user_id: &ruma::OwnedUserId) -> bool {60
+ let submitted = submitted.trim();61
+ submitted == user_id.as_str() || submitted == user_id.localpart()62
+}63
+64
+pub fn restore_error_kind(error: &AccountError) -> (&'static str, String) {65
+ match error {66
+ AccountError::StoreInUse => (67
+ "store_in_use",68
+ "another connection is using this session".to_owned(),69
+ ),70
+ error => (71
+ "stale",72
+ format!("restoring persisted session failed: {error}"),73
+ ),74
+ }75
+}76
+77
+pub async fn login_with(78
+ login: PasswordLogin,79
+ sessions: Arc<dyn SessionStore>,80
+) -> Result<Session, LoginFailure> {81
+ match Session::login_password(login, sessions).await {82
+ Ok(session) => Ok(session),83
+ Err(AccountError::StoreInUse) => Err(LoginFailure::new(84
+ "store_in_use",85
+ "another engine is using this session",86
+ )),87
+ Err(AccountError::Unsaved { session, .. }) => session88
+ .save()89
+ .await90
+ .map_err(|_| LoginFailure::new("upstream", "could not save the session")),91
+ Err(error) => Err(LoginFailure::new(92
+ "unauthorized",93
+ format!(94
+ "login failed: {}",95
+ malice_core::login::describe_error(&error)96
+ ),97
+ )),98
+ }99
+}100
+101
+/// Waits for the mirror to learn the user id, or for the session to leave102
+/// `Active` before that happens. Unlike the server's `wait_authenticated`,103
+/// there is no timeout: a wasm host has no request deadline to honour.104
+async fn wait_authenticated(mirror: &MirrorHandle) -> Option<String> {105
+ let mut updates = mirror.subscribe();106
+ loop {107
+ let update = updates.next().await?;108
+ match update.change {109
+ StateChange::State(state) => {110
+ if let Some(user_id) = &state.user_id {111
+ return Some(user_id.to_string());112
+ }113
+ if state.session != SessionStatus::Active {114
+ return None;115
+ }116
+ }117
+ StateChange::Authenticated(user_id) => return Some(user_id.to_string()),118
+ StateChange::Session(status) if status != SessionStatus::Active => return None,119
+ _ => {}120
+ }121
+ }122
+}123
+124
+impl HostInner {125
+ pub async fn start_account(126
+ self: &Rc<Self>,127
+ account: String,128
+ session: Session,129
+ sessions: Arc<dyn SessionStore>,130
+ ) -> Result<String, String> {131
+ let (engine, handle, events) = session132
+ .build(EngineConfig::default())133
+ .await134
+ .map_err(|error| format!("starting sync engine: {error}"))?;135
+ let (mirror, mirror_handle) = Mirror::new(handle.clone(), events);136
+137
+ let (cancel_tx, cancel_rx) = futures_channel::oneshot::channel::<()>();138
+ let (engine_stopped_tx, engine_stopped) = futures_channel::oneshot::channel::<()>();139
+ let (mirror_stopped_tx, mirror_stopped) = futures_channel::oneshot::channel::<()>();140
+ {141
+ let inner = self.clone();142
+ let account = account.clone();143
+ let mirror_handle = mirror_handle.clone();144
+ let forward = async move {145
+ let mut events = malice_core::feed::subscribe(&mirror_handle);146
+ while let Some(event) = events.next().await {147
+ if let Ok(json) = serde_json::to_string(&event) {148
+ inner.call_on_event(&account, &json);149
+ }150
+ }151
+ };152
+ let combined = Box::pin(futures_util::future::join(mirror.run(), forward));153
+ wasm_bindgen_futures::spawn_local(async move {154
+ let _stopped = mirror_stopped_tx;155
+ futures_util::future::select(combined, cancel_rx).await;156
+ });157
+ }158
+ {159
+ let inner = self.clone();160
+ let account = account.clone();161
+ wasm_bindgen_futures::spawn_local(async move {162
+ let _stopped = engine_stopped_tx;163
+ let retain = match engine.run().await {164
+ Ok(termination) => termination.retain(),165
+ Err(_error) => Retain::RecordAndStore,166
+ };167
+ inner.remove_account(&account, retain).await;168
+ });169
+ }170
+171
+ let Some(user_id) = wait_authenticated(&mirror_handle).await else {172
+ return Err("sync engine stopped before authenticating".to_owned());173
+ };174
+175
+ self.accounts.borrow_mut().insert(176
+ account,177
+ Account {178
+ handle,179
+ mirror: mirror_handle,180
+ sessions,181
+ _cancel: cancel_tx,182
+ engine_stopped,183
+ mirror_stopped,184
+ },185
+ );186
+ Ok(user_id)187
+ }188
+189
+ /// Removes the account and applies its retention. The map's `remove` is190
+ /// the arbitration point between a racing explicit logout and the191
+ /// engine's own termination, same as the server's `Option::take` on its192
+ /// `JoinHandle`: whichever gets here first does the work, the other193
+ /// finds nothing and returns.194
+ pub async fn remove_account(self: &Rc<Self>, account: &str, retain: Retain) {195
+ let Some(entry) = self.accounts.borrow_mut().remove(account) else {196
+ return;197
+ };198
+ let result = match retain {199
+ Retain::RecordAndStore => Ok(()),200
+ Retain::Store => entry.sessions.soft_log_out().await,201
+ Retain::Nothing => entry.sessions.discard().await,202
+ };203
+ drop(result);204
+ self.emit_session_ended(account, retain);205
+ }206
+}@@ -0,0 +1,260 @@
1
+#![cfg(target_family = "wasm")]2
+3
+mod account;4
+mod wire;5
+6
+use std::cell::RefCell;7
+use std::collections::HashMap;8
+use std::rc::Rc;9
+use std::sync::Arc;10
+11
+use macltok::{12
+ EncryptedSessionStore, MediaId, MediaLocation, MediaSize, PasswordLogin, Restore, Retain,13
+ Session, SessionStore, StoreConfig,14
+};15
+use malice_core::error::CoreError;16
+use malice_core::feed::WireEvent;17
+use malice_core::login::LoginInput;18
+use malice_core::media::THUMBNAIL_SIZE;19
+use malice_core::request::{Request, dispatch};20
+use wasm_bindgen::prelude::*;21
+22
+use account::{Account, login_with, restore_error_kind, store_name, username_matches};23
+use wire::{core_error_value, error_value, reply_value, to_js_value};24
+25
+pub(crate) struct HostInner {26
+ accounts: RefCell<HashMap<String, Account>>,27
+ on_event: js_sys::Function,28
+ on_fatal: js_sys::Function,29
+}30
+31
+impl HostInner {32
+ fn call_on_event(&self, account: &str, json: &str) {33
+ let _ = self.on_event.call2(34
+ &JsValue::NULL,35
+ &JsValue::from_str(account),36
+ &JsValue::from_str(json),37
+ );38
+ }39
+40
+ fn call_on_fatal(&self, message: &str) {41
+ let _ = self42
+ .on_fatal43
+ .call1(&JsValue::NULL, &JsValue::from_str(message));44
+ }45
+46
+ fn emit_session_ended(&self, account: &str, retain: Retain) {47
+ let event = WireEvent::SessionEnded {48
+ retain: retain.into(),49
+ };50
+ if let Ok(json) = serde_json::to_string(&event) {51
+ self.call_on_event(account, &json);52
+ }53
+ }54
+}55
+56
+#[wasm_bindgen]57
+pub struct Host(Rc<HostInner>);58
+59
+#[wasm_bindgen]60
+impl Host {61
+ #[wasm_bindgen(constructor)]62
+ pub fn new(on_event: js_sys::Function, on_fatal: js_sys::Function) -> Host {63
+ let inner = Rc::new(HostInner {64
+ accounts: RefCell::new(HashMap::new()),65
+ on_event,66
+ on_fatal,67
+ });68
+ let weak = Rc::downgrade(&inner);69
+ macltok::on_panic(move |message| {70
+ if let Some(inner) = weak.upgrade() {71
+ inner.call_on_fatal(message);72
+ }73
+ });74
+ Host(inner)75
+ }76
+77
+ pub async fn restore(&self, account: String, passphrase: String) -> Result<JsValue, JsValue> {78
+ let sessions: Arc<dyn SessionStore> = Arc::new(EncryptedSessionStore::indexeddb(79
+ StoreConfig::indexeddb(store_name(&account), passphrase),80
+ ));81
+ match Session::restore(sessions.clone()).await {82
+ Ok(None) => Ok(to_js_value(&serde_json::json!({ "status": "none" }))),83
+ Ok(Some(Restore::SoftLoggedOut(soft))) => Ok(to_js_value(&serde_json::json!({84
+ "status": "soft_logged_out",85
+ "user_id": soft.user_id.to_string(),86
+ "homeserver": soft.homeserver,87
+ }))),88
+ Ok(Some(Restore::Resumed(session))) => {89
+ match self.0.start_account(account, session, sessions).await {90
+ Ok(user_id) => Ok(to_js_value(&serde_json::json!({91
+ "status": "resumed",92
+ "user_id": user_id,93
+ }))),94
+ Err(message) => Err(error_value("unavailable", message)),95
+ }96
+ }97
+ Err(error) => {98
+ let (kind, message) = restore_error_kind(&error);99
+ Err(error_value(kind, message))100
+ }101
+ }102
+ }103
+104
+ pub async fn login(105
+ &self,106
+ account: String,107
+ passphrase: String,108
+ homeserver: String,109
+ username: String,110
+ password: String,111
+ ) -> Result<JsValue, JsValue> {112
+ let input = LoginInput {113
+ homeserver,114
+ username,115
+ password,116
+ };117
+ let input = match input.validate(true) {118
+ Ok(input) => input,119
+ Err(error) => return Err(core_error_value(error)),120
+ };121
+ let password = input.password.clone();122
+ let sessions: Arc<dyn SessionStore> = Arc::new(EncryptedSessionStore::indexeddb(123
+ StoreConfig::indexeddb(store_name(&account), passphrase),124
+ ));125
+ let login = match Session::restore(sessions.clone()).await {126
+ Ok(Some(Restore::SoftLoggedOut(soft)))127
+ if username_matches(&input.username, &soft.user_id) =>128
+ {129
+ soft.password_login(input.password)130
+ }131
+ _ => PasswordLogin::new(input.homeserver, input.username, input.password)132
+ .with_device_display_name("malice web"),133
+ };134
+ let session = match login_with(login, sessions.clone()).await {135
+ Ok(session) => session,136
+ Err(failure) => {137
+ let message = malice_core::login::sanitise_auth_error(&failure.message, &password);138
+ return Err(error_value(failure.kind, message));139
+ }140
+ };141
+ match self.0.start_account(account, session, sessions).await {142
+ Ok(user_id) => Ok(to_js_value(&serde_json::json!({ "user_id": user_id }))),143
+ Err(message) => Err(error_value("upstream", message)),144
+ }145
+ }146
+147
+ pub async fn logout(&self, account: String) -> Result<(), JsValue> {148
+ let handle = self149
+ .0150
+ .accounts151
+ .borrow()152
+ .get(&account)153
+ .map(|entry| entry.handle.clone());154
+ if let Some(handle) = handle {155
+ let _ = handle.logout().await;156
+ }157
+ self.0.remove_account(&account, Retain::Nothing).await;158
+ Ok(())159
+ }160
+161
+ pub async fn call(162
+ &self,163
+ account: String,164
+ request_json: String,165
+ bytes: Option<js_sys::Uint8Array>,166
+ ) -> Result<JsValue, JsValue> {167
+ let (handle, mirror) = {168
+ let accounts = self.0.accounts.borrow();169
+ let Some(entry) = accounts.get(&account) else {170
+ return Err(error_value("engine_stopped", "account is not running"));171
+ };172
+ (entry.handle.clone(), entry.mirror.clone())173
+ };174
+ let mut request: Request = serde_json::from_str(&request_json)175
+ .map_err(|error| error_value("bad_request", error.to_string()))?;176
+ if let Request::Upload { bytes: slot, .. } = &mut request {177
+ *slot = bytes.map(|array| array.to_vec()).unwrap_or_default();178
+ }179
+ match dispatch(&handle, &mirror, request).await {180
+ Ok(reply) => Ok(reply_value(reply)),181
+ Err(error) => Err(core_error_value(error)),182
+ }183
+ }184
+185
+ /// Where full-size media lives and the bearer token to fetch it, for the186
+ /// service worker to stream directly. Host-only: unlike `call`, this never187
+ /// goes through `malice_core::request::Request`, so the hosted server has188
+ /// no way to ask for a token over HTTP.189
+ pub async fn locate(190
+ &self,191
+ account: String,192
+ media_id: String,193
+ size: String,194
+ ) -> Result<JsValue, JsValue> {195
+ let handle = {196
+ let accounts = self.0.accounts.borrow();197
+ let Some(entry) = accounts.get(&account) else {198
+ return Err(error_value("engine_stopped", "account is not running"));199
+ };200
+ entry.handle.clone()201
+ };202
+ if media_id.is_empty() || media_id.len() > 512 {203
+ return Err(error_value("bad_request", "invalid media_id"));204
+ }205
+ let media_id = MediaId::from(media_id);206
+ let size = match size.as_str() {207
+ "Thumbnail" => MediaSize::Thumbnail {208
+ width: THUMBNAIL_SIZE,209
+ height: THUMBNAIL_SIZE,210
+ },211
+ "Full" => MediaSize::Full,212
+ _ => return Err(error_value("bad_request", "invalid size")),213
+ };214
+ let location = handle215
+ .media_source(&media_id, size)216
+ .await217
+ .map_err(|error| core_error_value(CoreError::from(error)))?;218
+ let token = match &location {219
+ MediaLocation::Local => None,220
+ _ => handle.access_token(),221
+ };222
+ let location = serde_json::to_value(&location)223
+ .map_err(|error| error_value("upstream", error.to_string()))?;224
+ Ok(to_js_value(&serde_json::json!({225
+ "location": location,226
+ "token": token,227
+ })))228
+ }229
+230
+ /// Stops every engine and resolves once their store locks are free.231
+ pub async fn shutdown(&self) {232
+ let accounts: Vec<Account> = self233
+ .0234
+ .accounts235
+ .borrow_mut()236
+ .drain()237
+ .map(|(_, account)| account)238
+ .collect();239
+ futures_util::future::join_all(accounts.into_iter().map(Account::shutdown)).await;240
+ }241
+242
+ pub fn snapshot(&self, account: String) -> Option<String> {243
+ let accounts = self.0.accounts.borrow();244
+ let entry = accounts.get(&account)?;245
+ let event = malice_core::feed::snapshot_event(&entry.mirror.snapshot());246
+ serde_json::to_string(&event).ok()247
+ }248
+}249
+250
+impl Drop for Host {251
+ /// Dropping the handles alone left the engine running in the browser252
+ /// harness, so each engine gets an explicit shutdown. Nothing can await it253
+ /// here, so a caller that must reuse the store straight away calls254
+ /// `shutdown` first.255
+ fn drop(&mut self) {256
+ for (_, account) in self.0.accounts.borrow_mut().drain() {257
+ wasm_bindgen_futures::spawn_local(account.shutdown());258
+ }259
+ }260
+}@@ -0,0 +1,56 @@
1
+use wasm_bindgen::JsValue;2
+3
+use malice_core::error::{CoreError, Failure};4
+use malice_core::request::Reply;5
+6
+pub fn to_js_value(value: &serde_json::Value) -> JsValue {7
+ js_sys::JSON::parse(&value.to_string()).unwrap_or(JsValue::NULL)8
+}9
+10
+pub fn error_value(kind: &str, message: impl AsRef<str>) -> JsValue {11
+ to_js_value(&serde_json::json!({ "kind": kind, "message": message.as_ref() }))12
+}13
+14
+pub fn failure_kind(failure: Failure) -> &'static str {15
+ match failure {16
+ Failure::BadRequest => "bad_request",17
+ Failure::NotFound => "not_found",18
+ Failure::Conflict => "conflict",19
+ Failure::Busy => "busy",20
+ Failure::TooLarge => "too_large",21
+ Failure::Unsupported => "unsupported",22
+ Failure::EngineStopped => "engine_stopped",23
+ Failure::Upstream => "upstream",24
+ }25
+}26
+27
+pub fn core_error_value(error: CoreError) -> JsValue {28
+ error_value(failure_kind(error.kind), error.message)29
+}30
+31
+pub fn reply_value(reply: Reply) -> JsValue {32
+ match reply {33
+ Reply::Done => js_sys::Object::new().into(),34
+ Reply::Json(value) => to_js_value(&serde_json::json!({ "json": value })),35
+ Reply::Avatar {36
+ bytes,37
+ content_type,38
+ } => bytes_value(bytes, Some(content_type)),39
+ Reply::Attachment {40
+ bytes,41
+ content_type,42
+ } => bytes_value(bytes, content_type),43
+ }44
+}45
+46
+fn bytes_value(bytes: Vec<u8>, content_type: Option<&str>) -> JsValue {47
+ let object = js_sys::Object::new();48
+ let array = js_sys::Uint8Array::from(bytes.as_slice());49
+ let content_type = match content_type {50
+ Some(content_type) => JsValue::from_str(content_type),51
+ None => JsValue::NULL,52
+ };53
+ js_sys::Reflect::set(&object, &JsValue::from_str("bytes"), &array).ok();54
+ js_sys::Reflect::set(&object, &JsValue::from_str("content_type"), &content_type).ok();55
+ object.into()56
+}@@ -0,0 +1,224 @@
1
+//! needs a live homeserver; run.nu starts and seeds one and bakes its details in at build time2
+3
+use futures_channel::mpsc;4
+use futures_util::StreamExt;5
+use futures_util::future::{Either, select};6
+use gloo_timers::future::TimeoutFuture;7
+use malice_worker::Host;8
+use serde_json::Value;9
+use wasm_bindgen::JsCast;10
+use wasm_bindgen::closure::Closure;11
+use wasm_bindgen_test::*;12
+13
+wasm_bindgen_test_configure!(run_in_shared_worker);14
+15
+const PASSPHRASE: &str = "host-test-passphrase";16
+const PHASE_TIMEOUT_MS: f64 = 90_000.0;17
+18
+#[wasm_bindgen_test]19
+async fn logs_in_syncs_and_logs_out() {20
+ assert!(js_sys::global().is_instance_of::<web_sys::SharedWorkerGlobalScope>());21
+22
+ let account = "host-contract".to_string();23
+ let (on_event, mut events) = event_channel();24
+ let (on_fatal, _fatal) = message_channel();25
+ let host = Host::new(on_event, on_fatal);26
+27
+ let login = host28
+ .login(29
+ account.clone(),30
+ PASSPHRASE.into(),31
+ env!("MALICE_HOMESERVER").into(),32
+ env!("MALICE_USERNAME").into(),33
+ env!("MALICE_PASSWORD").into(),34
+ )35
+ .await36
+ .unwrap_or_else(|error| panic!("login failed: {:?}", as_json(error)));37
+ assert!(as_json(login).get("user_id").is_some());38
+39
+ let room_id = find_room(&host, &account, env!("MALICE_ROOM_NAME"), deadline()).await;40
+41
+ let opened = host42
+ .call(account.clone(), open_request(&room_id), None)43
+ .await44
+ .unwrap_or_else(|error| panic!("open failed: {:?}", as_json(error)));45
+ assert_eq!(as_json(opened), Value::Object(Default::default()));46
+47
+ let seeded = env!("MALICE_SEEDED");48
+ wait_for_text(&mut events, &account, seeded, deadline()).await;49
+50
+ let sent = format!("{seeded}, answered by host-contract");51
+ let sent_reply = host52
+ .call(account.clone(), send_request(&room_id, &sent), None)53
+ .await54
+ .unwrap_or_else(|error| panic!("send failed: {:?}", as_json(error)));55
+ assert_eq!(as_json(sent_reply), Value::Object(Default::default()));56
+ wait_for_text(&mut events, &account, &sent, deadline()).await;57
+58
+ let avatar_error = host59
+ .call(60
+ account.clone(),61
+ r#"{"op":"avatar","mxc":"not-a-mxc"}"#.into(),62
+ None,63
+ )64
+ .await65
+ .expect_err("an invalid mxc URI must be rejected");66
+ assert_eq!(67
+ as_json(avatar_error).get("kind").and_then(Value::as_str),68
+ Some("bad_request")69
+ );70
+71
+ host.logout(account.clone())72
+ .await73
+ .expect("logout should discard the store cleanly");74
+ let restored = host75
+ .restore(account, PASSPHRASE.into())76
+ .await77
+ .expect("restoring a discarded store should not error");78
+ assert_eq!(79
+ as_json(restored).get("status").and_then(Value::as_str),80
+ Some("none")81
+ );82
+}83
+84
+/// the Web Lock is released by a promise settling, so a fresh host may still see it held for85
+/// a tick after `shutdown` resolves86
+#[wasm_bindgen_test]87
+async fn restore_after_shutdown_frees_the_store() {88
+ let account = "host-drop".to_string();89
+ let (on_event, _events) = event_channel();90
+ let (on_fatal, _fatal) = message_channel();91
+ let host = Host::new(on_event, on_fatal);92
+ host.login(93
+ account.clone(),94
+ PASSPHRASE.into(),95
+ env!("MALICE_HOMESERVER").into(),96
+ env!("MALICE_USERNAME").into(),97
+ env!("MALICE_PASSWORD").into(),98
+ )99
+ .await100
+ .unwrap_or_else(|error| panic!("login failed: {:?}", as_json(error)));101
+ let shutdown = Box::pin(host.shutdown());102
+ if let Either::Right(_) = select(shutdown, TimeoutFuture::new(PHASE_TIMEOUT_MS as u32)).await {103
+ panic!("shutdown never finished");104
+ }105
+ drop(host);106
+107
+ let (on_event, _events) = event_channel();108
+ let (on_fatal, _fatal) = message_channel();109
+ let host = Host::new(on_event, on_fatal);110
+ let end = deadline();111
+ let restored = loop {112
+ match host.restore(account.clone(), PASSPHRASE.into()).await {113
+ Err(error)114
+ if as_json(error.clone()).get("kind").and_then(Value::as_str)115
+ == Some("store_in_use") =>116
+ {117
+ assert!(118
+ js_sys::Date::now() < end,119
+ "the shut down host's store lock never freed"120
+ );121
+ TimeoutFuture::new(100).await;122
+ }123
+ outcome => {124
+ break outcome.expect("restore once the shut down host's engine has stopped");125
+ }126
+ }127
+ };128
+ assert_eq!(129
+ as_json(restored).get("status").and_then(Value::as_str),130
+ Some("resumed")131
+ );132
+133
+ host.logout(account)134
+ .await135
+ .expect("logout should discard the store cleanly");136
+}137
+138
+fn open_request(room_id: &str) -> String {139
+ serde_json::json!({ "op": "open", "room_id": room_id }).to_string()140
+}141
+142
+fn send_request(room_id: &str, body: &str) -> String {143
+ serde_json::json!({ "op": "send", "room_id": room_id, "body": body }).to_string()144
+}145
+146
+fn deadline() -> f64 {147
+ js_sys::Date::now() + PHASE_TIMEOUT_MS148
+}149
+150
+fn as_json(value: wasm_bindgen::JsValue) -> Value {151
+ let text = js_sys::JSON::stringify(&value)152
+ .ok()153
+ .and_then(|text| text.as_string())154
+ .unwrap_or_default();155
+ serde_json::from_str(&text).unwrap_or(Value::Null)156
+}157
+158
+fn event_channel() -> (js_sys::Function, mpsc::UnboundedReceiver<(String, String)>) {159
+ let (tx, rx) = mpsc::unbounded();160
+ let closure = Closure::wrap(Box::new(move |account: String, json: String| {161
+ let _ = tx.unbounded_send((account, json));162
+ }) as Box<dyn FnMut(String, String)>);163
+ (closure.into_js_value().unchecked_into(), rx)164
+}165
+166
+fn message_channel() -> (js_sys::Function, mpsc::UnboundedReceiver<String>) {167
+ let (tx, rx) = mpsc::unbounded();168
+ let closure = Closure::wrap(Box::new(move |message: String| {169
+ let _ = tx.unbounded_send(message);170
+ }) as Box<dyn FnMut(String)>);171
+ (closure.into_js_value().unchecked_into(), rx)172
+}173
+174
+/// polls the `rooms` op rather than the event stream, since the seeded room's arrival175
+/// order relative to the subscription's own snapshot isn't worth racing against176
+async fn find_room(host: &Host, account: &str, room_name: &str, deadline: f64) -> String {177
+ loop {178
+ let reply = host179
+ .call(account.to_string(), r#"{"op":"rooms"}"#.to_string(), None)180
+ .await181
+ .unwrap_or_else(|error| panic!("rooms failed: {:?}", as_json(error)));182
+ let rooms = as_json(reply);183
+ let found = rooms184
+ .get("json")185
+ .and_then(|json| json.get("rooms"))186
+ .and_then(Value::as_array)187
+ .and_then(|rooms| {188
+ rooms189
+ .iter()190
+ .find(|room| room.get("name").and_then(Value::as_str) == Some(room_name))191
+ })192
+ .and_then(|room| room.get("room_id"))193
+ .and_then(Value::as_str)194
+ .map(str::to_owned);195
+ if let Some(room_id) = found {196
+ return room_id;197
+ }198
+ assert!(199
+ js_sys::Date::now() < deadline,200
+ "the seeded room never appeared"201
+ );202
+ TimeoutFuture::new(200).await;203
+ }204
+}205
+206
+async fn wait_for_text(207
+ events: &mut mpsc::UnboundedReceiver<(String, String)>,208
+ account: &str,209
+ text: &str,210
+ deadline: f64,211
+) {212
+ loop {213
+ let left = (deadline - js_sys::Date::now()).max(0.0) as u32;214
+ match select(Box::pin(events.next()), Box::pin(TimeoutFuture::new(left))).await {215
+ Either::Left((Some((event_account, json)), _)) => {216
+ if event_account == account && json.contains(text) {217
+ return;218
+ }219
+ }220
+ Either::Left((None, _)) => panic!("event channel closed while waiting for {text:?}"),221
+ Either::Right(_) => panic!("timed out waiting for {text:?}"),222
+ }223
+ }224
+}@@ -0,0 +1,66 @@
1
+{2
+ "nodes": {3
+ "fenix": {4
+ "inputs": {5
+ "nixpkgs": [6
+ "nixpkgs"7
+ ],8
+ "rust-analyzer-src": "rust-analyzer-src"9
+ },10
+ "locked": {11
+ "lastModified": 1790420830,12
+ "narHash": "sha256-z3OT9zp96drkDc2qZlC9WRXMpKaysdMDJiwWpLVeiOU=",13
+ "owner": "nix-community",14
+ "repo": "fenix",15
+ "rev": "5f7e7d793cb2553410f857554de86f277ebe2f71",16
+ "type": "github"17
+ },18
+ "original": {19
+ "owner": "nix-community",20
+ "repo": "fenix",21
+ "type": "github"22
+ }23
+ },24
+ "nixpkgs": {25
+ "locked": {26
+ "lastModified": 1790149054,27
+ "narHash": "sha256-FfjXg6mAzKQBCY+7V9keGWOXG4DV2Eym/qA987lFl8A=",28
+ "owner": "NixOS",29
+ "repo": "nixpkgs",30
+ "rev": "8825bebf6324e0579d012936eff73379af284b6d",31
+ "type": "github"32
+ },33
+ "original": {34
+ "owner": "NixOS",35
+ "ref": "nixpkgs-unstable",36
+ "repo": "nixpkgs",37
+ "type": "github"38
+ }39
+ },40
+ "root": {41
+ "inputs": {42
+ "fenix": "fenix",43
+ "nixpkgs": "nixpkgs"44
+ }45
+ },46
+ "rust-analyzer-src": {47
+ "flake": false,48
+ "locked": {49
+ "lastModified": 1790305964,50
+ "narHash": "sha256-2qzlUJB59Rq0XIjVGf42XqqaTwXoRLIFX4S7sW1t2iw=",51
+ "owner": "rust-lang",52
+ "repo": "rust-analyzer",53
+ "rev": "1ad44dc58e65304b594063e70c144ecb58643671",54
+ "type": "github"55
+ },56
+ "original": {57
+ "owner": "rust-lang",58
+ "ref": "nightly",59
+ "repo": "rust-analyzer",60
+ "type": "github"61
+ }62
+ }63
+ },64
+ "root": "root",65
+ "version": 766
+}@@ -0,0 +1,230 @@
1
+{2
+ description = "Malice: a hosted Matrix client backed by macltok";3
+4
+ inputs = {5
+ nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";6
+ fenix = {7
+ url = "github:nix-community/fenix";8
+ inputs.nixpkgs.follows = "nixpkgs";9
+ };10
+ };11
+12
+ outputs =13
+ { self, nixpkgs, fenix }:14
+ let15
+ systems = [16
+ "x86_64-linux"17
+ "aarch64-linux"18
+ ];19
+ forAllSystems = nixpkgs.lib.genAttrs systems;20
+ workerLock = builtins.fromTOML (builtins.readFile ./crates/malice-worker/Cargo.lock);21
+ lockedVersion =22
+ name: (nixpkgs.lib.findFirst (p: p.name == name) null workerLock.package).version;23
+ # wasm-bindgen-test-runner refuses a module built by any other wasm-bindgen version24
+ wasmBindgenCli =25
+ pkgs:26
+ pkgs.buildWasmBindgenCli rec {27
+ src = pkgs.fetchCrate {28
+ pname = "wasm-bindgen-cli";29
+ version = lockedVersion "wasm-bindgen";30
+ hash = "sha256-a7lcXJnnZkYReja+iUO7NqqrWyv3toxnUgQb8s4IS5s=";31
+ };32
+ cargoDeps = pkgs.rustPlatform.fetchCargoVendor {33
+ inherit src;34
+ inherit (src) pname version;35
+ hash = "sha256-R1Tas33Ursy8kqsxguAkG0ZhNed2n5uFTAhw1l2qlLY=";36
+ };37
+ };38
+ wasmToolchain =39
+ system:40
+ let41
+ toolchains = fenix.packages.${system};42
+ in43
+ toolchains.combine [44
+ toolchains.stable.cargo45
+ toolchains.stable.clippy46
+ toolchains.stable.rustc47
+ toolchains.stable.rustfmt48
+ toolchains.stable.rust-src49
+ toolchains.targets.wasm32-unknown-unknown.stable.rust-std50
+ ];51
+ in52
+ {53
+ packages = forAllSystems (54
+ system:55
+ let56
+ pkgs = nixpkgs.legacyPackages.${system};57
+ wasmPlatform = pkgs.makeRustPlatform {58
+ cargo = wasmToolchain system;59
+ rustc = wasmToolchain system;60
+ };61
+ web = pkgs.buildNpmPackage {62
+ pname = "malice-web";63
+ version = "0.1.0";64
+ src = ./web;65
+ npmDepsHash = "sha256-Q00XwHH/+kfZTAPBKUlkx3hAtYvSpDw2opj22nGTIMY=";66
+ installPhase = ''67
+ runHook preInstall68
+ mkdir -p $out/share/malice69
+ cp -R dist/. $out/share/malice/70
+ runHook postInstall71
+ '';72
+ };73
+ backend = pkgs.rustPlatform.buildRustPackage {74
+ pname = "malice-server";75
+ version = "0.1.0";76
+ src = ./.;77
+ cargoLock.lockFile = ./Cargo.lock;78
+ nativeBuildInputs = [ pkgs.pkg-config ];79
+ buildInputs = [ pkgs.sqlite ];80
+ # preBuild = "export NIX_BUILD_CORES=2";81
+ preCheck = ''82
+ cargo build --release --bin malice --target ${pkgs.stdenv.hostPlatform.rust.rustcTarget} --offline --locked83
+ '';84
+ SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";85
+ };86
+ server = pkgs.symlinkJoin {87
+ name = "malice";88
+ paths = [89
+ backend90
+ web91
+ ];92
+ nativeBuildInputs = [ pkgs.makeWrapper ];93
+ postBuild = ''94
+ wrapProgram $out/bin/malice --set MALICE_WEB_DIR ${web}/share/malice95
+ '';96
+ };97
+ # the wasm-bindgen `--target web` glue for malice-worker's `Host`, consumed by98
+ # web/src/worker/pkg (gitignored, worker-web's TS imports it as ./pkg/malice_worker.js)99
+ workerWasm = wasmPlatform.buildRustPackage {100
+ pname = "malice-worker-wasm";101
+ version = "0.1.0";102
+ src = self;103
+ cargoRoot = "crates/malice-worker";104
+ cargoLock.lockFile = ./crates/malice-worker/Cargo.lock;105
+ nativeBuildInputs = [ (wasmBindgenCli pkgs) ];106
+ buildPhase = ''107
+ runHook preBuild108
+ cd crates/malice-worker109
+ cargo build --offline --frozen --release --lib --target wasm32-unknown-unknown110
+ mkdir -p $out111
+ wasm-bindgen --target web --out-dir $out --out-name malice_worker \112
+ target/wasm32-unknown-unknown/release/malice_worker.wasm113
+ runHook postBuild114
+ '';115
+ doCheck = false;116
+ installPhase = "true";117
+ };118
+ web-device = pkgs.buildNpmPackage {119
+ pname = "malice-web-device";120
+ version = "0.1.0";121
+ src = ./web;122
+ npmDepsHash = "sha256-Q00XwHH/+kfZTAPBKUlkx3hAtYvSpDw2opj22nGTIMY=";123
+ postPatch = ''124
+ mkdir -p src/worker/pkg125
+ cp -R ${workerWasm}/. src/worker/pkg/126
+ '';127
+ npmBuildScript = "build:device";128
+ installPhase = ''129
+ runHook preInstall130
+ mkdir -p $out/share/malice131
+ cp -R dist-device/. $out/share/malice/132
+ runHook postInstall133
+ '';134
+ };135
+ local = pkgs.symlinkJoin {136
+ name = "malice-local";137
+ paths = [138
+ backend139
+ web-device140
+ ];141
+ nativeBuildInputs = [ pkgs.makeWrapper ];142
+ postBuild = ''143
+ wrapProgram $out/bin/malice \144
+ --set MALICE_WEB_DIR ${web-device}/share/malice \145
+ --set MALICE_STATIC_ONLY 1146
+ '';147
+ };148
+ in149
+ {150
+ inherit web server web-device local;151
+ default = server;152
+ }153
+ );154
+155
+ devShells = forAllSystems (156
+ system:157
+ let158
+ pkgs = nixpkgs.legacyPackages.${system};159
+ in160
+ {161
+ default = pkgs.mkShell {162
+ packages = [163
+ (wasmToolchain system)164
+ (wasmBindgenCli pkgs)165
+ pkgs.rust-analyzer166
+ pkgs.nodejs_22167
+ pkgs.pkg-config168
+ pkgs.sqlite169
+ pkgs.firefox170
+ pkgs.geckodriver171
+ pkgs.matrix-continuwuity172
+ pkgs.nushell173
+ ];174
+ # fenix's rustc links through its bundled rust-lld, which skips nix's ld-wrapper and so175
+ # the store rpaths, leaving the native server unable to find libsqlite3 at run time176
+ "CARGO_TARGET_${nixpkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] pkgs.stdenv.hostPlatform.config)}_RUSTFLAGS" =177
+ "-C linker-features=-lld";178
+ };179
+ }180
+ );181
+182
+ nixosModules.malice = import ./nix/module.nix { inherit self; };183
+ nixosModules.default = self.nixosModules.malice;184
+ checks = forAllSystems (185
+ system:186
+ let187
+ pkgs = nixpkgs.legacyPackages.${system};188
+ wasmPlatform = pkgs.makeRustPlatform {189
+ cargo = wasmToolchain system;190
+ rustc = wasmToolchain system;191
+ };192
+ # continuwuity refuses to start without /etc/resolv.conf, which the build sandbox lacks, or with193
+ # stdenv's deliberately bogus SSL_CERT_FILE. no exec, run.nu launches through a shell that exits at194
+ # once and would trip --die-with-parent, so this shell stays as the parent run.nu kills195
+ sandboxedHomeserver = pkgs.writeShellScriptBin "conduwuit" ''196
+ export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt197
+ ${pkgs.lib.getExe pkgs.bubblewrap} --die-with-parent --dev-bind / / --tmpfs /etc \198
+ --ro-bind ${pkgs.writeText "resolv.conf" "nameserver 127.0.0.1\n"} /etc/resolv.conf \199
+ ${pkgs.lib.getExe' pkgs.matrix-continuwuity "conduwuit"} "$@"200
+ '';201
+ in202
+ {203
+ default = self.packages.${system}.default;204
+ worker-wasm = self.packages.${system}.web-device;205
+ worker-browser = wasmPlatform.buildRustPackage {206
+ pname = "malice-worker-browser-check";207
+ version = "0.1.0";208
+ src = self;209
+ cargoRoot = "crates/malice-worker";210
+ cargoLock.lockFile = ./crates/malice-worker/Cargo.lock;211
+ nativeBuildInputs = [212
+ (wasmBindgenCli pkgs)213
+ pkgs.firefox214
+ pkgs.geckodriver215
+ sandboxedHomeserver216
+ pkgs.nushell217
+ ];218
+ buildPhase = ''219
+ runHook preBuild220
+ export HOME=$TMPDIR221
+ nu --no-config-file crates/malice-worker/run.nu222
+ runHook postBuild223
+ '';224
+ doCheck = false;225
+ installPhase = "touch $out";226
+ };227
+ }228
+ );229
+ };230
+}@@ -0,0 +1,158 @@
1
+{ self }:2
+{3
+ config,4
+ lib,5
+ pkgs,6
+ ...7
+}:8
+let9
+ system = pkgs.stdenv.hostPlatform.system;10
+ hostedCfg = config.services.malice;11
+ deviceCfg = config.programs.malice;12
+ inherit (lib)13
+ mkEnableOption14
+ mkIf15
+ mkMerge16
+ mkOption17
+ types18
+ ;19
+ deviceUrl = "http://127.0.0.1:${toString deviceCfg.port}/";20
+ browserCommand =21
+ if lib.hasInfix "%u" deviceCfg.browser then22
+ lib.replaceStrings [ "%u" ] [ deviceUrl ] deviceCfg.browser23
+ else24
+ "${deviceCfg.browser} ${deviceUrl}";25
+ malicOpen = pkgs.writeShellScriptBin "malice-open" ''26
+ exec ${browserCommand}27
+ '';28
+ malicDesktopItem = pkgs.makeDesktopItem {29
+ name = "malice";30
+ desktopName = "Malice";31
+ comment = "Open the on-device Malice Matrix client";32
+ exec = "${malicOpen}/bin/malice-open";33
+ terminal = false;34
+ categories = [35
+ "Network"36
+ "InstantMessaging"37
+ ];38
+ };39
+in40
+{41
+ options.services.malice = {42
+ enable = mkEnableOption "Malice hosted Matrix client";43
+44
+ package = mkOption {45
+ type = types.package;46
+ default = self.packages.${system}.default;47
+ description = "Malice server and bundled Svelte frontend.";48
+ };49
+50
+ listenAddress = mkOption {51
+ type = types.str;52
+ default = "127.0.0.1";53
+ description = "Address to bind. Keep behind an HTTPS reverse proxy for secure browser cookies.";54
+ };55
+56
+ port = mkOption {57
+ type = types.port;58
+ default = 3000;59
+ description = "HTTP listener port.";60
+ };61
+62
+ openFirewall = mkOption {63
+ type = types.bool;64
+ default = false;65
+ description = "Open the HTTP listener port in the firewall.";66
+ };67
+ };68
+69
+ options.programs.malice = {70
+ enable = mkEnableOption "Malice on-device Matrix client";71
+72
+ package = mkOption {73
+ type = types.package;74
+ default = self.packages.${system}.local;75
+ description = "The malice binary wrapped for the on-device build (MALICE_WEB_DIR and MALICE_STATIC_ONLY set).";76
+ };77
+78
+ port = mkOption {79
+ type = types.port;80
+ default = 38465;81
+ description = ''82
+ Loopback port the socket-activated server listens on. This port is83
+ part of the browser origin malice runs at, so changing it hides84
+ accounts logged in at the old origin (their data is still on disk,85
+ just unreachable at the new URL). Everyone logged into this machine86
+ at once shares this one port; per-user ports are not supported yet.87
+ '';88
+ };89
+90
+ browser = mkOption {91
+ type = types.str;92
+ default = "xdg-open %u";93
+ description = ''94
+ Command the desktop entry runs to open malice. `%u` (desktop-entry95
+ convention) is replaced with malice's URL; if the command has no96
+ `%u`, the URL is appended after a space instead. E.g. "chromium97
+ --app=%u" to open malice in an app window instead of a browser tab.98
+ '';99
+ };100
+ };101
+102
+ config = mkMerge [103
+ (mkIf hostedCfg.enable {104
+ systemd.services.malice = {105
+ description = "Malice hosted Matrix client";106
+ wantedBy = [ "multi-user.target" ];107
+ after = [ "network-online.target" ];108
+ wants = [ "network-online.target" ];109
+ environment = {110
+ MALICE_BIND = "${hostedCfg.listenAddress}:${toString hostedCfg.port}";111
+ MALICE_DATA_DIR = "/var/lib/malice";112
+ MALICE_WEB_DIR = "${self.packages.${system}.web}/share/malice";113
+ MALICE_SESSION_COOKIE_SECURE = "true";114
+ };115
+ serviceConfig = {116
+ ExecStart = lib.getExe' hostedCfg.package "malice";117
+ DynamicUser = true;118
+ StateDirectory = "malice";119
+ StateDirectoryMode = "0700";120
+ UMask = "0077";121
+ Restart = "on-failure";122
+ RestartSec = 5;123
+ NoNewPrivileges = true;124
+ ProtectSystem = "strict";125
+ ProtectHome = true;126
+ PrivateTmp = true;127
+ RestrictAddressFamilies = [128
+ "AF_INET"129
+ "AF_INET6"130
+ "AF_UNIX"131
+ ];132
+ };133
+ };134
+ networking.firewall.allowedTCPPorts = lib.optionals hostedCfg.openFirewall [ hostedCfg.port ];135
+ })136
+ (mkIf deviceCfg.enable {137
+ systemd.user.sockets.malice = {138
+ description = "Malice on-device Matrix client socket";139
+ wantedBy = [ "sockets.target" ];140
+ socketConfig.ListenStream = "127.0.0.1:${toString deviceCfg.port}";141
+ };142
+ systemd.user.services.malice = {143
+ description = "Malice on-device Matrix client";144
+ environment = {145
+ MALICE_STATIC_ONLY = "1";146
+ MALICE_IDLE_EXIT_SECS = "300";147
+ };148
+ serviceConfig = {149
+ ExecStart = lib.getExe' deviceCfg.package "malice";150
+ };151
+ };152
+ environment.systemPackages = [153
+ malicOpen154
+ malicDesktopItem155
+ ];156
+ })157
+ ];158
+}@@ -0,0 +1,78 @@
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
+mirror = []10
+serde = ["dep:serde", "imbl/serde"]11
+sqlite = ["matrix-sdk/sqlite", "serde", "dep:url", "dep:matrix-sdk-store-encryption"]12
+bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"]13
+indexeddb = [14
+ "matrix-sdk/indexeddb",15
+ "serde",16
+ "dep:url",17
+ "dep:matrix-sdk-store-encryption",18
+ "dep:indexed_db_futures",19
+ "dep:js-sys",20
+ "dep:wasm-bindgen",21
+ "dep:wasm-bindgen-futures",22
+ "dep:web-sys",23
+]24
+25
+[dependencies]26
+futures-util = { version = "0.3.31", default-features = false, features = ["std"] }27
+http = "1"28
+imbl = { version = "6.1.0", default-features = false }29
+matrix-sdk = { version = "0.18.0", default-features = false, features = ["e2e-encryption"] }30
+matrix-sdk-crypto = { version = "0.18.0", default-features = false }31
+matrix-sdk-store-encryption = { version = "0.18.0", optional = true }32
+matrix-sdk-ui = { version = "0.18.0", default-features = false }33
+ruma = { version = "0.16.0", default-features = false }34
+serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }35
+thiserror = "2.0.16"36
+mime = "0.3.17"37
+sha2 = "0.10.9"38
+tokio = { version = "1.47.1", features = ["macros", "sync"] }39
+tracing = "0.1.41"40
+url = { version = "2.5.7", optional = true }41
+42
+[target.'cfg(target_family = "wasm")'.dependencies]43
+getrandom = { version = "0.2", features = ["js"] }44
+getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }45
+getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }46
+indexed_db_futures = { package = "matrix_indexed_db_futures", version = "0.7.0", optional = true, features = ["typed-arrays"] }47
+js-sys = { version = "0.3.105", optional = true }48
+matrix-sdk = { version = "0.18.0", default-features = false, features = ["js"] }49
+matrix-sdk-ui = { version = "0.18.0", default-features = false, features = ["js"] }50
+# the sdk's own wasm reqwest leaves out stream, which the bounded media download needs51
+reqwest = { version = "0.13.1", default-features = false, features = ["stream"] }52
+uuid = { version = "1", features = ["js"] }53
+wasm-bindgen = { version = "0.2.128", optional = true }54
+wasm-bindgen-futures = { version = "0.4.78", optional = true }55
+web-sys = { version = "0.3.105", optional = true, features = [56
+ "IdbFactory",57
+ "IdbOpenDbRequest",58
+ "IdbRequest",59
+ "Window",60
+ "WorkerGlobalScope",61
+] }62
+63
+[dev-dependencies]64
+matrix-sdk = { version = "0.18.0", default-features = false, features = ["testing"] }65
+matrix-sdk-test = "0.18.0"66
+serde_json = "1.0.143"67
+tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal", "time"] }68
+wiremock = "0.6.5"69
+70
+[[example]]71
+name = "headless"72
+required-features = ["sqlite"]73
+74
+[lints.rust]75
+unsafe_code = "forbid"76
+77
+[lints.clippy]78
+all = "warn"@@ -0,0 +1,119 @@
1
+use std::sync::Arc;2
+3
+use macltok::{4
+ EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Retain, RoomMembership,5
+ Session, SessionStore, StoreConfig, TimelineContent, TimelineDiff, TimelineId, TimelineItem,6
+ imbl::Vector,7
+};8
+9
+#[tokio::main]10
+async fn main() -> Result<(), Box<dyn std::error::Error>> {11
+ let store_path = std::path::PathBuf::from(required("MATRIX_STORE_PATH")?);12
+ let passphrase = required("MATRIX_STORE_PASSPHRASE")?;13
+ let sessions = Arc::new(EncryptedSessionStore::file(14
+ store_path.with_extension("session"),15
+ StoreConfig::sqlite(&store_path, &passphrase),16
+ ));17
+ let session = match Session::restore(sessions.clone()).await? {18
+ Some(Restore::Resumed(session)) => session,19
+ Some(Restore::SoftLoggedOut(soft)) => {20
+ Session::login_password(21
+ soft.password_login(required("MATRIX_PASSWORD")?),22
+ sessions.clone(),23
+ )24
+ .await?25
+ }26
+ None => {27
+ Session::login_password(28
+ PasswordLogin::new(29
+ required("MATRIX_HOMESERVER")?,30
+ required("MATRIX_USERNAME")?,31
+ required("MATRIX_PASSWORD")?,32
+ )33
+ .with_device_display_name("macltok headless example"),34
+ sessions.clone(),35
+ )36
+ .await?37
+ }38
+ };39
+ let (engine, handle, mut events) = session.build(EngineConfig::default()).await?;40
+ let running = tokio::spawn(engine.run());41
+ let mut opened: Option<TimelineId> = None;42
+ let mut items = Vector::new();43
+ let mut stale = false;44
+45
+ loop {46
+ tokio::select! {47
+ event = events.recv() => {48
+ let Some(event) = event else { break };49
+ match event {50
+ Event::RoomsChanged if opened.is_none() => {51
+ let Some(room) = handle52
+ .rooms()53
+ .await54
+ .into_iter()55
+ .find(|room| room.membership == RoomMembership::Joined)56
+ else {57
+ continue;58
+ };59
+ opened = Some(room.room_id.clone().into());60
+ // answers arrive on their own, the event loop keeps draining meanwhile61
+ let handle = handle.clone();62
+ tokio::spawn(async move {63
+ if let Err(error) = handle.open_room(room.room_id).await {64
+ eprintln!("could not open {error}");65
+ }66
+ });67
+ }68
+ Event::TimelineReset { timeline_id, items: snapshot }69
+ if opened.as_ref() == Some(&timeline_id) =>70
+ {71
+ items = snapshot;72
+ stale = false;73
+ print_messages(&items);74
+ }75
+ Event::TimelineChanged { timeline_id, diffs }76
+ if !stale && opened.as_ref() == Some(&timeline_id) =>77
+ {78
+ if TimelineDiff::try_apply_all(diffs, &mut items).is_err() {79
+ stale = true;80
+ let handle = handle.clone();81
+ tokio::spawn(async move { handle.resync(timeline_id).await });82
+ continue;83
+ }84
+ print_messages(&items);85
+ }86
+ Event::Stopped => break,87
+ _ => {}88
+ }89
+ }90
+ result = tokio::signal::ctrl_c() => {91
+ result?;92
+ let handle = handle.clone();93
+ tokio::spawn(async move { handle.shutdown().await });94
+ }95
+ }96
+ }97
+ let termination = running.await??;98
+ drop((handle, events));99
+ match termination.retain() {100
+ Retain::RecordAndStore => {}101
+ Retain::Store => sessions.soft_log_out().await?,102
+ Retain::Nothing => sessions.discard().await?,103
+ }104
+ Ok(())105
+}106
+107
+fn print_messages(items: &Vector<TimelineItem>) {108
+ for item in items {109
+ if let TimelineItem::Event(event) = item110
+ && let TimelineContent::Message(message) = &event.content111
+ {112
+ println!("{} {}", event.sender.name(), message.body);113
+ }114
+ }115
+}116
+117
+fn required(name: &str) -> Result<String, Box<dyn std::error::Error>> {118
+ std::env::var(name).map_err(|_| format!("{name} is required").into())119
+}@@ -0,0 +1,689 @@
1
+#[cfg(feature = "sqlite")]2
+use std::path::PathBuf;3
+#[cfg(not(target_family = "wasm"))]4
+use std::sync::atomic::{AtomicBool, Ordering};5
+use std::{collections::HashMap, sync::Arc};6
+7
+use matrix_sdk::{8
+ Client, ClientBuilder, SendOutsideWasm, SessionChange, SessionMeta, SyncOutsideWasm,9
+ ThreadingSupport,10
+ authentication::{11
+ AuthSession,12
+ matrix::MatrixSession,13
+ oauth::{14
+ ClientId, ClientRegistrationData, CsrfToken, OAuthSession, UserSession,15
+ registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},16
+ },17
+ },18
+ cross_process_lock::CrossProcessLockConfig,19
+ encryption::{BackupDownloadStrategy, EncryptionSettings},20
+ ruma::{OwnedDeviceId, OwnedUserId, serde::Raw},21
+};22
+use serde::{Deserialize, Serialize};23
+use thiserror::Error;24
+use tokio::sync::{broadcast, mpsc};25
+use url::Url;26
+27
+use crate::{28
+ ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, EventStream, SessionStore,29
+ engine::{Persistence, StoreLock},30
+ error::SourceError,31
+};32
+33
+/// a logged in client and the `SessionStore` its record lives in. the engine it builds34
+/// saves every token rotation there, unless told `without_persistence`35
+pub struct Session {36
+ client: Client,37
+ tracking: Tracking,38
+ store_lock: StoreLock,39
+}40
+41
+/// taken as soon as the client exists, since the sdk can refresh before there's an engine42
+struct Tracking {43
+ session_changes: broadcast::Receiver<SessionChange>,44
+ persistence: Option<Persistence>,45
+ #[cfg(not(target_family = "wasm"))]46
+ inline: Arc<AtomicBool>,47
+}48
+49
+#[non_exhaustive]50
+pub struct PasswordLogin {51
+ pub homeserver: String,52
+ pub username: String,53
+ pub password: String,54
+ pub device_display_name: Option<String>,55
+ /// set after a soft logout so the store's keys still belong to us56
+ pub device_id: Option<OwnedDeviceId>,57
+}58
+59
+impl PasswordLogin {60
+ #[must_use]61
+ pub fn new(62
+ homeserver: impl Into<String>,63
+ username: impl Into<String>,64
+ password: impl Into<String>,65
+ ) -> Self {66
+ Self {67
+ homeserver: homeserver.into(),68
+ username: username.into(),69
+ password: password.into(),70
+ device_display_name: None,71
+ device_id: None,72
+ }73
+ }74
+75
+ #[must_use]76
+ pub fn with_device_display_name(mut self, name: impl Into<String>) -> Self {77
+ self.device_display_name = Some(name.into());78
+ self79
+ }80
+81
+ #[must_use]82
+ pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {83
+ self.device_id = Some(device_id);84
+ self85
+ }86
+}87
+88
+#[non_exhaustive]89
+pub struct OAuthLogin {90
+ pub homeserver: String,91
+ pub redirect_uri: String,92
+ pub client_uri: String,93
+ pub client_name: Option<String>,94
+ pub client_id: Option<String>,95
+ pub device_id: Option<OwnedDeviceId>,96
+ pub application_type: OAuthApplicationType,97
+}98
+99
+impl OAuthLogin {100
+ #[must_use]101
+ pub fn new(102
+ homeserver: impl Into<String>,103
+ redirect_uri: impl Into<String>,104
+ client_uri: impl Into<String>,105
+ application_type: OAuthApplicationType,106
+ ) -> Self {107
+ Self {108
+ homeserver: homeserver.into(),109
+ redirect_uri: redirect_uri.into(),110
+ client_uri: client_uri.into(),111
+ client_name: None,112
+ client_id: None,113
+ device_id: None,114
+ application_type,115
+ }116
+ }117
+118
+ #[must_use]119
+ pub fn with_client_name(mut self, name: impl Into<String>) -> Self {120
+ self.client_name = Some(name.into());121
+ self122
+ }123
+124
+ /// a statically registered client id, skipping dynamic registration125
+ #[must_use]126
+ pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {127
+ self.client_id = Some(client_id.into());128
+ self129
+ }130
+131
+ #[must_use]132
+ pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {133
+ self.device_id = Some(device_id);134
+ self135
+ }136
+}137
+138
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]139
+#[non_exhaustive]140
+pub enum OAuthApplicationType {141
+ Native,142
+ Web,143
+}144
+145
+pub struct PendingOAuthLogin {146
+ client: Client,147
+ sessions: Arc<dyn SessionStore>,148
+ tracking: Option<(Tracking, StoreLock)>,149
+ authorization_url: String,150
+ state: Option<CsrfToken>,151
+}152
+153
+impl PendingOAuthLogin {154
+ #[must_use]155
+ pub fn authorization_url(&self) -> &str {156
+ &self.authorization_url157
+ }158
+159
+ #[must_use]160
+ pub fn registered_client_id(&self) -> String {161
+ self.client162
+ .oauth()163
+ .client_id()164
+ .expect("OAuth client was registered before authorization")165
+ .as_str()166
+ .to_owned()167
+ }168
+169
+ #[must_use]170
+ pub const fn is_pending(&self) -> bool {171
+ self.state.is_some()172
+ }173
+174
+ pub async fn finish(&mut self, callback_url: &str) -> Result<Session, AccountError> {175
+ let callback_url = Url::parse(callback_url)176
+ .map_err(|error| account_error("parsing the OAuth callback URL", error))?;177
+ let state = self178
+ .state179
+ .take()180
+ .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;181
+ let oauth = self.client.oauth();182
+ if let Err(error) = oauth.finish_login(callback_url.into()).await {183
+ oauth.abort_login(&state).await;184
+ return Err(account_error("OAuth login", error));185
+ }186
+ let (tracking, store_lock) = self187
+ .tracking188
+ .take()189
+ .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;190
+ Session::saved(191
+ self.client.clone(),192
+ self.sessions.clone(),193
+ tracking,194
+ store_lock,195
+ )196
+ .await197
+ }198
+199
+ pub async fn abort(mut self) {200
+ if let Some(state) = self.state.take() {201
+ self.client.oauth().abort_login(&state).await;202
+ }203
+ }204
+}205
+206
+pub struct StoreConfig {207
+ location: StoreLocation,208
+ passphrase: String,209
+}210
+211
+enum StoreLocation {212
+ #[cfg(feature = "sqlite")]213
+ Sqlite(PathBuf),214
+ #[cfg(feature = "indexeddb")]215
+ IndexedDb(String),216
+}217
+218
+impl StoreConfig {219
+ #[cfg(feature = "sqlite")]220
+ #[must_use]221
+ pub fn sqlite(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {222
+ Self {223
+ location: StoreLocation::Sqlite(path.into()),224
+ passphrase: passphrase.into(),225
+ }226
+ }227
+228
+ #[cfg(feature = "indexeddb")]229
+ #[must_use]230
+ pub fn indexeddb(name: impl Into<String>, passphrase: impl Into<String>) -> Self {231
+ Self {232
+ location: StoreLocation::IndexedDb(name.into()),233
+ passphrase: passphrase.into(),234
+ }235
+ }236
+237
+ /// delete the store's databases, once nothing has them open238
+ pub async fn delete(&self) -> Result<(), AccountError> {239
+ match &self.location {240
+ #[cfg(feature = "sqlite")]241
+ StoreLocation::Sqlite(path) => crate::session_store::delete_sqlite_store(path)242
+ .map_err(|error| account_error("deleting the Matrix store", error)),243
+ #[cfg(feature = "indexeddb")]244
+ StoreLocation::IndexedDb(name) => crate::session_store::delete_indexeddb_store(name)245
+ .await246
+ .map_err(|error| error.into_account_error("deleting the Matrix store")),247
+ }248
+ }249
+250
+ /// taken before the client opens the store, and held until the engine and every251
+ /// `ClientHandle` are gone252
+ pub(crate) async fn lock(&self) -> Result<StoreLock, AccountError> {253
+ match &self.location {254
+ #[cfg(feature = "sqlite")]255
+ StoreLocation::Sqlite(path) => StoreLock::file(path),256
+ #[cfg(feature = "indexeddb")]257
+ StoreLocation::IndexedDb(name) => StoreLock::web(name).await,258
+ }259
+ }260
+261
+ pub(crate) fn passphrase(&self) -> &str {262
+ &self.passphrase263
+ }264
+265
+ #[cfg(feature = "indexeddb")]266
+ pub(crate) fn indexeddb_name(&self) -> &str {267
+ let StoreLocation::IndexedDb(name) = &self.location;268
+ name269
+ }270
+}271
+272
+/// Serializable authentication secrets. A `SessionStore` keeps it, and the engine273
+/// replaces it there on every token rotation.274
+#[derive(Clone, Deserialize, Serialize)]275
+pub struct SessionRecord {276
+ homeserver: String,277
+ authentication: AuthenticationRecord,278
+}279
+280
+#[derive(Clone, Deserialize, Serialize)]281
+#[serde(tag = "type", content = "session", rename_all = "snake_case")]282
+enum AuthenticationRecord {283
+ Matrix(MatrixSession),284
+ OAuth {285
+ client_id: ClientId,286
+ user: UserSession,287
+ },288
+ SoftLoggedOut {289
+ meta: SessionMeta,290
+ client_id: Option<ClientId>,291
+ },292
+}293
+294
+impl SessionRecord {295
+ #[must_use]296
+ pub fn homeserver(&self) -> &str {297
+ &self.homeserver298
+ }299
+300
+ pub(crate) fn soft_logged_out(self) -> Self {301
+ let authentication = match self.authentication {302
+ AuthenticationRecord::Matrix(session) => AuthenticationRecord::SoftLoggedOut {303
+ meta: session.meta,304
+ client_id: None,305
+ },306
+ AuthenticationRecord::OAuth { client_id, user } => {307
+ AuthenticationRecord::SoftLoggedOut {308
+ meta: user.meta,309
+ client_id: Some(client_id),310
+ }311
+ }312
+ soft @ AuthenticationRecord::SoftLoggedOut { .. } => soft,313
+ };314
+ Self {315
+ homeserver: self.homeserver,316
+ authentication,317
+ }318
+ }319
+}320
+321
+pub enum Restore {322
+ Resumed(Session),323
+ SoftLoggedOut(SoftLogout),324
+}325
+326
+/// who to log in again as. the store keeps this device's e2ee keys, so a login327
+/// as any other device would orphan them328
+#[derive(Clone, Debug)]329
+#[non_exhaustive]330
+pub struct SoftLogout {331
+ pub homeserver: String,332
+ pub user_id: OwnedUserId,333
+ pub device_id: OwnedDeviceId,334
+ /// set when the login was OAuth, for `OAuthLogin::with_client_id`335
+ pub client_id: Option<String>,336
+}337
+338
+impl SoftLogout {339
+ #[must_use]340
+ pub fn password_login(&self, password: impl Into<String>) -> PasswordLogin {341
+ PasswordLogin::new(self.homeserver.clone(), self.user_id.as_str(), password)342
+ .with_device_id(self.device_id.clone())343
+ }344
+}345
+346
+#[derive(Debug, Error)]347
+pub enum AccountError {348
+ #[error("{context} failed")]349
+ Operation {350
+ context: &'static str,351
+ #[source]352
+ source: Box<SourceError>,353
+ },354
+ #[error("the homeserver returned no authenticated session")]355
+ MissingSession,356
+ #[error("the SDK returned an unsupported authentication session")]357
+ UnsupportedAuthentication,358
+ #[error("the OAuth callback was already submitted")]359
+ OAuthCallbackAlreadySubmitted,360
+ /// an IndexedDB delete is waiting on the store's other connections. it goes through361
+ /// once they close, and opening the store again waits behind it362
+ #[error("the store is still open elsewhere, it will be deleted once that closes")]363
+ StoreDeletePending,364
+ /// another engine, in this process or another, has the store. only one runs per store365
+ #[error("another engine is using this store")]366
+ StoreInUse,367
+ /// the login worked but its first record didn't save. the server already has the368
+ /// device, so retry the save rather than log in again369
+ #[error("saving the new login's record failed")]370
+ Unsaved {371
+ session: Box<UnsavedSession>,372
+ #[source]373
+ source: Box<AccountError>,374
+ },375
+ #[error(transparent)]376
+ Engine(#[from] EngineError),377
+}378
+379
+/// a live login whose record never reached its `SessionStore`380
+pub struct UnsavedSession {381
+ session: Session,382
+ sessions: Arc<dyn SessionStore>,383
+}384
+385
+impl UnsavedSession {386
+ pub async fn save(self) -> Result<Session, AccountError> {387
+ let saved = match session_record(&self.session.client) {388
+ Ok(record) => self.sessions.save(&record).await,389
+ Err(error) => Err(error),390
+ };391
+ match saved {392
+ Ok(()) => Ok(self.session),393
+ Err(source) => Err(AccountError::Unsaved {394
+ session: Box::new(self),395
+ source: Box::new(source),396
+ }),397
+ }398
+ }399
+400
+ /// the record to keep somewhere else, if the store won't take it401
+ pub fn record(&self) -> Result<SessionRecord, AccountError> {402
+ session_record(&self.session.client)403
+ }404
+}405
+406
+impl std::fmt::Debug for UnsavedSession {407
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {408
+ formatter409
+ .debug_struct("UnsavedSession")410
+ .finish_non_exhaustive()411
+ }412
+}413
+414
+impl Session {415
+ pub async fn login_password(416
+ login: PasswordLogin,417
+ sessions: Arc<dyn SessionStore>,418
+ ) -> Result<Self, AccountError> {419
+ let store_lock = sessions.store().lock().await?;420
+ let client = matrix_client_builder(sessions.store())421
+ .server_name_or_homeserver_url(login.homeserver.trim())422
+ .build()423
+ .await424
+ .map_err(|error| account_error("building the Matrix client", error))?;425
+ let tracking = Tracking::start(&client, sessions.clone())?;426
+ let mut request = client427
+ .matrix_auth()428
+ .login_username(login.username.trim(), &login.password)429
+ .request_refresh_token();430
+ if let Some(name) = login.device_display_name.as_deref() {431
+ request = request.initial_device_display_name(name);432
+ }433
+ if let Some(device_id) = login.device_id.as_deref() {434
+ request = request.device_id(device_id.as_str());435
+ }436
+ request437
+ .send()438
+ .await439
+ .map_err(|error| account_error("password login", error))?;440
+ Self::saved(client, sessions, tracking, store_lock).await441
+ }442
+443
+ /// the pending login lives only in this process, so the one that begins it has to finish it444
+ pub async fn begin_oauth(445
+ login: OAuthLogin,446
+ sessions: Arc<dyn SessionStore>,447
+ ) -> Result<PendingOAuthLogin, AccountError> {448
+ let redirect_uri = Url::parse(login.redirect_uri.trim())449
+ .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;450
+ let client_uri = Url::parse(login.client_uri.trim())451
+ .map_err(|error| account_error("parsing the OAuth client URI", error))?;452
+ let store_lock = sessions.store().lock().await?;453
+ let client = matrix_client_builder(sessions.store())454
+ .server_name_or_homeserver_url(login.homeserver.trim())455
+ .build()456
+ .await457
+ .map_err(|error| account_error("building the Matrix client", error))?;458
+ let tracking = Tracking::start(&client, sessions.clone())?;459
+ let application_type = match login.application_type {460
+ OAuthApplicationType::Native => ApplicationType::Native,461
+ OAuthApplicationType::Web => ApplicationType::Web,462
+ };463
+ let mut metadata = ClientMetadata::new(464
+ application_type,465
+ vec![OAuthGrantType::AuthorizationCode {466
+ redirect_uris: vec![redirect_uri.clone()],467
+ }],468
+ Localized::new(client_uri, []),469
+ );470
+ metadata.client_name = login.client_name.map(|name| Localized::new(name, []));471
+ let metadata = Raw::new(&metadata)472
+ .map_err(|error| account_error("serializing OAuth client metadata", error))?;473
+ let mut registration = ClientRegistrationData::new(metadata);474
+ if let Some(client_id) = login.client_id {475
+ registration.static_registrations = Some(HashMap::from([(476
+ client.homeserver(),477
+ ClientId::new(client_id),478
+ )]));479
+ }480
+ let authorization = client481
+ .oauth()482
+ .login(redirect_uri, login.device_id, Some(registration), None)483
+ .build()484
+ .await485
+ .map_err(|error| account_error("starting OAuth login", error))?;486
+ Ok(PendingOAuthLogin {487
+ client,488
+ sessions,489
+ tracking: Some((tracking, store_lock)),490
+ authorization_url: authorization.url.to_string(),491
+ state: Some(authorization.state),492
+ })493
+ }494
+495
+ /// Restore the record `sessions` holds, or `None` when it holds none.496
+ /// The caller must also handle rejection and explicit logout.497
+ pub async fn restore(sessions: Arc<dyn SessionStore>) -> Result<Option<Restore>, AccountError> {498
+ let Some(record) = sessions.load().await? else {499
+ return Ok(None);500
+ };501
+ let authentication = match record.authentication {502
+ AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),503
+ AuthenticationRecord::OAuth { client_id, user } => {504
+ OAuthSession { client_id, user }.into()505
+ }506
+ AuthenticationRecord::SoftLoggedOut { meta, client_id } => {507
+ return Ok(Some(Restore::SoftLoggedOut(SoftLogout {508
+ homeserver: record.homeserver,509
+ user_id: meta.user_id,510
+ device_id: meta.device_id,511
+ client_id: client_id.map(|client_id| client_id.as_str().to_owned()),512
+ })));513
+ }514
+ };515
+ let store_lock = sessions.store().lock().await?;516
+ let client = matrix_client_builder(sessions.store())517
+ .homeserver_url(&record.homeserver)518
+ .build()519
+ .await520
+ .map_err(|error| account_error("reopening the Matrix store", error))?;521
+ let tracking = Tracking::start(&client, sessions)?;522
+ client523
+ .restore_session(authentication)524
+ .await525
+ .map_err(|error| account_error("restoring the authenticated session", error))?;526
+ Ok(Some(Restore::Resumed(Self {527
+ client,528
+ tracking,529
+ store_lock,530
+ })))531
+ }532
+533
+ /// leave the record to the host, which then saves `ClientHandle::session_record()`534
+ /// on every `SessionTokensChanged`535
+ #[must_use]536
+ pub fn without_persistence(mut self) -> Self {537
+ self.tracking.persistence = None;538
+ #[cfg(not(target_family = "wasm"))]539
+ self.tracking.inline.store(false, Ordering::Release);540
+ self541
+ }542
+543
+ pub async fn build(544
+ self,545
+ config: EngineConfig,546
+ ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {547
+ Engine::with_persistence(548
+ self.client,549
+ config,550
+ self.tracking.session_changes,551
+ self.tracking.persistence,552
+ Some(self.store_lock),553
+ )554
+ .await555
+ .map_err(AccountError::from)556
+ }557
+558
+ async fn saved(559
+ client: Client,560
+ sessions: Arc<dyn SessionStore>,561
+ tracking: Tracking,562
+ store_lock: StoreLock,563
+ ) -> Result<Self, AccountError> {564
+ UnsavedSession {565
+ session: Self {566
+ client,567
+ tracking,568
+ store_lock,569
+ },570
+ sessions,571
+ }572
+ .save()573
+ .await574
+ }575
+}576
+577
+impl Tracking {578
+ fn start(client: &Client, sessions: Arc<dyn SessionStore>) -> Result<Self, AccountError> {579
+ let session_changes = client.subscribe_to_session_changes();580
+ #[cfg_attr(target_family = "wasm", allow(unused_variables))]581
+ let (results, inline_results) = mpsc::unbounded_channel();582
+ #[cfg(not(target_family = "wasm"))]583
+ let inline = save_inline(client, sessions.clone(), results)?;584
+ Ok(Self {585
+ session_changes,586
+ persistence: Some(Persistence {587
+ save: Box::new(move |client| {588
+ let sessions = sessions.clone();589
+ Box::pin(async move {590
+ let record = session_record(&client).map_err(save_failed)?;591
+ sessions.save(&record).await.map_err(save_failed)592
+ })593
+ }),594
+ inline: inline_results,595
+ }),596
+ #[cfg(not(target_family = "wasm"))]597
+ inline,598
+ })599
+ }600
+}601
+602
+/// the sdk calls this inside its refresh, after it has the new tokens and before it uses603
+/// them, so a store that writes there and then can't lose a refresh token to a crash604
+#[cfg(not(target_family = "wasm"))]605
+fn save_inline(606
+ client: &Client,607
+ sessions: Arc<dyn SessionStore>,608
+ results: mpsc::UnboundedSender<Result<(), crate::Error>>,609
+) -> Result<Arc<AtomicBool>, AccountError> {610
+ let enabled = Arc::new(AtomicBool::new(true));611
+ let saving = enabled.clone();612
+ client613
+ .set_session_callbacks(614
+ // only asked for with the cross process refresh lock, which we never enable615
+ Box::new(|client| {616
+ client617
+ .session_tokens()618
+ .ok_or_else(|| AccountError::MissingSession.into())619
+ }),620
+ Box::new(move |client| {621
+ if !saving.load(Ordering::Acquire) {622
+ return Ok(());623
+ }624
+ let record = session_record(&client)?;625
+ if let Some(result) = sessions.save_blocking(&record) {626
+ let _ = results.send(result.map_err(save_failed));627
+ }628
+ Ok(())629
+ }),630
+ )631
+ .map_err(|error| account_error("registering the session callbacks", error))?;632
+ Ok(enabled)633
+}634
+635
+fn save_failed(error: AccountError) -> crate::Error {636
+ let message = format!("saving the session record failed, {error}");637
+ crate::Error::with_source(ErrorKind::Store, message, error)638
+}639
+640
+pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {641
+ let authentication = match client.session().ok_or(AccountError::MissingSession)? {642
+ AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),643
+ AuthSession::OAuth(session) => {644
+ let OAuthSession { client_id, user } = *session;645
+ AuthenticationRecord::OAuth { client_id, user }646
+ }647
+ _ => return Err(AccountError::UnsupportedAuthentication),648
+ };649
+ Ok(SessionRecord {650
+ homeserver: client.homeserver().to_string(),651
+ authentication,652
+ })653
+}654
+655
+fn encryption_settings() -> EncryptionSettings {656
+ EncryptionSettings {657
+ auto_enable_cross_signing: true,658
+ backup_download_strategy: BackupDownloadStrategy::OneShot,659
+ auto_enable_backups: true,660
+ }661
+}662
+663
+/// `StoreLock` already keeps every other client off the store, and the sdk's lease would cost664
+/// a store write per locked section and a task renewing it every 50ms665
+fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {666
+ let builder = Client::builder()667
+ .with_encryption_settings(encryption_settings())668
+ .with_threading_support(ThreadingSupport::Enabled {669
+ with_subscriptions: false,670
+ })671
+ .handle_refresh_tokens()672
+ .cross_process_store_config(CrossProcessLockConfig::SingleProcess);673
+ match &store.location {674
+ #[cfg(feature = "sqlite")]675
+ StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),676
+ #[cfg(feature = "indexeddb")]677
+ StoreLocation::IndexedDb(name) => builder.indexeddb_store(name, Some(&store.passphrase)),678
+ }679
+}680
+681
+pub(crate) fn account_error(682
+ context: &'static str,683
+ source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,684
+) -> AccountError {685
+ AccountError::Operation {686
+ context,687
+ source: Box::new(source),688
+ }689
+}@@ -0,0 +1,903 @@
1
+use std::sync::Arc;2
+3
+use futures_util::StreamExt;4
+use matrix_sdk::executor::{JoinHandle, spawn};5
+use matrix_sdk::{6
+ Client,7
+ encryption::{8
+ VerificationState,9
+ backups::BackupState,10
+ recovery::RecoveryState as SdkRecoveryState,11
+ verification::{12
+ SasState, SasVerification, Verification, VerificationRequest, VerificationRequestState,13
+ },14
+ },15
+ ruma::events::key::verification::VerificationMethod,16
+};17
+use ruma::{OwnedDeviceId, UserId};18
+use tokio::sync::Mutex;19
+20
+use crate::{21
+ EncryptionState, Error, ErrorKind, Event, KeyBackupState, RecoveryState, VerificationAction,22
+ VerificationState as UiState, VerificationTarget, model::VerificationFailure,23
+ transport::EventSender,24
+};25
+26
+pub(crate) fn encryption_state(27
+ device_id: OwnedDeviceId,28
+ recovery: SdkRecoveryState,29
+ verification: VerificationState,30
+ backup: BackupState,31
+) -> EncryptionState {32
+ EncryptionState {33
+ device_id,34
+ device_verified: matches!(verification, VerificationState::Verified),35
+ recovery: match recovery {36
+ SdkRecoveryState::Unknown => RecoveryState::Unknown,37
+ SdkRecoveryState::Disabled => RecoveryState::Disabled,38
+ SdkRecoveryState::Incomplete => RecoveryState::Incomplete,39
+ SdkRecoveryState::Enabled => RecoveryState::Enabled,40
+ },41
+ key_backup: match backup {42
+ BackupState::Unknown => KeyBackupState::Unknown,43
+ BackupState::Creating => KeyBackupState::Creating,44
+ BackupState::Enabling => KeyBackupState::Enabling,45
+ BackupState::Resuming => KeyBackupState::Resuming,46
+ BackupState::Enabled => KeyBackupState::Enabled,47
+ BackupState::Downloading => KeyBackupState::Downloading,48
+ BackupState::Disabling => KeyBackupState::Disabling,49
+ },50
+ }51
+}52
+53
+#[derive(Default)]54
+pub(crate) struct VerificationController {55
+ active: Arc<Mutex<VerificationSession>>,56
+ task: Option<JoinHandle<()>>,57
+}58
+59
+#[derive(Default)]60
+struct VerificationSession {61
+ request: Option<VerificationRequest>,62
+ sas: Option<SasVerification>,63
+}64
+65
+impl VerificationController {66
+ pub(crate) async fn apply(67
+ &mut self,68
+ sender: EventSender,69
+ action: VerificationAction,70
+ ) -> Result<(), Error> {71
+ match action {72
+ VerificationAction::Accept => self.accept().await,73
+ VerificationAction::Confirm => {74
+ // Retain a handle across the operation: the watcher clears75
+ // `active` when it publishes a terminal event, but this clone76
+ // still observes the shared SDK state afterwards.77
+ let sas = self.active.lock().await.sas.clone();78
+ match self.confirm().await {79
+ Ok(()) => Ok(()),80
+ Err(error) if error.kind == ErrorKind::VerificationInactive => Err(error),81
+ // The confirm advanced local SDK state before its send failed,82
+ // so the retained session could report success on retry without83
+ // network traffic. Best-effort cancel (no lock held across the84
+ // network), clear for restart, then report the terminal state.85
+ Err(error) => {86
+ // Stop the watcher first so it cannot emit a stale87
+ // Confirmed from the local transition we are unwinding.88
+ self.stop_task().await;89
+ // The peer can complete or cancel the verification90
+ // while our own Done send fails. The stopped watcher may91
+ // already have published that terminal outcome (losing the92
+ // claim below); otherwise this path owns it and must emit93
+ // it instead of regressing to Cancelled.94
+ if sas.as_ref().is_some_and(|sas| sas.is_done()) {95
+ if take_verification(&self.active).await {96
+ let _ =97
+ sender.send(Event::VerificationChanged(UiState::Done)).await;98
+ }99
+ return Err(error);100
+ }101
+ if let Some(reason) = sas102
+ .as_ref()103
+ .and_then(|sas| sas.cancel_info())104
+ .map(|info| info.reason().to_owned())105
+ {106
+ if take_verification(&self.active).await {107
+ let _ = sender108
+ .send(Event::VerificationChanged(UiState::Cancelled(reason)))109
+ .await;110
+ }111
+ return Err(error);112
+ }113
+ if let Some(sas) = sas {114
+ if sas.cancel().await.is_err() {115
+ // The best-effort unwind already advanced116
+ // local state; clearing here keeps the failed117
+ // cancellation from looking retryable. The118
+ // send is owned only if the watcher has not119
+ // already claimed the session for its own120
+ // terminal outcome.121
+ if take_verification(&self.active).await {122
+ let _ = sender123
+ .send(Event::VerificationChanged(UiState::Cancelled(124
+ error.to_string(),125
+ )))126
+ .await;127
+ }128
+ return Err(error);129
+ }130
+ // The peer can complete while the unwind cancel is in131
+ // flight and the SDK call no-ops; the Done then owns the132
+ // terminal outcome, so report it instead of attributing133
+ // it to the send failure.134
+ if sas.is_done() {135
+ if take_verification(&self.active).await {136
+ let _ = sender137
+ .send(Event::VerificationChanged(UiState::Done))138
+ .await;139
+ }140
+ return Err(error);141
+ }142
+ if let Some(info) = sas.cancel_info() {143
+ // Our own unwind cancel just marked the flow; it does144
+ // not own the outcome yet, so fall through and report145
+ // the send failure as Cancelled below. Only a peer146
+ // cancellation racing the unwind owns it instead.147
+ if !info.cancelled_by_us() {148
+ if take_verification(&self.active).await {149
+ let _ = sender150
+ .send(Event::VerificationChanged(UiState::Cancelled(151
+ info.reason().to_owned(),152
+ )))153
+ .await;154
+ }155
+ return Err(Error::new(156
+ ErrorKind::VerificationInactive,157
+ info.reason().to_owned(),158
+ ));159
+ }160
+ }161
+ }162
+ if take_verification(&self.active).await {163
+ let _ = sender164
+ .send(Event::VerificationChanged(UiState::Cancelled(165
+ error.to_string(),166
+ )))167
+ .await;168
+ }169
+ Err(error)170
+ }171
+ }172
+ }173
+ VerificationAction::Reject => self.reject().await,174
+ VerificationAction::Cancel => {175
+ // The SDK no-ops a cancel of an already-settled flow, so a176
+ // failed claim inside `cancel` already reported the true177
+ // outcome as an error and owns no event here. Only the claim178
+ // winner emits the local Cancelled.179
+ let claimed = self.cancel_claimed(Some(&sender)).await?;180
+ if claimed {181
+ sender182
+ .send(Event::VerificationChanged(UiState::Cancelled(183
+ "cancelled by this client".to_owned(),184
+ )))185
+ .await186
+ .map_err(|source| {187
+ Error::with_source(188
+ ErrorKind::Interrupted,189
+ "event receiver has closed",190
+ source,191
+ )192
+ })?;193
+ }194
+ Ok(())195
+ }196
+ }197
+ }198
+199
+ pub(crate) async fn start(200
+ &mut self,201
+ client: &Client,202
+ sender: EventSender,203
+ target: VerificationTarget,204
+ ) -> Result<(), Error> {205
+ client206
+ .encryption()207
+ .wait_for_e2ee_initialization_tasks()208
+ .await;209
+ let own_user_id = client.user_id().ok_or_else(|| {210
+ Error::new(211
+ ErrorKind::Unauthenticated,212
+ "the Matrix client is not authenticated",213
+ )214
+ })?;215
+ if self.has_active_verification().await {216
+ self.cancel().await?;217
+ } else {218
+ self.stop_task().await;219
+ }220
+ let request = match &target {221
+ VerificationTarget::OwnIdentity => {222
+ request_identity_verification(client, own_user_id).await?223
+ }224
+ VerificationTarget::User(user_id) => {225
+ request_identity_verification(client, user_id).await?226
+ }227
+ VerificationTarget::Device { user_id, device_id } => {228
+ let device = client229
+ .encryption()230
+ .get_device(user_id, device_id)231
+ .await232
+ .map_err(|source| {233
+ Error::with_source(234
+ ErrorKind::Encryption,235
+ "reading the verification device failed",236
+ source,237
+ )238
+ })?239
+ .ok_or_else(|| {240
+ Error::new(241
+ ErrorKind::VerificationUnsupported,242
+ "the verification device is unavailable",243
+ )244
+ })?;245
+ device246
+ .request_verification_with_methods(vec![VerificationMethod::SasV1])247
+ .await248
+ .map_err(|source| {249
+ Error::from_sdk("starting device verification failed", source)250
+ })?251
+ }252
+ };253
+ // subscribed before anything can wait on the event channel, or a quick peer's254
+ // answer lands while we're blocked and nobody ever hears it255
+ let changes = request.changes();256
+ self.active.lock().await.request = Some(request.clone());257
+ sender258
+ .send(Event::VerificationTargetChanged(target))259
+ .await260
+ .map_err(|source| {261
+ Error::with_source(ErrorKind::Interrupted, "event receiver has closed", source)262
+ })?;263
+ sender264
+ .send(Event::VerificationChanged(UiState::Waiting))265
+ .await266
+ .map_err(|source| {267
+ Error::with_source(ErrorKind::Interrupted, "event receiver has closed", source)268
+ })?;269
+ let active = self.active.clone();270
+ self.task = Some(spawn(async move {271
+ follow_request(request, changes, active, sender).await;272
+ }));273
+ Ok(())274
+ }275
+276
+ /// another device asked us. only taken when nothing else is in flight, and not accepted277
+ /// until the user says so with `VerificationAction::Accept`278
+ pub(crate) async fn adopt_incoming(279
+ &mut self,280
+ sender: EventSender,281
+ request: VerificationRequest,282
+ ) {283
+ if request.we_started() || request.is_done() || request.cancel_info().is_some() {284
+ return;285
+ }286
+ if self.has_active_verification().await {287
+ tracing::debug!("ignoring a verification request while another is in flight");288
+ return;289
+ }290
+ self.stop_task().await;291
+ let changes = request.changes();292
+ self.active.lock().await.request = Some(request.clone());293
+ let incoming = UiState::Incoming {294
+ user_id: request.other_user_id().to_owned(),295
+ device_id: match request.state() {296
+ VerificationRequestState::Requested {297
+ other_device_data, ..298
+ }299
+ | VerificationRequestState::Ready {300
+ other_device_data, ..301
+ } => Some(other_device_data.device_id().to_owned()),302
+ _ => None,303
+ },304
+ };305
+ let target = VerificationTarget::User(request.other_user_id().to_owned());306
+ for event in [307
+ Event::VerificationTargetChanged(target),308
+ Event::VerificationChanged(incoming),309
+ ] {310
+ if sender.send(event).await.is_err() {311
+ return;312
+ }313
+ }314
+ let active = self.active.clone();315
+ self.task = Some(spawn(async move {316
+ follow_request(request, changes, active, sender).await;317
+ }));318
+ }319
+320
+ async fn accept(&self) -> Result<(), Error> {321
+ let inactive = |message: &str| Error::new(ErrorKind::VerificationInactive, message);322
+ let (request, sas) = {323
+ let active = self.active.lock().await;324
+ (active.request.clone(), active.sas.is_some())325
+ };326
+ let request = request.ok_or_else(|| inactive("no verification request is pending"))?;327
+ if request.we_started() {328
+ return Err(inactive("nobody asked us, we asked them"));329
+ }330
+ if request.is_done() {331
+ return Err(inactive("verification is already complete"));332
+ }333
+ if let Some(info) = request.cancel_info() {334
+ return Err(inactive(info.reason()));335
+ }336
+ if sas {337
+ return Err(inactive("verification has already started"));338
+ }339
+ request340
+ .accept_with_methods(vec![VerificationMethod::SasV1])341
+ .await342
+ .map_err(|source| Error::from_sdk("accepting verification request failed", source))343
+ }344
+345
+ async fn confirm(&self) -> Result<(), Error> {346
+ let sas = self.active.lock().await.sas.clone().ok_or_else(|| {347
+ Error::new(348
+ ErrorKind::VerificationInactive,349
+ "verification has not reached comparison",350
+ )351
+ })?;352
+ // Report a settled flow truthfully instead of as "not reached353
+ // comparison": the SDK would no-op the confirm with success.354
+ if sas.is_done() {355
+ return Err(Error::new(356
+ ErrorKind::VerificationInactive,357
+ "verification is already complete",358
+ ));359
+ }360
+ if let Some(info) = sas.cancel_info() {361
+ return Err(Error::new(362
+ ErrorKind::VerificationInactive,363
+ info.reason().to_owned(),364
+ ));365
+ }366
+ // Only the short-auth-string stage is confirmable. The SDK reports the367
+ // remote-MAC-arrived state through the same presentable flag, while an368
+ // early confirm would locally no-op and falsely report success.369
+ if !sas.can_be_presented() {370
+ return Err(Error::new(371
+ ErrorKind::VerificationInactive,372
+ "verification has not reached comparison",373
+ ));374
+ }375
+ // Our own confirm can move the flow to Done, so only Done that predates376
+ // the send means the SDK no-op'd on a stale session; a cancellation377
+ // racing the send must not report success.378
+ let was_done = sas.is_done();379
+ sas.confirm()380
+ .await381
+ .map_err(|source| Error::from_sdk("confirming verification failed", source))?;382
+ if sas.is_done() {383
+ if was_done {384
+ return Err(Error::new(385
+ ErrorKind::VerificationInactive,386
+ "verification is already complete",387
+ ));388
+ }389
+ } else if sas.is_cancelled() {390
+ let reason = sas391
+ .cancel_info()392
+ .map(|info| info.reason().to_owned())393
+ .unwrap_or_else(|| "verification was cancelled".to_owned());394
+ return Err(Error::new(ErrorKind::VerificationInactive, reason));395
+ }396
+ Ok(())397
+ }398
+399
+ async fn reject(&self) -> Result<(), Error> {400
+ let sas = self.active.lock().await.sas.clone().ok_or_else(|| {401
+ Error::new(402
+ ErrorKind::VerificationInactive,403
+ "verification has not reached comparison",404
+ )405
+ })?;406
+ // Report a settled flow truthfully instead of as "not reached407
+ // comparison": the SDK would no-op the mismatch with success.408
+ if sas.is_done() {409
+ return Err(Error::new(410
+ ErrorKind::VerificationInactive,411
+ "verification is already complete",412
+ ));413
+ }414
+ if let Some(info) = sas.cancel_info() {415
+ return Err(Error::new(416
+ ErrorKind::VerificationInactive,417
+ info.reason().to_owned(),418
+ ));419
+ }420
+ // Rejecting before the comparison stage would cancel a flow the user421
+ // never compared, so it requires the same presentable state.422
+ if !sas.can_be_presented() {423
+ return Err(Error::new(424
+ ErrorKind::VerificationInactive,425
+ "verification has not reached comparison",426
+ ));427
+ }428
+ sas.mismatch()429
+ .await430
+ .map_err(|source| Error::from_sdk("rejecting verification failed", source))?;431
+ // A peer completing the flow concurrently must not be reported as432
+ // rejected. A remote cancellation racing the send turns the SDK call433
+ // into a no-op success, so report it instead of a false success.434
+ if sas.is_done() {435
+ return Err(Error::new(436
+ ErrorKind::VerificationInactive,437
+ "verification is already complete",438
+ ));439
+ }440
+ if let Some(info) = sas.cancel_info() {441
+ // Our own mismatch just marked the flow cancelled-by-us; that is the442
+ // expected reject path and stays Ok. Only a peer cancel reports an443
+ // error.444
+ if !info.cancelled_by_us() {445
+ return Err(Error::new(446
+ ErrorKind::VerificationInactive,447
+ info.reason().to_owned(),448
+ ));449
+ }450
+ }451
+ Ok(())452
+ }453
+ pub(crate) async fn cancel(&mut self) -> Result<(), Error> {454
+ self.cancel_claimed(None).await.map(|_| ())455
+ }456
+457
+ /// Return whether this action won the claim for a local cancellation.458
+ /// The optional sender reports a peer terminal outcome when it races after459
+ /// the claim; starting a replacement verification does not publish it.460
+ async fn cancel_claimed(&mut self, sender: Option<&EventSender>) -> Result<bool, Error> {461
+ let (sas, request) = {462
+ let active = self.active.lock().await;463
+ (active.sas.clone(), active.request.clone())464
+ };465
+ if sas.is_none() && request.is_none() {466
+ return Err(Error::new(467
+ ErrorKind::VerificationInactive,468
+ "no verification is active",469
+ ));470
+ }471
+ // The watcher owns terminal emission and claims the session when it472
+ // publishes. If the SDK is already terminal, the watcher either already473
+ // reported it or is about to: leave session and watcher alone so the474
+ // outcome is emitted exactly once, and report it instead of letting the475
+ // SDK no-op success regress Done to "cancelled by this client".476
+ if let Some(sas) = sas.as_ref() {477
+ if sas.is_done() {478
+ return Err(Error::new(479
+ ErrorKind::VerificationInactive,480
+ "verification is already complete",481
+ ));482
+ }483
+ if let Some(info) = sas.cancel_info() {484
+ return Err(Error::new(485
+ ErrorKind::VerificationInactive,486
+ info.reason().to_owned(),487
+ ));488
+ }489
+ } else if let Some(request) = request.as_ref() {490
+ if request.is_done() {491
+ return Err(Error::new(492
+ ErrorKind::VerificationInactive,493
+ "verification is already complete",494
+ ));495
+ }496
+ if let Some(info) = request.cancel_info() {497
+ return Err(Error::new(498
+ ErrorKind::VerificationInactive,499
+ info.reason().to_owned(),500
+ ));501
+ }502
+ }503
+ // Own the terminal outcome from here: claim the session before504
+ // stopping the watcher so a watcher that already claimed (and will505
+ // emit its own terminal event) turns this into a truthful error506
+ // instead of a duplicate local cancel; no lock is held across sends.507
+ if !take_verification(&self.active).await {508
+ // Re-check the SDK: the claim loss may be a stale session after a509
+ // terminal outcome, or a concurrent start of a new flow. Only the510
+ // terminal case reports here; a live session belongs to the new511
+ // flow and must not be disturbed.512
+ if let Some(sas) = sas.as_ref() {513
+ if sas.is_done() {514
+ return Err(Error::new(515
+ ErrorKind::VerificationInactive,516
+ "verification is already complete",517
+ ));518
+ }519
+ if let Some(info) = sas.cancel_info() {520
+ return Err(Error::new(521
+ ErrorKind::VerificationInactive,522
+ info.reason().to_owned(),523
+ ));524
+ }525
+ } else if let Some(request) = request.as_ref() {526
+ if request.is_done() {527
+ return Err(Error::new(528
+ ErrorKind::VerificationInactive,529
+ "verification is already complete",530
+ ));531
+ }532
+ if let Some(info) = request.cancel_info() {533
+ return Err(Error::new(534
+ ErrorKind::VerificationInactive,535
+ info.reason().to_owned(),536
+ ));537
+ }538
+ }539
+ return Err(Error::new(540
+ ErrorKind::VerificationInactive,541
+ "no verification is active",542
+ ));543
+ }544
+ self.stop_task().await;545
+ if let Some(sas) = sas {546
+ let result = sas.cancel().await;547
+ if sas.is_done() {548
+ report_claimed_terminal(sender, UiState::Done).await;549
+ return Err(Error::new(550
+ ErrorKind::VerificationInactive,551
+ "verification is already complete",552
+ ));553
+ }554
+ if let Some(info) = sas.cancel_info()555
+ && !info.cancelled_by_us()556
+ {557
+ let reason = info.reason().to_owned();558
+ report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;559
+ return Err(Error::new(ErrorKind::VerificationInactive, reason));560
+ }561
+ if let Err(source) = result {562
+ let error = Error::from_sdk("cancelling verification failed", source);563
+ report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;564
+ return Err(error);565
+ }566
+ Ok(true)567
+ } else if let Some(request) = request {568
+ let result = request.cancel().await;569
+ if request.is_done() {570
+ report_claimed_terminal(sender, UiState::Done).await;571
+ return Err(Error::new(572
+ ErrorKind::VerificationInactive,573
+ "verification is already complete",574
+ ));575
+ }576
+ if let Some(info) = request.cancel_info()577
+ && !info.cancelled_by_us()578
+ {579
+ let reason = info.reason().to_owned();580
+ report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;581
+ return Err(Error::new(ErrorKind::VerificationInactive, reason));582
+ }583
+ if let Err(source) = result {584
+ let error = Error::from_sdk("cancelling verification failed", source);585
+ report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;586
+ return Err(error);587
+ }588
+ Ok(true)589
+ } else {590
+ Ok(false)591
+ }592
+ }593
+594
+ async fn has_active_verification(&self) -> bool {595
+ let active = self.active.lock().await;596
+ active.request.is_some() || active.sas.is_some()597
+ }598
+599
+ async fn stop_task(&mut self) {600
+ if let Some(task) = self.task.take() {601
+ task.abort();602
+ let _ = task.await;603
+ }604
+ }605
+}606
+607
+/// shutdown forgets the flow without telling the sdk, a network send could hang it608
+impl Drop for VerificationController {609
+ fn drop(&mut self) {610
+ if let Some(task) = self.task.take() {611
+ task.abort();612
+ }613
+ }614
+}615
+616
+async fn request_identity_verification(617
+ client: &Client,618
+ user_id: &UserId,619
+) -> Result<VerificationRequest, Error> {620
+ let identity = client621
+ .encryption()622
+ .request_user_identity(user_id)623
+ .await624
+ .map_err(|source| Error::from_sdk("requesting the user identity failed", source))?625
+ .ok_or_else(|| {626
+ Error::new(627
+ ErrorKind::NoCrossSigningIdentity,628
+ "the user has no cross-signing identity",629
+ )630
+ })?;631
+ identity632
+ .request_verification_with_methods(vec![VerificationMethod::SasV1])633
+ .await634
+ .map_err(|source| Error::from_verification_request("starting verification failed", source))635
+}636
+637
+/// `changes` is subscribed by the caller before the request could move, and the current state638
+/// is handled first, since the stream only reports what happens after it was taken639
+async fn follow_request(640
+ request: VerificationRequest,641
+ changes: impl futures_util::Stream<Item = VerificationRequestState> + Unpin,642
+ active: Arc<Mutex<VerificationSession>>,643
+ sender: EventSender,644
+) {645
+ let mut states = futures_util::stream::iter([request.state()]).chain(changes);646
+ while let Some(state) = states.next().await {647
+ match state {648
+ // created is ours waiting on them, requested is theirs waiting on `Accept`649
+ VerificationRequestState::Created { .. }650
+ | VerificationRequestState::Requested { .. } => {}651
+ VerificationRequestState::Ready { .. } => match request.start_sas().await {652
+ Ok(Some(sas)) => {653
+ follow_sas(sas, active.clone(), sender.clone()).await;654
+ return;655
+ }656
+ Ok(None) => {}657
+ Err(error) => {658
+ tracing::warn!(error = %error, "starting SAS verification failed");659
+ // Claim the session so this terminal outcome is emitted exactly660
+ // once; a lost claim means an action path already owns it.661
+ report_watcher_terminal(662
+ &active,663
+ &sender,664
+ UiState::Failed(VerificationFailure::SasStartup(error.to_string())),665
+ "SAS startup failure",666
+ )667
+ .await;668
+ return;669
+ }670
+ },671
+ VerificationRequestState::Transitioned { verification } => {672
+ if let Verification::SasV1(sas) = verification {673
+ follow_sas(sas, active.clone(), sender.clone()).await;674
+ } else {675
+ report_watcher_terminal(676
+ &active,677
+ &sender,678
+ UiState::Cancelled(679
+ "the other device selected an unsupported method".into(),680
+ ),681
+ "unsupported verification method",682
+ )683
+ .await;684
+ }685
+ return;686
+ }687
+ VerificationRequestState::Done => {688
+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;689
+ return;690
+ }691
+ VerificationRequestState::Cancelled(info) => {692
+ report_watcher_terminal(693
+ &active,694
+ &sender,695
+ UiState::Cancelled(info.reason().to_owned()),696
+ "cancellation",697
+ )698
+ .await;699
+ return;700
+ }701
+ }702
+ }703
+ tracing::debug!("verification request watcher ended unexpectedly");704
+ // The stream may end after the SDK already settled without delivering the705
+ // terminal update; report the settled outcome instead of StreamEnded, and706
+ // stay silent if an action path already claimed the session.707
+ if request.is_done() {708
+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;709
+ } else if let Some(info) = request.cancel_info() {710
+ let reason = info.reason().to_owned();711
+ report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;712
+ } else {713
+ report_watcher_terminal(714
+ &active,715
+ &sender,716
+ UiState::Failed(VerificationFailure::RequestStreamEnded),717
+ "request stream termination",718
+ )719
+ .await;720
+ }721
+}722
+723
+async fn follow_sas(724
+ sas: SasVerification,725
+ active: Arc<Mutex<VerificationSession>>,726
+ sender: EventSender,727
+) {728
+ // the sdk may already have moved the flow along before we got here, a peer starting729
+ // sas is the usual case, so take the stream first and the current state after730
+ let changes = sas.changes();731
+ active.lock().await.sas = Some(sas.clone());732
+ let mut states = futures_util::stream::iter([sas.state()]).chain(changes);733
+ while let Some(state) = states.next().await {734
+ let update = match state {735
+ SasState::Created { .. } | SasState::Accepted { .. } => continue,736
+ SasState::Started { .. } => {737
+ if let Err(error) = sas.accept().await {738
+ tracing::warn!(error = %error, "accepting SAS verification failed");739
+ // Claim the session so this terminal outcome is emitted exactly740
+ // once; a lost claim means an action path already owns it.741
+ report_watcher_terminal(742
+ &active,743
+ &sender,744
+ UiState::Failed(VerificationFailure::SasAcceptance(error.to_string())),745
+ "SAS acceptance failure",746
+ )747
+ .await;748
+ return;749
+ }750
+ continue;751
+ }752
+ SasState::KeysExchanged { emojis, decimals } => UiState::Comparison {753
+ emojis: emojis754
+ .map(|short_auth| {755
+ short_auth756
+ .emojis757
+ .into_iter()758
+ .map(|emoji| (emoji.symbol.to_owned(), emoji.description.to_owned()))759
+ .collect()760
+ })761
+ .unwrap_or_default(),762
+ decimals,763
+ },764
+ SasState::Confirmed => UiState::Confirmed,765
+ SasState::Done { .. } => UiState::Done,766
+ SasState::Cancelled(info) => UiState::Cancelled(info.reason().to_owned()),767
+ };768
+ let terminal = matches!(update, UiState::Done | UiState::Cancelled(_));769
+ // Claim the session before the terminal send so a racing Cancel observes770
+ // no active session and the SDK no-op cannot regress Done back to771
+ // "cancelled by this client". A lost claim means an action path already772
+ // owns the terminal outcome, so stay silent instead of duplicating it.773
+ if terminal && !take_verification(&active).await {774
+ return;775
+ }776
+ if sender777
+ .send(Event::VerificationChanged(update))778
+ .await779
+ .is_err()780
+ {781
+ tracing::debug!("SAS watcher stopped because the event receiver closed");782
+ return;783
+ }784
+ if terminal {785
+ return;786
+ }787
+ }788
+ tracing::debug!("SAS watcher ended unexpectedly");789
+ // The stream may end after the SDK already settled without delivering the790
+ // terminal update; report the settled outcome instead of StreamEnded, and791
+ // stay silent if an action path already claimed the session.792
+ if sas.is_done() {793
+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;794
+ } else if let Some(info) = sas.cancel_info() {795
+ let reason = info.reason().to_owned();796
+ report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;797
+ } else {798
+ report_watcher_terminal(799
+ &active,800
+ &sender,801
+ UiState::Failed(VerificationFailure::SasStreamEnded),802
+ "SAS stream termination",803
+ )804
+ .await;805
+ }806
+}807
+808
+async fn report_watcher_terminal(809
+ active: &Arc<Mutex<VerificationSession>>,810
+ sender: &EventSender,811
+ state: UiState,812
+ context: &'static str,813
+) {814
+ if take_verification(active).await815
+ && sender816
+ .send(Event::VerificationChanged(state))817
+ .await818
+ .is_err()819
+ {820
+ tracing::debug!(821
+ context,822
+ "verification watcher could not report terminal outcome"823
+ );824
+ }825
+}826
+827
+async fn report_claimed_terminal(sender: Option<&EventSender>, state: UiState) {828
+ if let Some(sender) = sender {829
+ let _ = sender.send(Event::VerificationChanged(state)).await;830
+ }831
+}832
+833
+/// Claim the active session for terminal emission, if one is still present.834
+///835
+/// Exactly one party (watcher or action path) wins the claim and may emit the836
+/// terminal event; the loser stays silent so Done is never overwritten by a837
+/// later local Cancelled/Failed.838
+async fn take_verification(active: &Arc<Mutex<VerificationSession>>) -> bool {839
+ let mut active = active.lock().await;840
+ let had = active.sas.is_some() || active.request.is_some();841
+ active.sas.take();842
+ active.request.take();843
+ had844
+}845
+846
+#[cfg(test)]847
+mod tests {848
+ use matrix_sdk::encryption::VerificationState;849
+ use matrix_sdk::encryption::backups::BackupState;850
+ use ruma::owned_device_id;851
+852
+ use super::*;853
+854
+ #[test]855
+ fn sdk_security_state_is_normalized() {856
+ assert_eq!(857
+ encryption_state(858
+ owned_device_id!("DEVICE"),859
+ SdkRecoveryState::Incomplete,860
+ VerificationState::Unverified,861
+ BackupState::Unknown,862
+ ),863
+ EncryptionState {864
+ device_id: owned_device_id!("DEVICE"),865
+ device_verified: false,866
+ recovery: RecoveryState::Incomplete,867
+ key_backup: KeyBackupState::Unknown,868
+ }869
+ );870
+ assert_eq!(871
+ encryption_state(872
+ owned_device_id!("DEVICE"),873
+ SdkRecoveryState::Enabled,874
+ VerificationState::Verified,875
+ BackupState::Enabled,876
+ ),877
+ EncryptionState {878
+ device_id: owned_device_id!("DEVICE"),879
+ device_verified: true,880
+ recovery: RecoveryState::Enabled,881
+ key_backup: KeyBackupState::Enabled,882
+ }883
+ );884
+ }885
+886
+ #[tokio::test]887
+ async fn comparison_actions_require_an_active_sas() {888
+ let mut controller = VerificationController::default();889
+890
+ assert_eq!(891
+ controller.confirm().await.unwrap_err().to_string(),892
+ "verification has not reached comparison"893
+ );894
+ assert_eq!(895
+ controller.reject().await.unwrap_err().to_string(),896
+ "verification has not reached comparison"897
+ );898
+ assert_eq!(899
+ controller.cancel().await.unwrap_err().to_string(),900
+ "no verification is active"901
+ );902
+ }903
+}@@ -0,0 +1,329 @@
1
+use std::{future::Future, panic::AssertUnwindSafe};2
+3
+use futures_util::FutureExt;4
+use matrix_sdk::encryption::verification::VerificationRequest;5
+use matrix_sdk::{6
+ Client,7
+ notification_settings::{IsEncrypted, IsOneToOne, RoomNotificationMode as SdkMode},8
+ ruma::{9
+ OwnedDeviceId, OwnedEventId, RoomId, TransactionId,10
+ api::client::receipt::create_receipt::v3::ReceiptType, events::receipt::ReceiptThread,11
+ },12
+ send_queue::{LocalEchoContent, SendHandle},13
+};14
+use tokio::sync::mpsc;15
+16
+use crate::{17
+ EncryptionCommand, Error, ErrorKind, InteractiveAuth, InteractiveAuthChallenge,18
+ NotificationCommand, RoomNotificationMode, TimelineId,19
+ encryption::VerificationController,20
+ transport::{EventSender, Responder},21
+};22
+23
+pub(super) struct Job<T> {24
+ pub(super) command: T,25
+ pub(super) reply: Responder,26
+}27
+28
+/// a panicking command answers with an error instead of taking its worker down with it29
+pub(super) async fn guarded<T>(30
+ label: &'static str,31
+ work: impl Future<Output = Result<T, Error>>,32
+) -> Result<T, Error> {33
+ AssertUnwindSafe(work)34
+ .catch_unwind()35
+ .await36
+ .unwrap_or_else(|_| Err(Error::new(ErrorKind::Other, format!("{label} panicked"))))37
+}38
+39
+pub(super) async fn run_notifications(40
+ client: Client,41
+ mut jobs: mpsc::Receiver<Job<NotificationCommand>>,42
+) {43
+ while let Some(Job { command, reply }) = jobs.recv().await {44
+ reply.done(guarded("notification command", notification(&client, command)).await);45
+ }46
+}47
+48
+pub(super) async fn run_encryption(49
+ client: Client,50
+ events: EventSender,51
+ mut jobs: mpsc::Receiver<Job<EncryptionCommand>>,52
+ mut incoming: mpsc::UnboundedReceiver<VerificationRequest>,53
+) {54
+ let mut verification = VerificationController::default();55
+ loop {56
+ tokio::select! {57
+ job = jobs.recv() => {58
+ let Some(Job { command, reply }) = job else { return };59
+ let work = encryption(&client, &events, &mut verification, command);60
+ reply.done(guarded("encryption command", work).await);61
+ }62
+ Some(request) = incoming.recv() => {63
+ verification.adopt_incoming(events.clone(), request).await;64
+ }65
+ }66
+ }67
+}68
+69
+async fn encryption(70
+ client: &Client,71
+ events: &EventSender,72
+ verification: &mut VerificationController,73
+ command: EncryptionCommand,74
+) -> Result<(), Error> {75
+ let encryption = client.encryption();76
+ match command {77
+ EncryptionCommand::Recover(key) => encryption78
+ .recovery()79
+ .recover(&key)80
+ .await81
+ .map_err(|source| Error::from_recovery("account recovery failed", source)),82
+ EncryptionCommand::RecoverKeyBackup(key) => encryption83
+ .recovery()84
+ .recover_and_fix_backup(&key)85
+ .await86
+ .map_err(|source| Error::from_recovery("recovering key backup failed", source)),87
+ EncryptionCommand::CreateKeyBackup => encryption88
+ .backups()89
+ .create()90
+ .await91
+ .map_err(|source| Error::from_sdk("creating key backup failed", source)),92
+ EncryptionCommand::EnableKeyBackup => encryption93
+ .recovery()94
+ .enable_backup()95
+ .await96
+ .map_err(|source| Error::from_recovery("enabling key backup failed", source)),97
+ EncryptionCommand::DisableKeyBackup { delete_from_server } => {98
+ let backups = encryption.backups();99
+ if delete_from_server {100
+ backups.disable_and_delete().await101
+ } else {102
+ backups.disable().await103
+ }104
+ .map_err(|source| Error::from_sdk("disabling key backup failed", source))105
+ }106
+ EncryptionCommand::StartVerification(target) => {107
+ verification.start(client, events.clone(), target).await108
+ }109
+ EncryptionCommand::Verify(action) => verification.apply(events.clone(), action).await,110
+ }111
+}112
+113
+async fn notification(client: &Client, command: NotificationCommand) -> Result<(), Error> {114
+ let settings = client.notification_settings().await;115
+ match command {116
+ NotificationCommand::SetRoomMode { room_id, mode } => {117
+ settings118
+ .set_room_notification_mode(&room_id, sdk_mode(mode))119
+ .await120
+ }121
+ NotificationCommand::ResetRoomMode(room_id) => {122
+ settings.delete_user_defined_room_rules(&room_id).await123
+ }124
+ NotificationCommand::SetDefaultMode {125
+ encrypted,126
+ one_to_one,127
+ mode,128
+ } => {129
+ settings130
+ .set_default_room_notification_mode(131
+ IsEncrypted::from(encrypted),132
+ IsOneToOne::from(one_to_one),133
+ sdk_mode(mode),134
+ )135
+ .await136
+ }137
+ NotificationCommand::AddKeyword(keyword) => settings.add_keyword(keyword).await,138
+ NotificationCommand::RemoveKeyword(keyword) => settings.remove_keyword(&keyword).await,139
+ }140
+ .map_err(|source| {141
+ Error::with_source(142
+ ErrorKind::Server,143
+ "updating notification settings failed",144
+ source,145
+ )146
+ })147
+}148
+149
+const fn sdk_mode(mode: RoomNotificationMode) -> SdkMode {150
+ match mode {151
+ RoomNotificationMode::AllMessages => SdkMode::AllMessages,152
+ RoomNotificationMode::MentionsAndKeywordsOnly => SdkMode::MentionsAndKeywordsOnly,153
+ RoomNotificationMode::Mute => SdkMode::Mute,154
+ }155
+}156
+157
+fn room(client: &Client, room_id: &RoomId) -> Result<matrix_sdk::Room, Error> {158
+ client.get_room(room_id).ok_or_else(Error::room_unknown)159
+}160
+161
+/// the closed-room path. without a timeline nothing stops the marker going backwards162
+pub(super) async fn mark_read(163
+ client: &Client,164
+ timeline_id: &TimelineId,165
+ event_id: OwnedEventId,166
+) -> Result<(), Error> {167
+ let room = room(client, timeline_id.room_id())?;168
+ let receipt_failed = |source| Error::from_sdk("sending read receipt failed", source);169
+ let thread = match timeline_id.thread_root() {170
+ None => ReceiptThread::Main,171
+ Some(root) => ReceiptThread::Thread(root.clone()),172
+ };173
+ room.send_single_receipt(ReceiptType::Read, thread, event_id.clone())174
+ .await175
+ .map_err(receipt_failed)?;176
+ if timeline_id.thread_root().is_none() {177
+ room.send_single_receipt(ReceiptType::FullyRead, ReceiptThread::Unthreaded, event_id)178
+ .await179
+ .map_err(receipt_failed)?;180
+ }181
+ Ok(())182
+}183
+184
+pub(super) async fn set_typing(185
+ client: &Client,186
+ room_id: &RoomId,187
+ typing: bool,188
+) -> Result<(), Error> {189
+ room(client, room_id)?190
+ .typing_notice(typing)191
+ .await192
+ .map_err(|source| Error::from_sdk("typing update failed", source))193
+}194
+195
+async fn send_handle(196
+ client: &Client,197
+ room_id: &RoomId,198
+ transaction_id: &TransactionId,199
+) -> Result<SendHandle, Error> {200
+ let (echoes, _) = room(client, room_id)?201
+ .send_queue()202
+ .subscribe()203
+ .await204
+ .map_err(|source| {205
+ Error::with_source(ErrorKind::Store, "reading send queue failed", source)206
+ })?;207
+ echoes208
+ .into_iter()209
+ .find_map(|echo| match echo.content {210
+ LocalEchoContent::Event { send_handle, .. }211
+ if echo.transaction_id.as_str() == transaction_id.as_str() =>212
+ {213
+ Some(send_handle)214
+ }215
+ _ => None,216
+ })217
+ .ok_or_else(|| {218
+ Error::new(219
+ ErrorKind::Invalid,220
+ "send is no longer pending or is not a message",221
+ )222
+ })223
+}224
+225
+pub(super) async fn cancel_send(226
+ client: &Client,227
+ room_id: &RoomId,228
+ transaction_id: &TransactionId,229
+) -> Result<(), Error> {230
+ match send_handle(client, room_id, transaction_id)231
+ .await?232
+ .abort()233
+ .await234
+ {235
+ Ok(true) => Ok(()),236
+ Ok(false) => Err(Error::new(237
+ ErrorKind::Invalid,238
+ "send could not be cancelled because it has already been sent",239
+ )),240
+ Err(source) => Err(Error::with_source(241
+ ErrorKind::Store,242
+ "cancelling send failed",243
+ source,244
+ )),245
+ }246
+}247
+248
+pub(super) async fn retry_send(249
+ client: &Client,250
+ room_id: &RoomId,251
+ transaction_id: &TransactionId,252
+) -> Result<(), Error> {253
+ send_handle(client, room_id, transaction_id)254
+ .await?255
+ .unwedge()256
+ .await257
+ .map_err(|source| Error::with_source(ErrorKind::Store, "retrying send failed", source))?;258
+ room(client, room_id)?.send_queue().set_enabled(true);259
+ Ok(())260
+}261
+262
+pub(super) async fn upload_avatar(263
+ client: &Client,264
+ mimetype: &str,265
+ bytes: Vec<u8>,266
+ max_bytes: usize,267
+) -> Result<(), Error> {268
+ if bytes.len() > max_bytes {269
+ return Err(Error::new(270
+ ErrorKind::MediaTooLarge,271
+ "avatar exceeds the configured byte limit",272
+ ));273
+ }274
+ let mimetype: mime::Mime = mimetype275
+ .parse()276
+ .map_err(|_| Error::new(ErrorKind::Invalid, "avatar MIME type is invalid"))?;277
+ if mimetype.type_() != mime::IMAGE {278
+ return Err(Error::new(279
+ ErrorKind::Invalid,280
+ "an avatar has to be an image",281
+ ));282
+ }283
+ client284
+ .account()285
+ .upload_avatar(&mimetype, bytes)286
+ .await287
+ .map(drop)288
+ .map_err(|source| Error::from_sdk("uploading avatar failed", source))289
+}290
+291
+pub(super) async fn rename_device(292
+ client: &Client,293
+ device_id: &OwnedDeviceId,294
+ display_name: &str,295
+) -> Result<(), Error> {296
+ client297
+ .rename_device(device_id, display_name)298
+ .await299
+ .map(drop)300
+ .map_err(|source| Error::from_http("renaming device failed", source))301
+}302
+303
+pub(super) async fn delete_devices(304
+ client: &Client,305
+ device_ids: &[OwnedDeviceId],306
+ auth: Option<InteractiveAuth>,307
+) -> Result<(), Error> {308
+ let user_id = client309
+ .user_id()310
+ .ok_or_else(|| Error::new(ErrorKind::Unauthenticated, "the client is not logged in"))?;311
+ let auth = auth312
+ .map(|auth| auth.into_sdk(user_id))313
+ .transpose()314
+ .map_err(|message| {315
+ Error::new(316
+ ErrorKind::Invalid,317
+ format!("interactive authentication data is invalid, {message}"),318
+ )319
+ })?;320
+ match client.delete_devices(device_ids, auth).await {321
+ Ok(_) => Ok(()),322
+ Err(source) => match source.as_uiaa_response() {323
+ Some(info) => Err(Error::interactive_auth(InteractiveAuthChallenge::from(324
+ info,325
+ ))),326
+ None => Err(Error::from_http("deleting devices failed", source)),327
+ },328
+ }329
+}@@ -0,0 +1,82 @@
1
+use std::future::Future;2
+3
+use futures_util::{StreamExt, stream::FuturesUnordered};4
+use matrix_sdk::SendOutsideWasm;5
+pub(super) use matrix_sdk::executor::{AbortHandle, JoinError, JoinHandle, spawn};6
+7
+#[cfg(not(target_family = "wasm"))]8
+pub(crate) type EngineBoxFuture<'a, T> = futures_util::future::BoxFuture<'a, T>;9
+#[cfg(target_family = "wasm")]10
+pub(crate) type EngineBoxFuture<'a, T> = futures_util::future::LocalBoxFuture<'a, T>;11
+12
+#[cfg(not(target_family = "wasm"))]13
+pub(super) fn is_panic(error: &JoinError) -> bool {14
+ error.is_panic()15
+}16
+17
+/// the sdk's wasm join error has no payload, and a real panic traps before it gets here18
+#[cfg(target_family = "wasm")]19
+pub(super) fn is_panic(error: &JoinError) -> bool {20
+ !error.is_cancelled()21
+}22
+23
+#[cfg(not(target_family = "wasm"))]24
+pub(super) fn resume_unwind(error: JoinError) -> ! {25
+ std::panic::resume_unwind(error.into_panic())26
+}27
+28
+#[cfg(target_family = "wasm")]29
+pub(super) fn resume_unwind(error: JoinError) -> ! {30
+ panic!("{error}")31
+}32
+33
+/// a `JoinSet` that runs on wasm too, and like it aborts whatever's left when dropped34
+pub(super) struct TaskSet<T: 'static> {35
+ handles: FuturesUnordered<JoinHandle<T>>,36
+}37
+38
+impl<T: 'static> TaskSet<T> {39
+ pub(super) fn new() -> Self {40
+ Self {41
+ handles: FuturesUnordered::new(),42
+ }43
+ }44
+45
+ pub(super) fn spawn(46
+ &mut self,47
+ task: impl Future<Output = T> + SendOutsideWasm + 'static,48
+ ) -> AbortHandle49
+ where50
+ T: SendOutsideWasm,51
+ {52
+ let handle = spawn(task);53
+ let abort = handle.abort_handle();54
+ self.handles.push(handle);55
+ abort56
+ }57
+58
+ pub(super) fn is_empty(&self) -> bool {59
+ self.handles.is_empty()60
+ }61
+62
+ pub(super) async fn join_next(&mut self) -> Option<Result<T, JoinError>> {63
+ self.handles.next().await64
+ }65
+66
+ fn abort_all(&self) {67
+ for handle in &self.handles {68
+ handle.abort();69
+ }70
+ }71
+72
+ pub(super) async fn shutdown(&mut self) {73
+ self.abort_all();74
+ while self.handles.next().await.is_some() {}75
+ }76
+}77
+78
+impl<T: 'static> Drop for TaskSet<T> {79
+ fn drop(&mut self) {80
+ self.abort_all();81
+ }82
+}@@ -0,0 +1,736 @@
1
+use std::{2
+ borrow::Cow,3
+ collections::{HashMap, VecDeque},4
+ io::Read as _,5
+ panic::AssertUnwindSafe,6
+ sync::Arc,7
+ time::Duration,8
+};9
+10
+use futures_util::{11
+ FutureExt, StreamExt,12
+ future::{Shared, WeakShared},13
+};14
+use matrix_sdk::{15
+ Client, Room, SendOutsideWasm, SyncOutsideWasm,16
+ media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings},17
+ room::reply::{EnforceThread, Reply as SdkReply},18
+ ruma::{19
+ api::{20
+ Metadata, OutgoingRequest,21
+ auth_scheme::SendAccessToken,22
+ client::{authenticated_media, media},23
+ },24
+ events::room::{25
+ EncryptedFileHash, EncryptedFileHashAlgorithm, EncryptedFileInfo, MediaSource,26
+ message::{AddMentions, ReplyWithinThread},27
+ },28
+ },29
+};30
+use matrix_sdk_crypto::{AttachmentDecryptor, MediaEncryptionInfo};31
+use ruma::MxcUri;32
+use tokio::sync::Mutex;33
+34
+use super::executor::EngineBoxFuture;35
+use crate::{36
+ AttachmentEncryption, AttachmentUpload, Error, ErrorKind, Media, MediaFile, MediaId, MediaKind,37
+ MediaLocation, MediaSize, TimelineId,38
+ normalise::{MediaRegistration, MediaScope},39
+ upload::prepare_upload,40
+};41
+42
+/// a stalled download gives up after this long without a byte43
+const DOWNLOAD_IDLE: Duration = Duration::from_secs(60);44
+/// the sdk client gives up on any request after thirty seconds, far too soon for a video45
+const DOWNLOAD_TOTAL: Duration = Duration::from_secs(60 * 60);46
+47
+type FlightFuture = EngineBoxFuture<'static, Result<Media, Error>>;48
+type Flight = Shared<FlightFuture>;49
+50
+/// owns everything needed to turn a media id or an mxc uri into bytes, so the51
+/// handle can answer media queries without a trip through the engine52
+pub(crate) struct MediaService {53
+ client: Client,54
+ registry: Mutex<MediaRegistry>,55
+ flights: Mutex<HashMap<String, WeakShared<FlightFuture>>>,56
+ max_bytes: usize,57
+}58
+59
+impl MediaService {60
+ pub(crate) fn new(client: Client, max_bytes: usize, max_sources: usize) -> Self {61
+ Self {62
+ client,63
+ registry: Mutex::new(MediaRegistry::new(max_sources)),64
+ flights: Mutex::new(HashMap::new()),65
+ max_bytes,66
+ }67
+ }68
+69
+ pub(crate) async fn register(&self, media: Vec<MediaRegistration>) {70
+ if !media.is_empty() {71
+ self.registry.lock().await.insert_all(media);72
+ }73
+ }74
+75
+ pub(crate) async fn release(&self, scope: &MediaScope) {76
+ self.registry.lock().await.release(scope);77
+ }78
+79
+ pub(crate) async fn clear(&self) {80
+ self.registry.lock().await.clear();81
+ }82
+83
+ pub(crate) async fn timeline_media(84
+ &self,85
+ media_id: &MediaId,86
+ size: MediaSize,87
+ ) -> Result<Media, Error> {88
+ let chosen = choose_source(self.registered(media_id).await?, size)?;89
+ if chosen90
+ .declared_size91
+ .is_some_and(|size| size > self.max_bytes as u64)92
+ {93
+ return Err(too_large("media exceeds the configured byte limit"));94
+ }95
+ self.fetch_or_file(chosen.request, chosen.mimetype).await96
+ }97
+98
+ pub(crate) async fn media_location(99
+ &self,100
+ media_id: &MediaId,101
+ size: MediaSize,102
+ ) -> Result<MediaLocation, Error> {103
+ let media = self.registered(media_id).await?;104
+ let chosen = choose_source(media.clone(), size)?;105
+ if is_local(&chosen.request.source) {106
+ return Ok(MediaLocation::Local);107
+ }108
+ if !matches!(chosen.request.format, MediaFormat::Thumbnail(_)) {109
+ return Ok(MediaLocation::File(110
+ media_file(111
+ &self.client,112
+ chosen.request,113
+ chosen.mimetype,114
+ chosen.declared_size,115
+ )116
+ .await?,117
+ ));118
+ }119
+ let url = media_http_request(&self.client, &chosen.request)120
+ .await?121
+ .uri()122
+ .to_string();123
+ let fallback = MediaRequestParameters {124
+ source: media.full.source,125
+ format: MediaFormat::File,126
+ };127
+ Ok(MediaLocation::ServerThumbnail {128
+ url,129
+ fallback: media_file(130
+ &self.client,131
+ fallback,132
+ media.full.mimetype,133
+ media.full.declared_size,134
+ )135
+ .await?,136
+ })137
+ }138
+139
+ async fn registered(&self, media_id: &MediaId) -> Result<MediaRegistration, Error> {140
+ self.registry141
+ .lock()142
+ .await143
+ .get(media_id)144
+ .cloned()145
+ .ok_or_else(|| {146
+ Error::new(147
+ ErrorKind::MediaUnknown,148
+ "media is not registered in a retained timeline",149
+ )150
+ })151
+ }152
+153
+ pub(crate) async fn mxc_media(&self, uri: &MxcUri, size: MediaSize) -> Result<Media, Error> {154
+ if !uri.is_valid() {155
+ return Err(Error::new(ErrorKind::Invalid, "mxc uri is malformed"));156
+ }157
+ let format = match size {158
+ MediaSize::Full => MediaFormat::File,159
+ MediaSize::Thumbnail { width, height } => thumbnail_format(width, height),160
+ };161
+ self.fetch_or_file(162
+ MediaRequestParameters {163
+ source: MediaSource::Plain(uri.to_owned()),164
+ format,165
+ },166
+ None,167
+ )168
+ .await169
+ }170
+171
+ /// plenty of servers won't thumbnail everything, and the file itself is the next best172
+ /// thing. the byte cap still holds either way173
+ async fn fetch_or_file(174
+ &self,175
+ request: MediaRequestParameters,176
+ mimetype: Option<String>,177
+ ) -> Result<Media, Error> {178
+ if !matches!(request.format, MediaFormat::Thumbnail(_)) {179
+ return self.fetch(request, mimetype).await;180
+ }181
+ match self.fetch(request.clone(), mimetype.clone()).await {182
+ Err(error) if error.kind == ErrorKind::MediaUnavailable => {183
+ let file = MediaRequestParameters {184
+ source: request.source,185
+ format: MediaFormat::File,186
+ };187
+ self.fetch(file, mimetype).await188
+ }189
+ result => result,190
+ }191
+ }192
+193
+ async fn fetch(194
+ &self,195
+ request: MediaRequestParameters,196
+ mimetype: Option<String>,197
+ ) -> Result<Media, Error> {198
+ let key = flight_key(&request);199
+ let flight = {200
+ let mut flights = self.flights.lock().await;201
+ flights.retain(|_, flight| flight.upgrade().is_some());202
+ match flights.get(&key).and_then(WeakShared::upgrade) {203
+ Some(flight) => flight,204
+ None => {205
+ let prepared = AssertUnwindSafe(fetch(206
+ self.client.clone(),207
+ request,208
+ mimetype,209
+ self.max_bytes,210
+ ))211
+ .catch_unwind()212
+ .map(|result| {213
+ result.unwrap_or_else(|_| {214
+ Err(Error::new(ErrorKind::Other, "media fetch panicked"))215
+ })216
+ });217
+ let boxed: FlightFuture = Box::pin(prepared);218
+ let flight: Flight = boxed.shared();219
+ if let Some(weak) = flight.downgrade() {220
+ flights.insert(key, weak);221
+ }222
+ flight223
+ }224
+ }225
+ };226
+ flight.await227
+ }228
+}229
+230
+struct ChosenSource {231
+ request: MediaRequestParameters,232
+ mimetype: Option<String>,233
+ declared_size: Option<u64>,234
+}235
+236
+fn choose_source(media: MediaRegistration, size: MediaSize) -> Result<ChosenSource, Error> {237
+ let (source, format, mimetype, declared_size) = match size {238
+ MediaSize::Full => (239
+ media.full.source,240
+ MediaFormat::File,241
+ media.full.mimetype,242
+ media.full.declared_size,243
+ ),244
+ MediaSize::Thumbnail { width, height } => match media.thumbnail {245
+ Some(thumbnail) => (246
+ thumbnail.source,247
+ MediaFormat::File,248
+ thumbnail.mimetype,249
+ thumbnail.declared_size,250
+ ),251
+ // the server can only resize what it can read252
+ None if matches!(media.kind, MediaKind::Image | MediaKind::Video)253
+ && matches!(media.full.source, MediaSource::Plain(_)) =>254
+ {255
+ // the server picks the thumbnail's type, so the original's would be a lie256
+ let format = if is_local(&media.full.source) {257
+ MediaFormat::File258
+ } else {259
+ thumbnail_format(width, height)260
+ };261
+ (media.full.source, format, None, None)262
+ }263
+ None => {264
+ return Err(Error::new(265
+ ErrorKind::MediaUnavailable,266
+ "the requested media size is unavailable",267
+ ));268
+ }269
+ },270
+ };271
+ Ok(ChosenSource {272
+ request: MediaRequestParameters { source, format },273
+ mimetype,274
+ declared_size,275
+ })276
+}277
+278
+async fn media_file(279
+ client: &Client,280
+ request: MediaRequestParameters,281
+ mimetype: Option<String>,282
+ declared_size: Option<u64>,283
+) -> Result<MediaFile, Error> {284
+ let url = media_http_request(client, &request)285
+ .await?286
+ .uri()287
+ .to_string();288
+ let encryption = match &request.source {289
+ MediaSource::Plain(_) => None,290
+ MediaSource::Encrypted(file) => {291
+ let (EncryptedFileInfo::V2(info), Some(EncryptedFileHash::Sha256(sha256))) = (292
+ &file.info,293
+ file.hashes.get(&EncryptedFileHashAlgorithm::Sha256),294
+ ) else {295
+ return Err(Error::new(296
+ ErrorKind::MediaUnavailable,297
+ "encrypted media is not v2 with a sha256 hash",298
+ ));299
+ };300
+ Some(AttachmentEncryption {301
+ key: info.k.encode(),302
+ iv: info.iv.encode(),303
+ sha256: sha256.encode(),304
+ })305
+ }306
+ };307
+ Ok(MediaFile {308
+ url,309
+ mimetype,310
+ declared_size,311
+ encryption,312
+ })313
+}314
+315
+fn thumbnail_format(width: u32, height: u32) -> MediaFormat {316
+ MediaFormat::Thumbnail(MediaThumbnailSettings::new(width.into(), height.into()))317
+}318
+319
+fn is_local(source: &MediaSource) -> bool {320
+ let uri = match source {321
+ MediaSource::Plain(uri) => uri,322
+ MediaSource::Encrypted(file) => &file.url,323
+ };324
+ uri.server_name()325
+ .is_ok_and(|server| server.as_str() == "send-queue.localhost")326
+}327
+328
+/// plain and encrypted sources share a cache key in the sdk, but must never share a flight329
+fn flight_key(request: &MediaRequestParameters) -> String {330
+ let uri = match &request.source {331
+ MediaSource::Plain(uri) => format!("plain|{uri}"),332
+ MediaSource::Encrypted(file) => format!("encrypted|{}", file.url),333
+ };334
+ match &request.format {335
+ MediaFormat::File => uri.to_owned(),336
+ MediaFormat::Thumbnail(settings) => format!(337
+ "{uri}|{}|{}x{}|{}",338
+ settings.method, settings.width, settings.height, settings.animated339
+ ),340
+ }341
+}342
+343
+fn too_large(message: &'static str) -> Error {344
+ Error::new(ErrorKind::MediaTooLarge, message)345
+}346
+347
+async fn fetch(348
+ client: Client,349
+ request: MediaRequestParameters,350
+ mimetype: Option<String>,351
+ max_bytes: usize,352
+) -> Result<Media, Error> {353
+ if let Some(cached) = cached_media(&client, &request).await? {354
+ if cached.len() > max_bytes {355
+ if let Ok(store) = client.media_store().lock().await {356
+ let _ = store.remove_media_content(&request).await;357
+ }358
+ return Err(too_large("cached media exceeds the configured byte limit"));359
+ }360
+ // plain and encrypted share a cache key, so a plain fetch of an encrypted file's uri361
+ // leaves ciphertext here. its hash gives it away362
+ if is_ciphertext(&cached, &request.source) {363
+ let bytes = maybe_decrypt(cached, &request, max_bytes)?;364
+ cache(&client, &request, &bytes).await;365
+ return Ok(media(bytes, mimetype));366
+ }367
+ return Ok(media(cached, mimetype));368
+ }369
+ let (bytes, served_mimetype) = download_bounded(&client, &request, max_bytes).await?;370
+ // the sdk caches plaintext under the encrypted key, so we do too or nobody can read anything371
+ let bytes = maybe_decrypt(bytes, &request, max_bytes)?;372
+ cache(&client, &request, &bytes).await;373
+ Ok(media(bytes, mimetype.or(served_mimetype)))374
+}375
+376
+async fn cache(client: &Client, request: &MediaRequestParameters, bytes: &[u8]) {377
+ if let Ok(store) = client.media_store().lock().await {378
+ let _ = store379
+ .add_media_content(380
+ request,381
+ bytes.to_vec(),382
+ matrix_sdk::media::store::IgnoreMediaRetentionPolicy::No,383
+ )384
+ .await;385
+ }386
+}387
+388
+fn is_ciphertext(bytes: &[u8], source: &MediaSource) -> bool {389
+ use sha2::{Digest, Sha256};390
+ let MediaSource::Encrypted(file) = source else {391
+ return false;392
+ };393
+ file.hashes394
+ .get(&ruma::events::room::EncryptedFileHashAlgorithm::Sha256)395
+ .is_some_and(|hash| hash.as_bytes() == Sha256::digest(bytes).as_slice())396
+}397
+398
+fn media(bytes: Vec<u8>, mimetype: Option<String>) -> Media {399
+ let mimetype = mimetype400
+ .or_else(|| sniff_image(&bytes).map(str::to_owned))401
+ .unwrap_or_else(|| "application/octet-stream".to_owned());402
+ Media {403
+ bytes: Arc::from(bytes),404
+ mimetype,405
+ }406
+}407
+408
+/// the cache forgets content types, and avatars never declare one. images are the common case409
+fn sniff_image(bytes: &[u8]) -> Option<&'static str> {410
+ match bytes {411
+ [0x89, b'P', b'N', b'G', ..] => Some("image/png"),412
+ [0xff, 0xd8, 0xff, ..] => Some("image/jpeg"),413
+ [b'G', b'I', b'F', b'8', ..] => Some("image/gif"),414
+ [415
+ b'R',416
+ b'I',417
+ b'F',418
+ b'F',419
+ _,420
+ _,421
+ _,422
+ _,423
+ b'W',424
+ b'E',425
+ b'B',426
+ b'P',427
+ ..,428
+ ] => Some("image/webp"),429
+ _ => None,430
+ }431
+}432
+433
+async fn cached_media(434
+ client: &Client,435
+ request: &MediaRequestParameters,436
+) -> Result<Option<Vec<u8>>, Error> {437
+ fn cache_failed(438
+ source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,439
+ ) -> Error {440
+ Error::with_source(ErrorKind::Store, "reading media cache failed", source)441
+ }442
+ client443
+ .media_store()444
+ .lock()445
+ .await446
+ .map_err(cache_failed)?447
+ .get_media_content(request)448
+ .await449
+ .map_err(cache_failed)450
+}451
+452
+fn maybe_decrypt(453
+ bytes: Vec<u8>,454
+ request: &MediaRequestParameters,455
+ max_bytes: usize,456
+) -> Result<Vec<u8>, Error> {457
+ let MediaSource::Encrypted(file) = &request.source else {458
+ return Ok(bytes);459
+ };460
+ fn decrypt_failed(461
+ source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,462
+ ) -> Error {463
+ Error::with_source(464
+ ErrorKind::MediaUnavailable,465
+ "media decryption failed",466
+ source,467
+ )468
+ }469
+ let info: MediaEncryptionInfo = (**file).clone().into();470
+ let mut cursor = std::io::Cursor::new(bytes);471
+ let mut decryptor = AttachmentDecryptor::new(&mut cursor, info).map_err(decrypt_failed)?;472
+ let mut decrypted = Vec::new();473
+ let mut chunk = [0_u8; 8192];474
+ loop {475
+ let read = decryptor.read(&mut chunk).map_err(decrypt_failed)?;476
+ if read == 0 {477
+ return Ok(decrypted);478
+ }479
+ if read > max_bytes.saturating_sub(decrypted.len()) {480
+ return Err(too_large(481
+ "decrypted media exceeds the configured byte limit",482
+ ));483
+ }484
+ decrypted.extend_from_slice(&chunk[..read]);485
+ }486
+}487
+488
+/// the sdk's own download has no size cap, so we do it by hand489
+async fn download_bounded(490
+ client: &Client,491
+ request: &MediaRequestParameters,492
+ max_bytes: usize,493
+) -> Result<(Vec<u8>, Option<String>), Error> {494
+ if is_local(&request.source) {495
+ return Err(Error::new(496
+ ErrorKind::MediaUnavailable,497
+ "local media is not in the media cache",498
+ ));499
+ }500
+ let network_failed =501
+ |source| Error::with_source(ErrorKind::Network, "media download failed", source);502
+ let mut refreshed = false;503
+ let response = loop {504
+ let http_request = media_http_request(client, request).await?;505
+ let (parts, _) = http_request.into_parts();506
+ let mut builder = client507
+ .http_client()508
+ .get(parts.uri.to_string())509
+ .timeout(DOWNLOAD_TOTAL);510
+ for (name, value) in &parts.headers {511
+ builder = builder.header(name.as_str(), value.as_bytes());512
+ }513
+ let response = builder.send().await.map_err(network_failed)?;514
+ if response.status() != matrix_sdk::reqwest::StatusCode::UNAUTHORIZED {515
+ break response;516
+ }517
+ if refreshed518
+ || client519
+ .session_tokens()520
+ .is_none_or(|tokens| tokens.refresh_token.is_none())521
+ {522
+ return Err(Error::new(523
+ ErrorKind::Unauthenticated,524
+ "media download was not authorized",525
+ ));526
+ }527
+ client.refresh_access_token().await.map_err(|source| {528
+ Error::from_refresh_token("refreshing media access token failed", source)529
+ })?;530
+ refreshed = true;531
+ };532
+ let status = response.status();533
+ if !status.is_success() {534
+ return Err(Error::new(535
+ ErrorKind::MediaUnavailable,536
+ format!("media download failed with status {status}"),537
+ ));538
+ }539
+ if response540
+ .content_length()541
+ .is_some_and(|length| length > max_bytes as u64)542
+ {543
+ return Err(too_large("media exceeds the configured byte limit"));544
+ }545
+ let mimetype = response546
+ .headers()547
+ .get(http::header::CONTENT_TYPE)548
+ .and_then(|value| value.to_str().ok())549
+ .map(str::to_owned);550
+ let mut stream = response.bytes_stream();551
+ let mut bytes = Vec::new();552
+ loop {553
+ let chunk = matrix_sdk::timeout::timeout(stream.next(), DOWNLOAD_IDLE)554
+ .await555
+ .map_err(|_| Error::new(ErrorKind::Network, "media download stalled"))?;556
+ let Some(chunk) = chunk else { break };557
+ let chunk = chunk.map_err(network_failed)?;558
+ if chunk.len() > max_bytes.saturating_sub(bytes.len()) {559
+ return Err(too_large(560
+ "downloaded media exceeds the configured byte limit",561
+ ));562
+ }563
+ bytes.extend_from_slice(&chunk);564
+ }565
+ Ok((bytes, mimetype))566
+}567
+568
+async fn media_http_request(569
+ client: &Client,570
+ request: &MediaRequestParameters,571
+) -> Result<http::Request<Vec<u8>>, Error> {572
+ let versions = client.supported_versions().await.map_err(|source| {573
+ Error::with_source(574
+ ErrorKind::Network,575
+ "reading supported server versions failed",576
+ source,577
+ )578
+ })?;579
+ let authenticated =580
+ authenticated_media::get_content::v1::Request::PATH_BUILDER.is_supported(&versions);581
+ let base = client.homeserver().to_string();582
+ let token = client.access_token();583
+ let invalid_uri =584
+ |source| Error::with_source(ErrorKind::Invalid, "media URI is invalid", source);585
+ let (uri, thumbnail) = match (&request.source, &request.format) {586
+ (MediaSource::Encrypted(file), _) => (&file.url, None),587
+ (MediaSource::Plain(uri), MediaFormat::File) => (uri, None),588
+ (MediaSource::Plain(uri), MediaFormat::Thumbnail(settings)) => (uri, Some(settings)),589
+ };590
+ // each endpoint has its own auth input type, so no generic helper can take them all591
+ macro_rules! into_http {592
+ ($endpoint:expr, $token:expr) => {593
+ $endpoint594
+ .try_into_http_request::<Vec<u8>>(&base, $token, Cow::Borrowed(&versions))595
+ .map_err(|source| {596
+ Error::with_source(ErrorKind::Other, "media request is invalid", source)597
+ })?598
+ };599
+ }600
+ let authed = SendAccessToken::IfRequired(token.as_deref().unwrap_or(""));601
+ #[allow(deprecated)]602
+ let mut http_request = match (thumbnail, authenticated) {603
+ (None, true) => into_http!(604
+ authenticated_media::get_content::v1::Request::from_uri(uri).map_err(invalid_uri)?,605
+ authed606
+ ),607
+ (None, false) => into_http!(608
+ media::get_content::v3::Request::from_url(uri).map_err(invalid_uri)?,609
+ SendAccessToken::None610
+ ),611
+ (Some(settings), true) => {612
+ let mut endpoint = authenticated_media::get_content_thumbnail::v1::Request::from_uri(613
+ uri,614
+ settings.width,615
+ settings.height,616
+ )617
+ .map_err(invalid_uri)?;618
+ endpoint.method = Some(settings.method.clone());619
+ endpoint.animated = Some(settings.animated);620
+ into_http!(endpoint, authed)621
+ }622
+ (Some(settings), false) => {623
+ let mut endpoint = media::get_content_thumbnail::v3::Request::from_url(624
+ uri,625
+ settings.width,626
+ settings.height,627
+ )628
+ .map_err(invalid_uri)?;629
+ endpoint.method = Some(settings.method.clone());630
+ endpoint.animated = Some(settings.animated);631
+ into_http!(endpoint, SendAccessToken::None)632
+ }633
+ };634
+ // legacy endpoints don't ask for a token but plenty of servers want one anyway635
+ if let Some(token) = token.as_deref() {636
+ let value = format!("Bearer {token}").parse().map_err(|_| {637
+ Error::new(ErrorKind::Unauthenticated, "stored access token is invalid")638
+ })?;639
+ http_request640
+ .headers_mut()641
+ .insert(http::header::AUTHORIZATION, value);642
+ }643
+ Ok(http_request)644
+}645
+646
+pub(crate) async fn queue_upload(647
+ room: Room,648
+ upload: AttachmentUpload,649
+ max_bytes: usize,650
+) -> Result<(), Error> {651
+ let (upload, mime, mut config) = prepare_upload(upload, max_bytes)?;652
+ if let TimelineId::Thread { root_event_id, .. } = &upload.timeline_id {653
+ config.reply = Some(SdkReply {654
+ event_id: root_event_id.clone(),655
+ enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),656
+ add_mentions: AddMentions::No,657
+ });658
+ }659
+ room.send_queue()660
+ .send_attachment(upload.filename, mime, upload.bytes, config)661
+ .await662
+ .map(drop)663
+ .map_err(|source| {664
+ use matrix_sdk::send_queue::RoomSendQueueError;665
+ let kind = match &source {666
+ RoomSendQueueError::RoomNotJoined | RoomSendQueueError::RoomDisappeared => {667
+ ErrorKind::RoomUnknown668
+ }669
+ RoomSendQueueError::StorageError(_) => ErrorKind::Store,670
+ _ => ErrorKind::MediaUnavailable,671
+ };672
+ Error::with_source(kind, "media upload could not be queued", source)673
+ })674
+}675
+676
+/// least recently seen goes first. stale order slots are skipped by stamp rather than hunted down677
+struct MediaRegistry {678
+ entries: HashMap<MediaId, (MediaRegistration, u64)>,679
+ order: VecDeque<(MediaId, u64)>,680
+ next_stamp: u64,681
+ limit: usize,682
+}683
+684
+impl MediaRegistry {685
+ fn new(limit: usize) -> Self {686
+ Self {687
+ entries: HashMap::new(),688
+ order: VecDeque::new(),689
+ next_stamp: 0,690
+ limit,691
+ }692
+ }693
+694
+ fn get(&self, id: &MediaId) -> Option<&MediaRegistration> {695
+ self.entries.get(id).map(|(media, _)| media)696
+ }697
+698
+ fn insert_all(&mut self, media: Vec<MediaRegistration>) {699
+ for media in media {700
+ let stamp = self.next_stamp;701
+ self.next_stamp += 1;702
+ self.order.push_back((media.id.clone(), stamp));703
+ self.entries.insert(media.id.clone(), (media, stamp));704
+ }705
+ while self.entries.len() > self.limit {706
+ let Some((id, stamp)) = self.order.pop_front() else {707
+ break;708
+ };709
+ if self.is_current(&id, stamp) {710
+ self.entries.remove(&id);711
+ }712
+ }713
+ if self.order.len() > self.limit.saturating_mul(4) {714
+ let entries = &self.entries;715
+ self.order716
+ .retain(|(id, stamp)| entries.get(id).is_some_and(|(_, current)| current == stamp));717
+ }718
+ }719
+720
+ fn is_current(&self, id: &MediaId, stamp: u64) -> bool {721
+ self.entries722
+ .get(id)723
+ .is_some_and(|(_, current)| *current == stamp)724
+ }725
+726
+ fn release(&mut self, scope: &MediaScope) {727
+ self.entries.retain(|id, _| !scope.owns(id));728
+ let entries = &self.entries;729
+ self.order.retain(|(id, _)| entries.contains_key(id));730
+ }731
+732
+ fn clear(&mut self) {733
+ self.entries.clear();734
+ self.order.clear();735
+ }736
+}@@ -0,0 +1,1001 @@
1
+mod commands;2
+pub(crate) mod executor;3
+mod media;4
+mod mutations;5
+mod prepare;6
+mod room;7
+mod session;8
+mod timeline;9
+mod watchers;10
+11
+use std::{12
+ collections::{HashMap, VecDeque},13
+ future::Future,14
+ panic::AssertUnwindSafe,15
+ sync::Arc,16
+};17
+18
+use futures_util::FutureExt;19
+use matrix_sdk::{Client, SendOutsideWasm, SessionChange};20
+use matrix_sdk_ui::sync_service::SyncService;21
+use ruma::{OwnedDeviceId, OwnedRoomId};22
+use thiserror::Error as ThisError;23
+use tokio::sync::{broadcast, mpsc, watch};24
+25
+pub(crate) use media::MediaService;26
+pub(crate) use session::Persistence;27
+28
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]29
+pub(crate) use crate::store_lock::StoreLock;30
+#[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]31
+pub(crate) enum StoreLock {}32
+33
+use self::{34
+ commands::Job,35
+ executor::{EngineBoxFuture, TaskSet},36
+ mutations::{Mutation, MutationJob},37
+ room::{ActorNote, ActorRequest, RoomActor},38
+ timeline::{SessionContext, Supervisor},39
+};40
+use crate::{41
+ AccountCommand, ClientHandle, Command, ConnectionState, EncryptionCommand, EngineErrorKind,42
+ EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand, MediaCommand,43
+ NotificationCommand, RoomCommand, Termination, TimelineCommand,44
+ transport::{DriverPort, Request, Responder, transport_pair},45
+};46
+47
+#[derive(Clone, Debug, Eq, PartialEq)]48
+#[non_exhaustive]49
+pub struct EngineConfig {50
+ pub command_capacity: usize,51
+ pub event_capacity: usize,52
+ pub initial_history_limit: u16,53
+ pub max_media_bytes: usize,54
+ pub max_media_sources: usize,55
+ /// warm a room up for sending when it's opened or someone starts typing in it56
+ pub prepare_to_send: bool,57
+}58
+59
+impl EngineConfig {60
+ #[must_use]61
+ pub const fn with_command_capacity(mut self, capacity: usize) -> Self {62
+ self.command_capacity = capacity;63
+ self64
+ }65
+66
+ #[must_use]67
+ pub const fn with_event_capacity(mut self, capacity: usize) -> Self {68
+ self.event_capacity = capacity;69
+ self70
+ }71
+72
+ #[must_use]73
+ pub const fn with_initial_history_limit(mut self, limit: u16) -> Self {74
+ self.initial_history_limit = limit;75
+ self76
+ }77
+78
+ #[must_use]79
+ pub const fn with_max_media_bytes(mut self, bytes: usize) -> Self {80
+ self.max_media_bytes = bytes;81
+ self82
+ }83
+84
+ #[must_use]85
+ pub const fn with_max_media_sources(mut self, sources: usize) -> Self {86
+ self.max_media_sources = sources;87
+ self88
+ }89
+90
+ #[must_use]91
+ pub const fn with_prepare_to_send(mut self, prepare: bool) -> Self {92
+ self.prepare_to_send = prepare;93
+ self94
+ }95
+}96
+97
+impl Default for EngineConfig {98
+ fn default() -> Self {99
+ Self {100
+ command_capacity: 64,101
+ event_capacity: 128,102
+ initial_history_limit: 30,103
+ max_media_bytes: 20 * 1024 * 1024,104
+ max_media_sources: 2_048,105
+ prepare_to_send: true,106
+ }107
+ }108
+}109
+110
+#[derive(Debug, ThisError)]111
+pub enum EngineError {112
+ #[error("the Matrix client is not authenticated")]113
+ Unauthenticated,114
+ #[error("command, event, and media source capacities must be nonzero")]115
+ ZeroCapacity,116
+ #[error("could not build the sync service")]117
+ SyncService(#[source] matrix_sdk_ui::sync_service::Error),118
+ #[error("the event receiver has closed")]119
+ EventReceiverClosed,120
+ #[error("engine task {task} panicked")]121
+ TaskPanicked { task: String },122
+}123
+124
+impl EngineError {125
+ fn failure(&self) -> EngineFailure {126
+ EngineFailure {127
+ kind: match self {128
+ Self::EventReceiverClosed => EngineErrorKind::EventReceiverClosed,129
+ Self::TaskPanicked { .. } => EngineErrorKind::Watcher,130
+ Self::Unauthenticated | Self::ZeroCapacity | Self::SyncService(_) => {131
+ EngineErrorKind::Other132
+ }133
+ },134
+ message: self.to_string(),135
+ }136
+ }137
+}138
+139
+#[derive(Clone)]140
+enum TaskContext {141
+ /// a panic here costs the caller an `Interrupted` answer and nothing else142
+ Command(&'static str),143
+ /// a panic here takes the engine with it144
+ Watcher(&'static str),145
+ RoomActor {146
+ room_id: OwnedRoomId,147
+ generation: u64,148
+ },149
+}150
+151
+impl TaskContext {152
+ fn label(&self) -> String {153
+ match self {154
+ Self::Command(name) | Self::Watcher(name) => (*name).to_owned(),155
+ Self::RoomActor {156
+ room_id,157
+ generation,158
+ } => format!("room actor {room_id} generation {generation}"),159
+ }160
+ }161
+}162
+163
+/// tasks catch their own panics so each comes back with its context, since joining164
+/// through the sdk's executor gives no task id to look one up by. wasm has nothing to catch165
+fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {166
+ payload167
+ .downcast_ref::<&str>()168
+ .map(ToString::to_string)169
+ .or_else(|| payload.downcast_ref::<String>().cloned())170
+ .unwrap_or_else(|| "task panicked with a non-string payload".to_owned())171
+}172
+173
+struct TaskRegistry {174
+ tasks: TaskSet<(TaskContext, Option<String>)>,175
+}176
+177
+impl TaskRegistry {178
+ fn spawn(179
+ &mut self,180
+ context: TaskContext,181
+ task: impl Future<Output = ()> + SendOutsideWasm + 'static,182
+ ) {183
+ self.tasks.spawn(async move {184
+ let panic = AssertUnwindSafe(task)185
+ .catch_unwind()186
+ .await187
+ .err()188
+ .map(panic_message);189
+ (context, panic)190
+ });191
+ }192
+193
+ fn is_empty(&self) -> bool {194
+ self.tasks.is_empty()195
+ }196
+197
+ async fn join_next(&mut self) -> Option<(TaskContext, Option<String>)> {198
+ loop {199
+ if let Ok(done) = self.tasks.join_next().await? {200
+ return Some(done);201
+ }202
+ }203
+ }204
+205
+ async fn shutdown(&mut self) {206
+ self.tasks.shutdown().await;207
+ }208
+}209
+210
+enum RoomSlot {211
+ Active {212
+ generation: u64,213
+ sender: mpsc::Sender<ActorRequest>,214
+ },215
+ /// opens and closes that arrive while the actor winds down, replayed in order once it's gone216
+ Closing {217
+ generation: u64,218
+ pending: VecDeque<Deferred>,219
+ },220
+}221
+222
+impl RoomSlot {223
+ const fn generation(&self) -> u64 {224
+ match self {225
+ Self::Active { generation, .. } | Self::Closing { generation, .. } => *generation,226
+ }227
+ }228
+}229
+230
+enum Deferred {231
+ Open(Responder),232
+ Close(Responder),233
+}234
+235
+/// logouts in flight, and a token rejection held back because one of them caused it236
+#[derive(Default)]237
+pub(super) struct Logouts {238
+ pub(super) in_flight: usize,239
+ pub(super) rejected: Option<Option<OwnedDeviceId>>,240
+}241
+242
+struct Queues {243
+ mutations: mpsc::Receiver<MutationJob>,244
+ notifications: mpsc::Receiver<Job<NotificationCommand>>,245
+ encryption: mpsc::Receiver<Job<EncryptionCommand>>,246
+ session_changes: broadcast::Receiver<SessionChange>,247
+ persistence: Option<Persistence>,248
+ upload_reports: mpsc::Receiver<watchers::RoomUpload>,249
+}250
+251
+/// requests other devices send us, handed from the sdk's event handler to the encryption worker252
+type IncomingVerifications =253
+ mpsc::UnboundedSender<matrix_sdk::encryption::verification::VerificationRequest>;254
+255
+pub struct Engine {256
+ client: Client,257
+ sync: Arc<SyncService>,258
+ port: DriverPort,259
+ config: EngineConfig,260
+ media: Arc<MediaService>,261
+ tasks: TaskRegistry,262
+ rooms: HashMap<OwnedRoomId, RoomSlot>,263
+ next_generation: u64,264
+ notes: mpsc::UnboundedReceiver<ActorNote>,265
+ note_sender: mpsc::UnboundedSender<ActorNote>,266
+ /// the rooms sliding sync should follow, applied off the run loop since it touches the store267
+ subscriptions: watch::Sender<Vec<OwnedRoomId>>,268
+ mutations: mpsc::Sender<MutationJob>,269
+ notifications: mpsc::Sender<Job<NotificationCommand>>,270
+ encryption: mpsc::Sender<Job<EncryptionCommand>>,271
+ queues: Option<Queues>,272
+ /// one progress forwarder per room that has uploaded, and whether it's subscribed yet273
+ uploads: HashMap<OwnedRoomId, watch::Receiver<bool>>,274
+ upload_reports: mpsc::Sender<watchers::RoomUpload>,275
+ /// the client can outlive us, so the handler has to come off it when we stop276
+ verification_handler: Option<matrix_sdk::event_handler::EventHandlerHandle>,277
+ stop: mpsc::UnboundedSender<Termination>,278
+ stops: mpsc::UnboundedReceiver<Termination>,279
+ logouts: Arc<watch::Sender<Logouts>>,280
+ preparations: prepare::Preparations,281
+ /// shared with every `ClientHandle`, so the store stays locked while the client is reachable282
+ _store_lock: Option<Arc<StoreLock>>,283
+}284
+285
+impl Engine {286
+ /// the escape hatch for a client you built yourself. `Session` is the usual way in287
+ pub async fn from_client(288
+ client: Client,289
+ config: EngineConfig,290
+ ) -> Result<(Self, ClientHandle, EventStream), EngineError> {291
+ let session_changes = client.subscribe_to_session_changes();292
+ Self::with_persistence(client, config, session_changes, None, None).await293
+ }294
+295
+ /// `session_changes` is taken when the client is built, since the sdk can refresh296
+ /// before an engine exists297
+ pub(crate) async fn with_persistence(298
+ client: Client,299
+ config: EngineConfig,300
+ session_changes: broadcast::Receiver<SessionChange>,301
+ persistence: Option<Persistence>,302
+ store_lock: Option<StoreLock>,303
+ ) -> Result<(Self, ClientHandle, EventStream), EngineError> {304
+ if client.user_id().is_none() {305
+ return Err(EngineError::Unauthenticated);306
+ }307
+ if config.command_capacity == 0308
+ || config.event_capacity == 0309
+ || config.max_media_sources == 0310
+ {311
+ return Err(EngineError::ZeroCapacity);312
+ }313
+ let sync = Arc::new(314
+ SyncService::builder(client.clone())315
+ .with_offline_mode()316
+ .build()317
+ .await318
+ .map_err(EngineError::SyncService)?,319
+ );320
+ let store_lock = store_lock.map(Arc::new);321
+ let media = Arc::new(MediaService::new(322
+ client.clone(),323
+ config.max_media_bytes,324
+ config.max_media_sources,325
+ ));326
+ let (handle, port, events) = transport_pair(327
+ client.clone(),328
+ sync.clone(),329
+ media.clone(),330
+ store_lock.clone(),331
+ config.command_capacity,332
+ config.event_capacity,333
+ );334
+ client.send_queue().enable_upload_progress(true);335
+ let (note_sender, notes) = mpsc::unbounded_channel();336
+ let (stop, stops) = mpsc::unbounded_channel();337
+ let (upload_reports, upload_report_queue) = mpsc::channel(config.event_capacity);338
+ let (mutations, mutation_queue) = mpsc::channel(config.command_capacity);339
+ let (notifications, notification_queue) = mpsc::channel(config.command_capacity);340
+ let (encryption, encryption_queue) = mpsc::channel(config.command_capacity);341
+ let engine = Self {342
+ client,343
+ sync,344
+ port,345
+ config,346
+ media,347
+ tasks: TaskRegistry {348
+ tasks: TaskSet::new(),349
+ },350
+ rooms: HashMap::new(),351
+ next_generation: 0,352
+ notes,353
+ note_sender,354
+ subscriptions: watch::Sender::new(Vec::new()),355
+ mutations,356
+ notifications,357
+ encryption,358
+ queues: Some(Queues {359
+ mutations: mutation_queue,360
+ notifications: notification_queue,361
+ encryption: encryption_queue,362
+ session_changes,363
+ persistence,364
+ upload_reports: upload_report_queue,365
+ }),366
+ upload_reports,367
+ uploads: HashMap::new(),368
+ verification_handler: None,369
+ preparations: prepare::Preparations::default(),370
+ stop,371
+ stops,372
+ logouts: Arc::new(watch::Sender::new(Logouts::default())),373
+ _store_lock: store_lock,374
+ };375
+ Ok((engine, handle, events))376
+ }377
+378
+ /// boxed because the engine's future is deep enough to blow the compiler's query depth379
+ /// in every crate that spawns it, which isn't something a client should have to fix380
+ pub fn run(self) -> EngineBoxFuture<'static, Result<Termination, EngineError>> {381
+ Box::pin(self.run_inner())382
+ }383
+384
+ async fn run_inner(mut self) -> Result<Termination, EngineError> {385
+ let events = self.port.event_sender();386
+ let user_id = self387
+ .client388
+ .user_id()389
+ .expect("authentication was checked during construction")390
+ .to_owned();391
+ self.port392
+ .connection_sender()393
+ .send_replace(ConnectionState::Connecting);394
+ for event in [395
+ Event::Authenticated { user_id },396
+ Event::ConnectionChanged(ConnectionState::Connecting),397
+ ] {398
+ if events.send(event).await.is_err() {399
+ self.shutdown().await;400
+ return Err(EngineError::EventReceiverClosed);401
+ }402
+ }403
+ self.start_tasks();404
+ self.resume_upload_forwarders().await;405
+ self.sync.start().await;406
+407
+ let mut shutdown_reply = None;408
+ let mut handles_dropped = false;409
+ let mut logouts = self.logouts.subscribe();410
+ let ending = loop {411
+ tokio::select! {412
+ biased;413
+ Some(termination) = self.stops.recv() => break Ok(termination),414
+ () = events.closed() => break Err(EngineError::EventReceiverClosed),415
+ () = settled(&mut logouts), if handles_dropped => {416
+ break Ok(Termination::HandlesDropped);417
+ }418
+ Some((context, panic)) = self.tasks.join_next(), if !self.tasks.is_empty() => {419
+ let Some(message) = panic else { continue };420
+ if let TaskContext::Command(label) = context {421
+ tracing::error!(label, panic = %message, "command task panicked");422
+ continue;423
+ }424
+ break Err(EngineError::TaskPanicked { task: context.label() });425
+ }426
+ Some(note) = self.notes.recv() => self.actor_note(note).await,427
+ request = self.port.recv(), if !handles_dropped => {428
+ // nobody's left to ask, but a logout they asked for still deserves to land429
+ let Some(request) = request else {430
+ handles_dropped = true;431
+ continue;432
+ };433
+ if let Some(reply) = self.dispatch(request).await {434
+ shutdown_reply = Some(reply);435
+ break Ok(Termination::ShutDown);436
+ }437
+ }438
+ }439
+ };440
+441
+ if let Err(error) = &ending {442
+ let _ = events.send(Event::Failed(error.failure())).await;443
+ }444
+ self.shutdown().await;445
+ let _ = events.send(Event::Stopped).await;446
+ if let Some(reply) = shutdown_reply {447
+ reply.done(Ok(()));448
+ }449
+ // everything else still queued answers `Interrupted` as it drops, but a shutdown did happen450
+ while let Some(Request { command, reply }) = self.port.try_recv() {451
+ if matches!(command, Command::Lifecycle(LifecycleCommand::Shutdown)) {452
+ reply.done(Ok(()));453
+ }454
+ }455
+ ending456
+ }457
+458
+ fn start_tasks(&mut self) {459
+ let queues = self.queues.take().expect("engine tasks start once");460
+ let (incoming, incoming_verifications) = mpsc::unbounded_channel();461
+ self.verification_handler = Some(self.verification_handler(incoming));462
+ let client = &self.client;463
+ let events = || self.port.event_sender();464
+ let (notices, notice_queue) = mpsc::unbounded_channel();465
+ let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 12] = [466
+ (467
+ "upload tracker",468
+ Box::pin(watchers::uploads(469
+ client.clone(),470
+ client.send_queue().subscribe(),471
+ queues.upload_reports,472
+ events(),473
+ )),474
+ ),475
+ (476
+ "room subscription watcher",477
+ Box::pin(watchers::room_subscriptions(478
+ self.sync.clone(),479
+ self.subscriptions.subscribe(),480
+ )),481
+ ),482
+ (483
+ "session change watcher",484
+ Box::pin(session::session(485
+ client.clone(),486
+ queues.session_changes,487
+ queues.persistence,488
+ notices,489
+ client490
+ .device_id()491
+ .expect("authentication was checked during construction")492
+ .to_owned(),493
+ self.logouts.clone(),494
+ )),495
+ ),496
+ (497
+ "session notice watcher",498
+ Box::pin(session::session_notices(499
+ notice_queue,500
+ events(),501
+ self.stop.clone(),502
+ )),503
+ ),504
+ (505
+ "connection state watcher",506
+ Box::pin(watchers::connection(507
+ self.sync.state(),508
+ self.sync.clone(),509
+ client.clone(),510
+ events(),511
+ self.port.connection_sender(),512
+ )),513
+ ),514
+ (515
+ "send queue watcher",516
+ Box::pin(watchers::send_queue_errors(517
+ client.clone(),518
+ client.send_queue().subscribe_errors(),519
+ self.port.connection_sender().subscribe(),520
+ )),521
+ ),522
+ (523
+ "encryption state watcher",524
+ Box::pin(watchers::encryption(client.clone(), events())),525
+ ),526
+ (527
+ "room list watcher",528
+ Box::pin(watchers::rooms(client.clone(), events())),529
+ ),530
+ (531
+ "notification settings watcher",532
+ Box::pin(watchers::notification_settings(client.clone(), events())),533
+ ),534
+ (535
+ "room command worker",536
+ Box::pin(mutations::run_scheduler(537
+ client.clone(),538
+ queues.mutations,539
+ self.config.command_capacity,540
+ )),541
+ ),542
+ (543
+ "notification command worker",544
+ Box::pin(commands::run_notifications(545
+ client.clone(),546
+ queues.notifications,547
+ )),548
+ ),549
+ (550
+ "encryption command worker",551
+ Box::pin(commands::run_encryption(552
+ client.clone(),553
+ events(),554
+ queues.encryption,555
+ incoming_verifications,556
+ )),557
+ ),558
+ ];559
+ for (label, task) in watchers {560
+ self.tasks.spawn(TaskContext::Watcher(label), task);561
+ }562
+ }563
+564
+ fn verification_handler(565
+ &self,566
+ incoming: IncomingVerifications,567
+ ) -> matrix_sdk::event_handler::EventHandlerHandle {568
+ use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent;569
+ self.client.add_event_handler(570
+ move |event: ToDeviceKeyVerificationRequestEvent, client: Client| {571
+ let incoming = incoming.clone();572
+ async move {573
+ let request = client574
+ .encryption()575
+ .get_verification_request(576
+ &event.sender,577
+ event.content.transaction_id.as_str(),578
+ )579
+ .await;580
+ if let Some(request) = request {581
+ let _ = incoming.send(request);582
+ }583
+ }584
+ },585
+ )586
+ }587
+588
+ /// hands back the shutdown request's responder, which is answered once we've stopped589
+ async fn dispatch(&mut self, Request { command, reply }: Request) -> Option<Responder> {590
+ let client = self.client.clone();591
+ match command {592
+ Command::Lifecycle(LifecycleCommand::Shutdown) => return Some(reply),593
+ Command::Account(AccountCommand::Logout) => {594
+ let events = self.port.event_sender();595
+ let stop = self.stop.clone();596
+ let logouts = self.logouts.clone();597
+ logouts.send_modify(|logouts| logouts.in_flight += 1);598
+ self.spawn_command("logout", reply, async move {599
+ let result = client.logout().await;600
+ if result.is_ok() {601
+ let _ = events.send(Event::LoggedOut).await;602
+ let _ = stop.send(Termination::LoggedOut);603
+ }604
+ let mut rejected = None;605
+ logouts.send_if_modified(|logouts| {606
+ if result.is_err() && logouts.in_flight == 1 {607
+ rejected = logouts.rejected.take();608
+ }609
+ false610
+ });611
+ if let Some(soft_logout) = rejected {612
+ session::reject_session(&events, &stop, soft_logout).await;613
+ }614
+ logouts.send_modify(|logouts| logouts.in_flight -= 1);615
+ result.map_err(|source| Error::from_sdk("logout failed", source))616
+ });617
+ }618
+ Command::Account(AccountCommand::SetAccountData {619
+ event_type,620
+ content,621
+ }) => enqueue(622
+ &self.mutations,623
+ Job {624
+ command: Mutation::GlobalAccountData {625
+ event_type,626
+ content,627
+ },628
+ reply,629
+ },630
+ ),631
+ Command::Account(AccountCommand::SetDisplayName(name)) => {632
+ self.spawn_command("set display name", reply, async move {633
+ client634
+ .account()635
+ .set_display_name(name.as_deref())636
+ .await637
+ .map_err(|source| Error::from_sdk("setting display name failed", source))638
+ });639
+ }640
+ Command::Account(AccountCommand::SetAvatar(avatar)) => {641
+ self.spawn_command("set avatar", reply, async move {642
+ client643
+ .account()644
+ .set_avatar_url(avatar.as_deref())645
+ .await646
+ .map_err(|source| Error::from_sdk("setting avatar failed", source))647
+ });648
+ }649
+ Command::Account(AccountCommand::UploadAvatar { mimetype, bytes }) => {650
+ let max_bytes = self.config.max_media_bytes;651
+ self.spawn_command("upload avatar", reply, async move {652
+ commands::upload_avatar(&client, &mimetype, bytes, max_bytes).await653
+ });654
+ }655
+ Command::Account(AccountCommand::RenameDevice {656
+ device_id,657
+ display_name,658
+ }) => self.spawn_command("rename device", reply, async move {659
+ commands::rename_device(&client, &device_id, &display_name).await660
+ }),661
+ Command::Account(AccountCommand::DeleteDevices { device_ids, auth }) => {662
+ self.spawn_command("delete devices", reply, async move {663
+ commands::delete_devices(&client, &device_ids, auth).await664
+ });665
+ }666
+ Command::Room(RoomCommand::Open(room_id)) => {667
+ if self.config.prepare_to_send {668
+ self.prepare_quietly(room_id.clone());669
+ }670
+ self.open_room(room_id, reply).await;671
+ }672
+ Command::Room(RoomCommand::Close(room_id)) => self.close_room(room_id, reply).await,673
+ Command::Room(RoomCommand::PrepareToSend(room_id)) => {674
+ let flight = self.preparations.join(&client, room_id);675
+ self.spawn_command("prepare to send", reply, flight);676
+ }677
+ Command::Room(RoomCommand::SetTyping { room_id, typing }) => {678
+ if typing && self.config.prepare_to_send {679
+ self.prepare_quietly(room_id.clone());680
+ }681
+ self.spawn_command("typing", reply, async move {682
+ commands::set_typing(&client, &room_id, typing).await683
+ });684
+ }685
+ Command::Room(RoomCommand::CancelSend {686
+ room_id,687
+ transaction_id,688
+ }) => self.spawn_command("cancel send", reply, async move {689
+ commands::cancel_send(&client, &room_id, &transaction_id).await690
+ }),691
+ Command::Room(RoomCommand::RetrySend {692
+ room_id,693
+ transaction_id,694
+ }) => self.spawn_command("retry send", reply, async move {695
+ commands::retry_send(&client, &room_id, &transaction_id).await696
+ }),697
+ Command::Room(command) => enqueue(698
+ &self.mutations,699
+ Job {700
+ command: Mutation::Room(command),701
+ reply,702
+ },703
+ ),704
+ Command::Timeline(command) => self.route_timeline(command, reply),705
+ Command::Media(MediaCommand::Upload(upload)) => self.upload(upload, reply),706
+ Command::Encryption(command) => enqueue(&self.encryption, Job { command, reply }),707
+ Command::Notification(command) => {708
+ enqueue(&self.notifications, Job { command, reply });709
+ }710
+ }711
+ None712
+ }713
+714
+ fn spawn_command(715
+ &mut self,716
+ label: &'static str,717
+ reply: Responder,718
+ work: impl Future<Output = Result<(), Error>> + SendOutsideWasm + 'static,719
+ ) {720
+ self.tasks.spawn(TaskContext::Command(label), async move {721
+ reply.done(work.await);722
+ });723
+ }724
+725
+ fn prepare_quietly(&mut self, room_id: OwnedRoomId) {726
+ let flight = self.preparations.join(&self.client, room_id);727
+ self.tasks728
+ .spawn(TaskContext::Command("prepare to send"), async move {729
+ let _ = flight.await;730
+ });731
+ }732
+733
+ fn upload(&mut self, upload: crate::AttachmentUpload, reply: Responder) {734
+ let Some(room) = self.client.get_room(upload.timeline_id.room_id()) else {735
+ return reply.done(Err(Error::room_unknown()));736
+ };737
+ let mut ready = self.upload_forwarder(&room);738
+ let max_bytes = self.config.max_media_bytes;739
+ // queueing before the forwarder listens would lose the first progress ticks740
+ self.spawn_command("upload", reply, async move {741
+ ready742
+ .wait_for(|subscribed| *subscribed)743
+ .await744
+ .map_err(|_| Error::new(ErrorKind::Store, "reading the media send queue failed"))?;745
+ media::queue_upload(room, upload, max_bytes).await746
+ });747
+ }748
+749
+ fn upload_forwarder(&mut self, room: &matrix_sdk::Room) -> watch::Receiver<bool> {750
+ self.uploads.retain(|_, ready| ready.has_changed().is_ok());751
+ if let Some(ready) = self.uploads.get(room.room_id()) {752
+ return ready.clone();753
+ }754
+ let (subscribed, ready) = watch::channel(false);755
+ self.tasks.spawn(756
+ TaskContext::Command("upload watcher"),757
+ watchers::room_uploads(room.clone(), self.upload_reports.clone(), subscribed),758
+ );759
+ self.uploads760
+ .insert(room.room_id().to_owned(), ready.clone());761
+ ready762
+ }763
+764
+ /// the sdk resumes uploads left over from last time, and they deserve progress too765
+ async fn resume_upload_forwarders(&mut self) {766
+ let Ok(echoes) = self.client.send_queue().local_echoes().await else {767
+ return;768
+ };769
+ for room_id in echoes.keys() {770
+ if let Some(room) = self.client.get_room(room_id) {771
+ self.upload_forwarder(&room);772
+ }773
+ }774
+ }775
+776
+ fn route_timeline(&mut self, command: TimelineCommand, reply: Responder) {777
+ let sender = match self.rooms.get(command.room_id()) {778
+ Some(RoomSlot::Active { sender, .. }) => Some(sender.clone()),779
+ _ => None,780
+ };781
+ match (sender, command) {782
+ (Some(sender), command) => send_actor(&sender, ActorRequest::Execute(command, reply)),783
+ (784
+ None,785
+ TimelineCommand::MarkRead {786
+ timeline_id,787
+ event_id,788
+ },789
+ ) => {790
+ let client = self.client.clone();791
+ self.spawn_command("mark read", reply, async move {792
+ commands::mark_read(&client, &timeline_id, event_id).await793
+ });794
+ }795
+ (None, _) => reply.done(Err(Error::new(ErrorKind::RoomNotOpen, "room is not open"))),796
+ }797
+ }798
+799
+ async fn open_room(&mut self, room_id: OwnedRoomId, reply: Responder) {800
+ match self.rooms.get_mut(&room_id) {801
+ Some(RoomSlot::Active { sender, .. }) => send_actor(sender, ActorRequest::Open(reply)),802
+ Some(RoomSlot::Closing { pending, .. }) => {803
+ defer(pending, Deferred::Open(reply), self.config.command_capacity);804
+ }805
+ None => self.start_actor(room_id, reply).await,806
+ }807
+ }808
+809
+ async fn close_room(&mut self, room_id: OwnedRoomId, reply: Responder) {810
+ let (generation, sender) = match self.rooms.get_mut(&room_id) {811
+ None => return reply.done(Ok(())),812
+ Some(RoomSlot::Closing { pending, .. }) => {813
+ return defer(814
+ pending,815
+ Deferred::Close(reply),816
+ self.config.command_capacity,817
+ );818
+ }819
+ Some(RoomSlot::Active { generation, sender }) => (*generation, sender.clone()),820
+ };821
+ match sender.try_send(ActorRequest::Close(reply)) {822
+ Ok(()) => {}823
+ // the actor already left on its own, which is closed enough824
+ Err(mpsc::error::TrySendError::Closed(request)) => request.into_reply().done(Ok(())),825
+ Err(mpsc::error::TrySendError::Full(request)) => {826
+ return request.into_reply().done(Err(Error::new(827
+ ErrorKind::RoomBusy,828
+ "room command queue is full",829
+ )));830
+ }831
+ }832
+ self.rooms.insert(833
+ room_id,834
+ RoomSlot::Closing {835
+ generation,836
+ pending: VecDeque::new(),837
+ },838
+ );839
+ self.update_room_subscriptions();840
+ }841
+842
+ async fn start_actor(&mut self, room_id: OwnedRoomId, reply: Responder) {843
+ let Some(room) = self.client.get_room(&room_id) else {844
+ return reply.done(Err(Error::room_unknown()));845
+ };846
+ self.next_generation = self.next_generation.wrapping_add(1);847
+ let generation = self.next_generation;848
+ let (sender, requests) = mpsc::channel(self.config.command_capacity);849
+ let (supervisor, panics) = Supervisor::new();850
+ let actor = RoomActor {851
+ room_id: room_id.clone(),852
+ generation,853
+ room,854
+ requests,855
+ notes: self.note_sender.clone(),856
+ context: SessionContext {857
+ events: self.port.event_sender(),858
+ media: self.media.clone(),859
+ current_user_id: self.client.user_id().map(ToOwned::to_owned),860
+ initial_history_limit: self.config.initial_history_limit,861
+ supervisor,862
+ },863
+ panics,864
+ opening: vec![reply],865
+ };866
+ self.rooms867
+ .insert(room_id.clone(), RoomSlot::Active { generation, sender });868
+ self.tasks.spawn(869
+ TaskContext::RoomActor {870
+ room_id,871
+ generation,872
+ },873
+ actor.run(),874
+ );875
+ self.update_room_subscriptions();876
+ }877
+878
+ async fn actor_note(&mut self, note: ActorNote) {879
+ let (room_id, generation) = match ¬e {880
+ ActorNote::Leaving {881
+ room_id,882
+ generation,883
+ }884
+ | ActorNote::Exited {885
+ room_id,886
+ generation,887
+ ..888
+ } => (room_id.clone(), *generation),889
+ };890
+ if self891
+ .rooms892
+ .get(&room_id)893
+ .is_none_or(|slot| slot.generation() != generation)894
+ {895
+ return;896
+ }897
+ let ActorNote::Exited { leftovers, .. } = note else {898
+ if let Some(slot @ RoomSlot::Active { .. }) = self.rooms.get_mut(&room_id) {899
+ *slot = RoomSlot::Closing {900
+ generation,901
+ pending: VecDeque::new(),902
+ };903
+ self.update_room_subscriptions();904
+ }905
+ return;906
+ };907
+ let pending = match self.rooms.remove(&room_id) {908
+ Some(RoomSlot::Closing { pending, .. }) => pending,909
+ _ => VecDeque::new(),910
+ };911
+ // leftovers were sent before anything deferred, so they go first912
+ for request in leftovers {913
+ match request {914
+ ActorRequest::Open(reply) => self.open_room(room_id.clone(), reply).await,915
+ ActorRequest::Close(reply) => reply.done(Ok(())),916
+ ActorRequest::Execute(command, reply) => self.route_timeline(command, reply),917
+ }918
+ }919
+ for deferred in pending {920
+ match deferred {921
+ Deferred::Open(reply) => self.open_room(room_id.clone(), reply).await,922
+ Deferred::Close(reply) => self.close_room(room_id.clone(), reply).await,923
+ }924
+ }925
+ self.update_room_subscriptions();926
+ }927
+928
+ fn update_room_subscriptions(&self) {929
+ let room_ids = self930
+ .rooms931
+ .iter()932
+ .filter(|(_, slot)| matches!(slot, RoomSlot::Active { .. }))933
+ .map(|(room_id, _)| room_id.clone())934
+ .collect();935
+ self.subscriptions.send_replace(room_ids);936
+ }937
+938
+ async fn shutdown(&mut self) {939
+ if let Some(handler) = self.verification_handler.take() {940
+ self.client.remove_event_handler(handler);941
+ }942
+ self.port.close();943
+ self.tasks.shutdown().await;944
+ self.rooms.clear();945
+ self.uploads.clear();946
+ self.media.clear().await;947
+ self.sync.stop().await;948
+ self.port949
+ .connection_sender()950
+ .send_replace(ConnectionState::Stopped);951
+ let _ = self952
+ .port953
+ .event_sender()954
+ .send(Event::ConnectionChanged(ConnectionState::Stopped))955
+ .await;956
+ }957
+}958
+959
+fn send_actor(sender: &mpsc::Sender<ActorRequest>, request: ActorRequest) {960
+ if let Err(error) = sender.try_send(request) {961
+ let (request, error) = match error {962
+ mpsc::error::TrySendError::Full(request) => (963
+ request,964
+ Error::new(ErrorKind::RoomBusy, "room command queue is full"),965
+ ),966
+ mpsc::error::TrySendError::Closed(request) => (967
+ request,968
+ Error::new(ErrorKind::RoomNotOpen, "room is not open"),969
+ ),970
+ };971
+ request.into_reply().done(Err(error));972
+ }973
+}974
+975
+fn defer(pending: &mut VecDeque<Deferred>, deferred: Deferred, capacity: usize) {976
+ if pending.len() < capacity {977
+ return pending.push_back(deferred);978
+ }979
+ let (Deferred::Open(reply) | Deferred::Close(reply)) = deferred;980
+ reply.done(Err(Error::new(981
+ ErrorKind::RoomBusy,982
+ "room lifecycle queue is full",983
+ )));984
+}985
+986
+async fn settled(logouts: &mut watch::Receiver<Logouts>) {987
+ let _ = logouts.wait_for(|logouts| logouts.in_flight == 0).await;988
+}989
+990
+fn enqueue<T>(sender: &mpsc::Sender<Job<T>>, job: Job<T>) {991
+ if let Err(error) = sender.try_send(job) {992
+ let (job, error) = match error {993
+ mpsc::error::TrySendError::Full(job) => (994
+ job,995
+ Error::new(ErrorKind::QueueFull, "command queue is full"),996
+ ),997
+ mpsc::error::TrySendError::Closed(job) => (job, Error::interrupted()),998
+ };999
+ job.reply.done(Err(error));1000
+ }1001
+}@@ -0,0 +1,379 @@
1
+use std::{2
+ collections::{HashMap, HashSet, VecDeque},3
+ sync::Arc,4
+};5
+6
+use matrix_sdk::{7
+ Client, Room,8
+ ruma::{9
+ Int,10
+ api::client::room::{11
+ Visibility as SdkVisibility,12
+ create_room::v3::{Request as CreateRoomRequest, RoomPreset as SdkPreset},13
+ },14
+ events::{15
+ AnyGlobalAccountDataEventContent, GlobalAccountDataEventType, MessageLikeEventType,16
+ StateEventType, room::power_levels::RoomPowerLevelsEventContent,17
+ },18
+ serde::Raw,19
+ },20
+};21
+use ruma::{OwnedRoomId, OwnedUserId};22
+use tokio::sync::mpsc;23
+24
+use super::commands::{Job, guarded};25
+use super::executor::TaskSet;26
+use crate::{27
+ CommandOutput, CreateRoomOptions, Error, ErrorKind, PowerLevelChanges, RoomCommand, RoomPreset,28
+ RoomVisibility,29
+};30
+31
+/// state changes that must not race each other. same room, same alias, same dm32
+/// partner or same account data type run in order, everything else runs at once33
+pub(super) enum Mutation {34
+ Room(RoomCommand),35
+ GlobalAccountData {36
+ event_type: GlobalAccountDataEventType,37
+ content: Raw<AnyGlobalAccountDataEventContent>,38
+ },39
+}40
+41
+pub(super) type MutationJob = Job<Mutation>;42
+43
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]44
+enum Key {45
+ Room(OwnedRoomId),46
+ Alias(String),47
+ Direct(OwnedUserId),48
+ AccountData(String),49
+ Independent(u64),50
+}51
+52
+pub(super) async fn run_scheduler(53
+ client: Client,54
+ mut jobs: mpsc::Receiver<MutationJob>,55
+ queue_limit: usize,56
+) {57
+ let fresh_dms = FreshDms::default();58
+ let mut waiting = HashMap::<Key, VecDeque<MutationJob>>::new();59
+ let mut running = HashSet::<Key>::new();60
+ let mut tasks = TaskSet::<Key>::new();61
+ let mut queued = 0_usize;62
+ let mut independent = 0_u64;63
+ let mut receiving = true;64
+ while receiving || !tasks.is_empty() {65
+ tokio::select! {66
+ biased;67
+ Some(finished) = tasks.join_next(), if !tasks.is_empty() => {68
+ // a job's own panic is caught inside it, so this is only ever a key69
+ let Ok(key) = finished else { continue };70
+ let next = waiting.get_mut(&key).and_then(VecDeque::pop_front);71
+ if waiting.get(&key).is_some_and(VecDeque::is_empty) {72
+ waiting.remove(&key);73
+ }74
+ match next {75
+ Some(job) => {76
+ queued -= 1;77
+ spawn(&mut tasks, &client, &fresh_dms, key, job);78
+ }79
+ None => {80
+ running.remove(&key);81
+ }82
+ }83
+ }84
+ job = jobs.recv(), if receiving => {85
+ let Some(job) = job else {86
+ receiving = false;87
+ continue;88
+ };89
+ let key = key(&job.command, client.user_id().map(ruma::UserId::server_name)).unwrap_or_else(|| {90
+ independent += 1;91
+ Key::Independent(independent)92
+ });93
+ if running.insert(key.clone()) {94
+ spawn(&mut tasks, &client, &fresh_dms, key, job);95
+ } else if queued < queue_limit {96
+ waiting.entry(key).or_default().push_back(job);97
+ queued += 1;98
+ } else {99
+ job.reply.done(Err(Error::new(100
+ ErrorKind::QueueFull,101
+ "room command queue is full",102
+ )));103
+ }104
+ }105
+ }106
+ }107
+}108
+109
+/// `server` is ours, since a new alias lives there110
+fn key(mutation: &Mutation, server: Option<&ruma::ServerName>) -> Option<Key> {111
+ let room_id = match mutation {112
+ Mutation::GlobalAccountData { event_type, .. } => {113
+ return Some(Key::AccountData(event_type.to_string()));114
+ }115
+ Mutation::Room(RoomCommand::Join { room, .. }) => {116
+ return Some(match OwnedRoomId::try_from(room.clone()) {117
+ Ok(room_id) => Key::Room(room_id),118
+ Err(alias) => Key::Alias(alias.to_string()),119
+ });120
+ }121
+ Mutation::Room(RoomCommand::CreateDirect(user_id)) => {122
+ return Some(Key::Direct(user_id.clone()));123
+ }124
+ Mutation::Room(RoomCommand::Create(options)) => {125
+ return match (&options.alias_localpart, options.invite.as_slice()) {126
+ (Some(localpart), _) => Some(Key::Alias(format!("#{localpart}:{}", server?))),127
+ (None, [user_id]) if options.direct => Some(Key::Direct(user_id.clone())),128
+ _ => None,129
+ };130
+ }131
+ Mutation::Room(132
+ RoomCommand::Leave(room_id)133
+ | RoomCommand::Forget(room_id)134
+ | RoomCommand::AcceptInvite(room_id)135
+ | RoomCommand::RejectInvite(room_id)136
+ | RoomCommand::InviteUser { room_id, .. }137
+ | RoomCommand::KickUser { room_id, .. }138
+ | RoomCommand::BanUser { room_id, .. }139
+ | RoomCommand::UnbanUser { room_id, .. }140
+ | RoomCommand::SetPowerLevels { room_id, .. }141
+ | RoomCommand::SetUserPowerLevel { room_id, .. }142
+ | RoomCommand::SetAccountData { room_id, .. },143
+ ) => room_id,144
+ Mutation::Room(_) => return None,145
+ };146
+ Some(Key::Room(room_id.clone()))147
+}148
+149
+/// dms made here that the next sync hasn't told the sdk about yet, since `get_dm_room`150
+/// only knows `m.direct` once it comes back round151
+type FreshDms = Arc<std::sync::Mutex<HashMap<OwnedUserId, OwnedRoomId>>>;152
+153
+fn spawn(154
+ tasks: &mut TaskSet<Key>,155
+ client: &Client,156
+ fresh_dms: &FreshDms,157
+ key: Key,158
+ job: MutationJob,159
+) {160
+ let client = client.clone();161
+ let fresh_dms = fresh_dms.clone();162
+ tasks.spawn(async move {163
+ let Job { command, reply } = job;164
+ reply.send(guarded("room command", execute(&client, &fresh_dms, command)).await);165
+ key166
+ });167
+}168
+169
+async fn execute(170
+ client: &Client,171
+ fresh_dms: &FreshDms,172
+ mutation: Mutation,173
+) -> Result<CommandOutput, Error> {174
+ let command = match mutation {175
+ Mutation::GlobalAccountData {176
+ event_type,177
+ content,178
+ } => {179
+ client180
+ .account()181
+ .set_account_data_raw(event_type, content)182
+ .await183
+ .map_err(|source| Error::from_sdk("writing account data failed", source))?;184
+ return Ok(CommandOutput::Done);185
+ }186
+ Mutation::Room(command) => command,187
+ };188
+ let room = |room_id: &OwnedRoomId| client.get_room(room_id).ok_or_else(Error::room_unknown);189
+ let joined = |room: Room| CommandOutput::Room(room.room_id().to_owned());190
+ let sdk = |message: &'static str| move |source| Error::from_sdk(message, source);191
+ match command {192
+ RoomCommand::Join { room, via } => client193
+ .join_room_by_id_or_alias(&room, &via)194
+ .await195
+ .map(joined)196
+ .map_err(sdk("joining room failed")),197
+ RoomCommand::Leave(room_id) => room(&room_id)?198
+ .leave()199
+ .await200
+ .map(done)201
+ .map_err(sdk("leaving room failed")),202
+ RoomCommand::Forget(room_id) => room(&room_id)?203
+ .forget()204
+ .await205
+ .map(done)206
+ .map_err(sdk("forgetting room failed")),207
+ RoomCommand::AcceptInvite(room_id) => room(&room_id)?208
+ .join()209
+ .await210
+ .map(done)211
+ .map_err(sdk("accepting invite failed")),212
+ RoomCommand::RejectInvite(room_id) => room(&room_id)?213
+ .leave()214
+ .await215
+ .map(done)216
+ .map_err(sdk("rejecting invite failed")),217
+ RoomCommand::InviteUser { room_id, user_id } => room(&room_id)?218
+ .invite_user_by_id(&user_id)219
+ .await220
+ .map(done)221
+ .map_err(sdk("inviting user failed")),222
+ RoomCommand::KickUser {223
+ room_id,224
+ user_id,225
+ reason,226
+ } => room(&room_id)?227
+ .kick_user(&user_id, reason.as_deref())228
+ .await229
+ .map(done)230
+ .map_err(sdk("kicking user failed")),231
+ RoomCommand::BanUser {232
+ room_id,233
+ user_id,234
+ reason,235
+ } => room(&room_id)?236
+ .ban_user(&user_id, reason.as_deref())237
+ .await238
+ .map(done)239
+ .map_err(sdk("banning user failed")),240
+ RoomCommand::UnbanUser {241
+ room_id,242
+ user_id,243
+ reason,244
+ } => room(&room_id)?245
+ .unban_user(&user_id, reason.as_deref())246
+ .await247
+ .map(done)248
+ .map_err(sdk("unbanning user failed")),249
+ RoomCommand::SetPowerLevels { room_id, changes } => {250
+ apply_power_level_changes(&room(&room_id)?, changes)251
+ .await252
+ .map(done)253
+ }254
+ RoomCommand::SetUserPowerLevel {255
+ room_id,256
+ user_id,257
+ level,258
+ } => room(&room_id)?259
+ .update_power_levels(vec![(&user_id, power_level(level)?)])260
+ .await261
+ .map(done)262
+ .map_err(sdk("updating user power level failed")),263
+ RoomCommand::SetAccountData {264
+ room_id,265
+ event_type,266
+ content,267
+ } => room(&room_id)?268
+ .set_account_data_raw(event_type, content)269
+ .await270
+ .map(done)271
+ .map_err(sdk("writing room account data failed")),272
+ RoomCommand::Create(options) => client273
+ .create_room(create_room_request(options))274
+ .await275
+ .map(joined)276
+ .map_err(sdk("creating room failed")),277
+ RoomCommand::CreateDirect(user_id) => {278
+ if let Some(existing) = client.get_dm_room(&user_id) {279
+ return Ok(joined(existing));280
+ }281
+ let fresh = fresh_dms282
+ .lock()283
+ .expect("nothing panics holding this")284
+ .get(&user_id)285
+ .cloned();286
+ if let Some(room_id) = fresh.filter(|room_id| client.get_room(room_id).is_some()) {287
+ return Ok(CommandOutput::Room(room_id));288
+ }289
+ let room = client290
+ .create_dm(&user_id)291
+ .await292
+ .map_err(sdk("creating direct room failed"))?;293
+ fresh_dms294
+ .lock()295
+ .expect("nothing panics holding this")296
+ .insert(user_id, room.room_id().to_owned());297
+ Ok(joined(room))298
+ }299
+ _ => Err(Error::new(ErrorKind::Other, "room command was misrouted")),300
+ }301
+}302
+303
+/// one state event carrying every requested change, since the sdk's own patcher is private304
+async fn apply_power_level_changes(room: &Room, changes: PowerLevelChanges) -> Result<(), Error> {305
+ let mut levels = room306
+ .power_levels()307
+ .await308
+ .map_err(|source| Error::from_sdk("reading power levels failed", source.into()))?;309
+ for (target, value) in [310
+ (&mut levels.ban, changes.ban),311
+ (&mut levels.invite, changes.invite),312
+ (&mut levels.kick, changes.kick),313
+ (&mut levels.redact, changes.redact),314
+ (&mut levels.events_default, changes.events_default),315
+ (&mut levels.state_default, changes.state_default),316
+ (&mut levels.users_default, changes.users_default),317
+ (&mut levels.notifications.room, changes.room_notification),318
+ ] {319
+ if let Some(value) = value {320
+ *target = power_level(value)?;321
+ }322
+ }323
+ for (event_type, value) in [324
+ (StateEventType::RoomName.into(), changes.room_name),325
+ (StateEventType::RoomAvatar.into(), changes.room_avatar),326
+ (StateEventType::RoomTopic.into(), changes.room_topic),327
+ (StateEventType::SpaceChild.into(), changes.space_child),328
+ (MessageLikeEventType::Beacon.into(), changes.beacon),329
+ (StateEventType::BeaconInfo.into(), changes.beacon_info),330
+ ] {331
+ if let Some(value) = value {332
+ levels.events.insert(event_type, power_level(value)?);333
+ }334
+ }335
+ let content = RoomPowerLevelsEventContent::try_from(levels).map_err(|source| {336
+ Error::with_source(337
+ ErrorKind::Invalid,338
+ "normalising power levels failed",339
+ source,340
+ )341
+ })?;342
+ room.send_state_event(content)343
+ .await344
+ .map(drop)345
+ .map_err(|source| Error::from_sdk("updating power levels failed", source))346
+}347
+348
+fn done<T>(_: T) -> CommandOutput {349
+ CommandOutput::Done350
+}351
+352
+fn power_level(value: i64) -> Result<Int, Error> {353
+ value.try_into().map_err(|source| {354
+ Error::with_source(355
+ ErrorKind::Invalid,356
+ "power level is outside the Matrix integer range",357
+ source,358
+ )359
+ })360
+}361
+362
+fn create_room_request(options: CreateRoomOptions) -> CreateRoomRequest {363
+ let mut request = CreateRoomRequest::new();364
+ request.name = options.name;365
+ request.topic = options.topic;366
+ request.room_alias_name = options.alias_localpart;367
+ request.invite = options.invite;368
+ request.is_direct = options.direct;369
+ request.visibility = match options.visibility {370
+ RoomVisibility::Private => SdkVisibility::Private,371
+ RoomVisibility::Public => SdkVisibility::Public,372
+ };373
+ request.preset = options.preset.map(|preset| match preset {374
+ RoomPreset::PrivateChat => SdkPreset::PrivateChat,375
+ RoomPreset::TrustedPrivateChat => SdkPreset::TrustedPrivateChat,376
+ RoomPreset::PublicChat => SdkPreset::PublicChat,377
+ });378
+ request379
+}@@ -0,0 +1,47 @@
1
+use std::collections::HashMap;2
+3
+use futures_util::{4
+ FutureExt,5
+ future::{Shared, WeakShared},6
+};7
+use matrix_sdk::Client;8
+use ruma::OwnedRoomId;9
+10
+use super::executor::EngineBoxFuture;11
+use crate::Error;12
+13
+type Flight = Shared<EngineBoxFuture<'static, Result<(), Error>>>;14
+15
+/// one warm-up per room at a time, later asks join the one in flight16
+#[derive(Default)]17
+pub(super) struct Preparations(18
+ HashMap<OwnedRoomId, WeakShared<EngineBoxFuture<'static, Result<(), Error>>>>,19
+);20
+21
+impl Preparations {22
+ pub(super) fn join(&mut self, client: &Client, room_id: OwnedRoomId) -> Flight {23
+ self.0.retain(|_, flight| flight.upgrade().is_some());24
+ if let Some(flight) = self.0.get(&room_id).and_then(WeakShared::upgrade) {25
+ return flight;26
+ }27
+ let work: EngineBoxFuture<'static, _> = Box::pin(prepare(client.clone(), room_id.clone()));28
+ let flight = work.shared();29
+ if let Some(weak) = flight.downgrade() {30
+ self.0.insert(room_id, weak);31
+ }32
+ flight33
+ }34
+}35
+36
+/// the part of the sdk's pre-send encryption work it lets us reach. syncing the members37
+/// has the sdk track them all, and its own background `/keys/query` then catches up38
+/// their devices. the one time key claim and the room key share stay on the send path39
+async fn prepare(client: Client, room_id: OwnedRoomId) -> Result<(), Error> {40
+ let room = client.get_room(&room_id).ok_or_else(Error::room_unknown)?;41
+ if room.encryption_state().is_encrypted() && !room.are_members_synced() {42
+ room.sync_members()43
+ .await44
+ .map_err(|source| Error::from_sdk("syncing room members failed", source))?;45
+ }46
+ Ok(())47
+}@@ -0,0 +1,459 @@
1
+use std::collections::{HashMap, VecDeque};2
+3
+use matrix_sdk::{Room, RoomState};4
+use matrix_sdk_ui::timeline::{RoomExt, Timeline, TimelineFocus, TimelineReadReceiptTracking};5
+use ruma::{OwnedEventId, OwnedRoomId};6
+use tokio::sync::mpsc;7
+8
+use super::executor::{AbortHandle, JoinError, TaskSet, is_panic, resume_unwind};9
+use super::timeline::{SessionContext, TaskGuard, TimelineSession};10
+use crate::{11
+ Error, ErrorKind, Event, TimelineCommand, TimelineId, TypingState, transport::Responder,12
+};13
+14
+pub(super) enum ActorRequest {15
+ /// opening an open room answers with a fresh reset16
+ Open(Responder),17
+ Execute(TimelineCommand, Responder),18
+ Close(Responder),19
+}20
+21
+impl ActorRequest {22
+ pub(super) fn into_reply(self) -> Responder {23
+ match self {24
+ Self::Open(reply) | Self::Execute(_, reply) | Self::Close(reply) => reply,25
+ }26
+ }27
+}28
+29
+pub(super) enum ActorNote {30
+ /// stopping on its own, after a leave or a kick. anything sent its way now comes back in `Exited`31
+ Leaving {32
+ room_id: OwnedRoomId,33
+ generation: u64,34
+ },35
+ Exited {36
+ room_id: OwnedRoomId,37
+ generation: u64,38
+ /// requests that arrived too late to be served, for the engine to answer or replay39
+ leftovers: Vec<ActorRequest>,40
+ },41
+}42
+43
+pub(super) struct RoomActor {44
+ pub(super) room_id: OwnedRoomId,45
+ pub(super) generation: u64,46
+ pub(super) room: Room,47
+ pub(super) requests: mpsc::Receiver<ActorRequest>,48
+ pub(super) notes: mpsc::UnboundedSender<ActorNote>,49
+ pub(super) context: SessionContext,50
+ pub(super) panics: mpsc::UnboundedReceiver<&'static str>,51
+ pub(super) opening: Vec<Responder>,52
+}53
+54
+impl RoomActor {55
+ pub(super) async fn run(mut self) {56
+ self.serve().await;57
+ self.requests.close();58
+ let mut leftovers = Vec::new();59
+ while let Ok(request) = self.requests.try_recv() {60
+ leftovers.push(request);61
+ }62
+ let _ = self.notes.send(ActorNote::Exited {63
+ room_id: self.room_id.clone(),64
+ generation: self.generation,65
+ leftovers,66
+ });67
+ }68
+69
+ async fn serve(&mut self) {70
+ let timeline = match build_timeline(71
+ &self.room,72
+ TimelineFocus::Live {73
+ hide_threaded_events: true,74
+ },75
+ )76
+ .await77
+ {78
+ Ok(timeline) => timeline,79
+ Err(error) => return self.refuse_everything(error).await,80
+ };81
+ let Ok(room_timeline) = TimelineSession::start(82
+ TimelineId::Room(self.room_id.clone()),83
+ timeline,84
+ self.context.clone(),85
+ )86
+ .await87
+ else {88
+ return;89
+ };90
+ for reply in self.opening.drain(..) {91
+ reply.done(Ok(()));92
+ }93
+ let (typing_guard, typing) = self.room.subscribe_to_typing_notifications();94
+ let mut typing_watcher = TaskGuard(self.context.supervisor.spawn(95
+ "typing watcher",96
+ watch_typing(self.room_id.clone(), typing, self.context.events.clone()),97
+ ));98
+ let mut timelines = Timelines {99
+ room: room_timeline,100
+ threads: Threads::new(self.requests.max_capacity()),101
+ work: TaskSet::new(),102
+ };103
+ let close = self.serve_open(&mut timelines).await;104
+ if close.is_none() {105
+ self.requests.close();106
+ let _ = self.notes.send(ActorNote::Leaving {107
+ room_id: self.room_id.clone(),108
+ generation: self.generation,109
+ });110
+ }111
+112
+ // let redactions and receipts that already left finish rather than lie about them113
+ while timelines.work.join_next().await.is_some() {}114
+ drop(typing_guard);115
+ typing_watcher.0.abort();116
+ let _ = (&mut typing_watcher.0).await;117
+ let _ = self118
+ .context119
+ .events120
+ .send(Event::TypingChanged(TypingState {121
+ room_id: self.room_id.clone(),122
+ users: Vec::new(),123
+ }))124
+ .await;125
+ timelines.threads.close().await;126
+ timelines.room.close().await;127
+ if let Some(reply) = close {128
+ reply.done(Ok(()));129
+ }130
+ }131
+132
+ /// returns the close request's responder, if a close is why we stopped133
+ async fn serve_open(&mut self, timelines: &mut Timelines) -> Option<Responder> {134
+ let mut room_info = self.room.subscribe_info();135
+ // a leave that landed while the timeline was building won't come round again136
+ if matches!(self.room.state(), RoomState::Left | RoomState::Banned) {137
+ return None;138
+ }139
+ loop {140
+ tokio::select! {141
+ Some(info) = room_info.next() => {142
+ if matches!(info.state(), RoomState::Left | RoomState::Banned) {143
+ tracing::debug!(room_id = %self.room_id, "room actor stopped because we left the room");144
+ return None;145
+ }146
+ }147
+ request = self.requests.recv() => match request {148
+ None => return None,149
+ Some(ActorRequest::Close(reply)) => return Some(reply),150
+ Some(ActorRequest::Open(reply)) => reply.done(timelines.room.resync().await),151
+ Some(ActorRequest::Execute(command, reply)) => {152
+ timelines.execute(command, reply, &self.room).await;153
+ }154
+ },155
+ Some(built) = timelines.threads.builds.join_next(), if !timelines.threads.builds.is_empty() => {156
+ timelines.thread_built(built, &self.room_id, &self.context).await;157
+ }158
+ Some(label) = self.panics.recv() => panic!("{label} panicked in room {}", self.room_id),159
+ Some(done) = timelines.work.join_next(), if !timelines.work.is_empty() => {160
+ if let Err(error) = done && is_panic(&error) {161
+ tracing::warn!(room_id = %self.room_id, %error, "timeline operation panicked");162
+ }163
+ }164
+ }165
+ }166
+ }167
+168
+ async fn refuse_everything(&mut self, error: matrix_sdk_ui::timeline::Error) {169
+ tracing::warn!(%error, room_id = %self.room_id, "room actor could not build its timeline");170
+ let error = Error::with_source(ErrorKind::Timeline, "room timeline build failed", error);171
+ for reply in self.opening.drain(..) {172
+ reply.done(Err(error.clone()));173
+ }174
+ self.requests.close();175
+ while let Some(request) = self.requests.recv().await {176
+ request.into_reply().done(Err(error.clone()));177
+ }178
+ }179
+}180
+181
+async fn build_timeline(182
+ room: &Room,183
+ focus: TimelineFocus,184
+) -> Result<Timeline, matrix_sdk_ui::timeline::Error> {185
+ room.timeline_builder()186
+ .with_focus(focus)187
+ .track_read_marker_and_receipts(TimelineReadReceiptTracking::AllEvents)188
+ .build()189
+ .await190
+}191
+192
+async fn watch_typing(193
+ room_id: OwnedRoomId,194
+ mut typing: tokio::sync::broadcast::Receiver<Vec<ruma::OwnedUserId>>,195
+ events: crate::transport::EventSender,196
+) {197
+ loop {198
+ match typing.recv().await {199
+ Ok(users) => {200
+ let state = TypingState {201
+ room_id: room_id.clone(),202
+ users,203
+ };204
+ if events.send(Event::TypingChanged(state)).await.is_err() {205
+ return;206
+ }207
+ }208
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {209
+ tracing::warn!(%room_id, skipped, "typing watcher lagged");210
+ }211
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => return,212
+ }213
+ }214
+}215
+216
+struct Timelines {217
+ room: TimelineSession,218
+ threads: Threads,219
+ work: TaskSet<()>,220
+}221
+222
+impl Timelines {223
+ async fn execute(&mut self, command: TimelineCommand, reply: Responder, room: &Room) {224
+ match command {225
+ TimelineCommand::OpenThread { root_event_id, .. } => {226
+ self.threads.open(root_event_id, reply, room);227
+ }228
+ TimelineCommand::CloseThread { root_event_id, .. } => {229
+ reply.done(self.threads.close_one(&root_event_id).await);230
+ }231
+ TimelineCommand::Resync {232
+ timeline_id: TimelineId::Room(_),233
+ } => reply.done(self.room.resync().await),234
+ command => {235
+ let root = command236
+ .timeline_id()237
+ .and_then(TimelineId::thread_root)238
+ .cloned();239
+ let Some(root) = root else {240
+ return self.room.execute(command, reply, &mut self.work).await;241
+ };242
+ if let TimelineCommand::MarkRead {243
+ timeline_id,244
+ event_id,245
+ } = &command246
+ && !self.threads.open.contains_key(&root)247
+ {248
+ let client = room.client();249
+ let (timeline_id, event_id) = (timeline_id.clone(), event_id.clone());250
+ self.work.spawn(async move {251
+ reply.done(252
+ super::commands::mark_read(&client, &timeline_id, event_id).await,253
+ );254
+ });255
+ return;256
+ }257
+ match self.threads.route(root, command, reply) {258
+ Routed::Open(session, TimelineCommand::Resync { .. }, reply) => {259
+ reply.done(session.resync().await);260
+ }261
+ Routed::Open(session, command, reply) => {262
+ session.execute(command, reply, &mut self.work).await;263
+ }264
+ Routed::Queued => {}265
+ }266
+ }267
+ }268
+ }269
+270
+ async fn thread_built(271
+ &mut self,272
+ built: Result<Built, JoinError>,273
+ room_id: &OwnedRoomId,274
+ context: &SessionContext,275
+ ) {276
+ let Some((root, queued, timeline)) = self.threads.built(built) else {277
+ return;278
+ };279
+ let timeline = match timeline {280
+ Ok(timeline) => timeline,281
+ Err(error) => {282
+ tracing::warn!(%error, %room_id, %root, "thread timeline build failed");283
+ let error =284
+ Error::with_source(ErrorKind::Timeline, "thread timeline build failed", error);285
+ for (_, reply) in queued {286
+ reply.done(Err(error.clone()));287
+ }288
+ return;289
+ }290
+ };291
+ let id = TimelineId::thread(room_id.clone(), root.clone());292
+ let Ok(session) = TimelineSession::start(id, timeline, context.clone()).await else {293
+ return;294
+ };295
+ let session = self296
+ .threads297
+ .open298
+ .entry(root)299
+ .insert_entry(session)300
+ .into_mut();301
+ for (command, reply) in queued {302
+ match command {303
+ None => reply.done(Ok(())),304
+ Some(TimelineCommand::Resync { .. }) => reply.done(session.resync().await),305
+ Some(command) => session.execute(command, reply, &mut self.work).await,306
+ }307
+ }308
+ }309
+}310
+311
+type Built = (312
+ OwnedEventId,313
+ u64,314
+ Result<Timeline, matrix_sdk_ui::timeline::Error>,315
+);316
+317
+/// `None` is the open itself, everything else waits behind it318
+type Queued = VecDeque<(Option<TimelineCommand>, Responder)>;319
+320
+enum Routed<'a> {321
+ Open(&'a mut TimelineSession, TimelineCommand, Responder),322
+ Queued,323
+}324
+325
+struct OpeningThread {326
+ generation: u64,327
+ build: AbortHandle,328
+ queued: Queued,329
+}330
+331
+struct Threads {332
+ open: HashMap<OwnedEventId, TimelineSession>,333
+ opening: HashMap<OwnedEventId, OpeningThread>,334
+ builds: TaskSet<Built>,335
+ next_generation: u64,336
+ queued: usize,337
+ capacity: usize,338
+}339
+340
+impl Threads {341
+ fn new(capacity: usize) -> Self {342
+ Self {343
+ open: HashMap::new(),344
+ opening: HashMap::new(),345
+ builds: TaskSet::new(),346
+ next_generation: 0,347
+ queued: 0,348
+ capacity,349
+ }350
+ }351
+352
+ fn enqueue(&mut self, root: &OwnedEventId, command: Option<TimelineCommand>, reply: Responder) {353
+ if self.queued >= self.capacity {354
+ return reply.done(Err(Error::new(355
+ ErrorKind::QueueFull,356
+ "thread command queue is full",357
+ )));358
+ }359
+ let Some(opening) = self.opening.get_mut(root) else {360
+ return reply.done(Err(Error::new(361
+ ErrorKind::TimelineNotOpen,362
+ "thread timeline is not open",363
+ )));364
+ };365
+ opening.queued.push_back((command, reply));366
+ self.queued += 1;367
+ }368
+369
+ fn open(&mut self, root: OwnedEventId, reply: Responder, room: &Room) {370
+ if self.open.contains_key(&root) {371
+ return reply.done(Ok(()));372
+ }373
+ if !self.opening.contains_key(&root) {374
+ let generation = self.next_generation;375
+ self.next_generation = self.next_generation.wrapping_add(1);376
+ let room = room.clone();377
+ let build_root = root.clone();378
+ let build = self.builds.spawn(async move {379
+ let focus = TimelineFocus::Thread {380
+ root_event_id: build_root.clone(),381
+ };382
+ (build_root, generation, build_timeline(&room, focus).await)383
+ });384
+ self.opening.insert(385
+ root.clone(),386
+ OpeningThread {387
+ generation,388
+ build,389
+ queued: VecDeque::new(),390
+ },391
+ );392
+ }393
+ self.enqueue(&root, None, reply);394
+ }395
+396
+ fn route(397
+ &mut self,398
+ root: OwnedEventId,399
+ command: TimelineCommand,400
+ reply: Responder,401
+ ) -> Routed<'_> {402
+ if !self.open.contains_key(&root) {403
+ self.enqueue(&root, Some(command), reply);404
+ return Routed::Queued;405
+ }406
+ let session = self.open.get_mut(&root).expect("thread was just checked");407
+ Routed::Open(session, command, reply)408
+ }409
+410
+ /// `None` when the build belongs to a thread that was closed while it ran411
+ fn built(412
+ &mut self,413
+ built: Result<Built, JoinError>,414
+ ) -> Option<(415
+ OwnedEventId,416
+ Queued,417
+ Result<Timeline, matrix_sdk_ui::timeline::Error>,418
+ )> {419
+ let (root, generation, timeline) = match built {420
+ Ok(built) => built,421
+ Err(error) if error.is_cancelled() => return None,422
+ Err(error) => resume_unwind(error),423
+ };424
+ if self425
+ .opening426
+ .get(&root)427
+ .is_none_or(|opening| opening.generation != generation)428
+ {429
+ return None;430
+ }431
+ let opening = self.opening.remove(&root)?;432
+ self.queued -= opening.queued.len();433
+ Some((root, opening.queued, timeline))434
+ }435
+436
+ async fn close_one(&mut self, root: &OwnedEventId) -> Result<(), Error> {437
+ if let Some(session) = self.open.remove(root) {438
+ session.close().await;439
+ return Ok(());440
+ }441
+ let Some(opening) = self.opening.remove(root) else {442
+ return Err(Error::new(443
+ ErrorKind::TimelineNotOpen,444
+ "thread timeline is not open",445
+ ));446
+ };447
+ opening.build.abort();448
+ self.queued -= opening.queued.len();449
+ Ok(())450
+ }451
+452
+ async fn close(&mut self) {453
+ self.builds.shutdown().await;454
+ self.opening.clear();455
+ for (_, session) in self.open.drain() {456
+ session.close().await;457
+ }458
+ }459
+}@@ -0,0 +1,132 @@
1
+use std::sync::Arc;2
+3
+use matrix_sdk::{BoxFuture, Client, SessionChange};4
+use ruma::OwnedDeviceId;5
+use tokio::sync::{6
+ broadcast::{self, error::RecvError},7
+ mpsc, watch,8
+};9
+10
+use super::Logouts;11
+use crate::{Error, Event, Termination, transport::EventSender};12
+13
+#[cfg(not(target_family = "wasm"))]14
+pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>> + Send + Sync;15
+#[cfg(target_family = "wasm")]16
+pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>>;17
+18
+/// how the engine keeps the saved record current, handed over by `Session::build`19
+pub(crate) struct Persistence {20
+ pub(crate) save: Box<SaveRecord>,21
+ /// what the sdk's own save callback made of each refresh, when the store could write22
+ /// there and then23
+ pub(crate) inline: mpsc::UnboundedReceiver<Result<(), Error>>,24
+}25
+26
+impl Persistence {27
+ async fn persist(&mut self, client: &Client) -> Result<(), Error> {28
+ let mut inline = None;29
+ while let Ok(result) = self.inline.try_recv() {30
+ inline = Some(result);31
+ }32
+ if let Some(Ok(())) = inline {33
+ return Ok(());34
+ }35
+ // an inline save for a newer refresh can land while this one writes, so go again36
+ // until what's on disk is what the client holds37
+ loop {38
+ let tokens = client.session_tokens();39
+ (self.save)(client.clone()).await?;40
+ if client.session_tokens() == tokens {41
+ return Ok(());42
+ }43
+ }44
+ }45
+}46
+47
+pub(super) enum Notice {48
+ Event(Event),49
+ Rejected(Option<OwnedDeviceId>),50
+}51
+52
+/// saves and notices are split, so a full event stream never holds a save back53
+pub(super) async fn session(54
+ client: Client,55
+ mut changes: broadcast::Receiver<SessionChange>,56
+ mut persistence: Option<Persistence>,57
+ notices: mpsc::UnboundedSender<Notice>,58
+ device_id: OwnedDeviceId,59
+ logouts: Arc<watch::Sender<Logouts>>,60
+) {61
+ loop {62
+ let event = match changes.recv().await {63
+ // a missed refresh costs one needless save, a missed rejection comes round again64
+ Ok(SessionChange::TokensRefreshed) | Err(RecvError::Lagged(_)) => {65
+ match persistence.as_mut() {66
+ None => Event::SessionTokensChanged,67
+ Some(persistence) => match persistence.persist(&client).await {68
+ Ok(()) => Event::SessionTokensChanged,69
+ Err(error) => Event::SessionSaveFailed {70
+ message: error.message,71
+ },72
+ },73
+ }74
+ }75
+ Ok(SessionChange::UnknownToken(data)) => {76
+ let soft_logout = data.soft_logout.then_some(device_id);77
+ let mut held = false;78
+ // our own logout revokes the token, so a rejection meanwhile is its doing79
+ logouts.send_if_modified(|logouts| {80
+ held = logouts.in_flight > 0;81
+ if held {82
+ logouts.rejected = Some(soft_logout.clone());83
+ }84
+ held85
+ });86
+ if !held {87
+ let _ = notices.send(Notice::Rejected(soft_logout));88
+ }89
+ return;90
+ }91
+ Err(RecvError::Closed) => return,92
+ };93
+ if notices.send(Notice::Event(event)).is_err() {94
+ return;95
+ }96
+ }97
+}98
+99
+pub(super) async fn session_notices(100
+ mut notices: mpsc::UnboundedReceiver<Notice>,101
+ events: EventSender,102
+ stop: mpsc::UnboundedSender<Termination>,103
+) {104
+ while let Some(notice) = notices.recv().await {105
+ match notice {106
+ Notice::Event(event) => {107
+ if events.send(event).await.is_err() {108
+ return;109
+ }110
+ }111
+ Notice::Rejected(soft_logout) => {112
+ return reject_session(&events, &stop, soft_logout).await;113
+ }114
+ }115
+ }116
+}117
+118
+pub(super) async fn reject_session(119
+ events: &EventSender,120
+ stop: &mpsc::UnboundedSender<Termination>,121
+ soft_logout: Option<OwnedDeviceId>,122
+) {123
+ let _ = events124
+ .send(Event::SessionAccessRejected {125
+ soft_logout: soft_logout.clone(),126
+ })127
+ .await;128
+ let _ = stop.send(match soft_logout {129
+ Some(device_id) => Termination::SoftLoggedOut { device_id },130
+ None => Termination::AccessRevoked,131
+ });132
+}@@ -0,0 +1,463 @@
1
+use std::{collections::HashSet, future::Future, panic::AssertUnwindSafe, sync::Arc};2
+3
+use futures_util::{FutureExt, StreamExt};4
+use matrix_sdk::SendOutsideWasm;5
+use matrix_sdk::{6
+ room::edit::EditedContent,7
+ room::reply::{EnforceThread, Reply as SdkReply},8
+ ruma::{9
+ api::client::receipt::create_receipt::v3::ReceiptType,10
+ events::{11
+ Mentions,12
+ room::message::{13
+ AddMentions, Relation, ReplyWithinThread, RoomMessageEventContentWithoutRelation,14
+ },15
+ },16
+ },17
+};18
+use matrix_sdk_ui::timeline::{Timeline, TimelineEventItemId};19
+use ruma::{OwnedEventId, OwnedUserId};20
+use tokio::sync::mpsc;21
+22
+use super::executor::{EngineBoxFuture, JoinHandle, TaskSet, spawn};23
+use crate::{24
+ Error, ErrorKind, Event, EventRef, HistoryState, MessageContent, MessageEditContent,25
+ MessageMentions, RichText, TimelineCommand, TimelineId,26
+ engine::MediaService,27
+ normalise::{MediaScope, normalise_timeline, normalise_timeline_diffs},28
+ transport::{EventSender, Responder, SendEventError},29
+};30
+31
+const PAGINATION_QUEUE: usize = 32;32
+33
+/// long-lived tasks inside a room actor report panics here so the actor can die loudly34
+#[derive(Clone)]35
+pub(super) struct Supervisor(mpsc::UnboundedSender<&'static str>);36
+37
+impl Supervisor {38
+ pub(super) fn new() -> (Self, mpsc::UnboundedReceiver<&'static str>) {39
+ let (sender, receiver) = mpsc::unbounded_channel();40
+ (Self(sender), receiver)41
+ }42
+43
+ pub(super) fn spawn(44
+ &self,45
+ label: &'static str,46
+ task: impl Future<Output = ()> + SendOutsideWasm + 'static,47
+ ) -> JoinHandle<()> {48
+ let panics = self.0.clone();49
+ spawn(async move {50
+ if AssertUnwindSafe(task).catch_unwind().await.is_err() {51
+ let _ = panics.send(label);52
+ }53
+ })54
+ }55
+}56
+57
+/// aborts its task when dropped, so an actor that dies mid-flight takes its helpers with it58
+pub(super) struct TaskGuard(pub(super) JoinHandle<()>);59
+60
+impl Drop for TaskGuard {61
+ fn drop(&mut self) {62
+ self.0.abort();63
+ }64
+}65
+66
+/// what a timeline session needs from the room that owns it67
+#[derive(Clone)]68
+pub(super) struct SessionContext {69
+ pub(super) events: EventSender,70
+ pub(super) media: Arc<MediaService>,71
+ pub(super) current_user_id: Option<OwnedUserId>,72
+ pub(super) initial_history_limit: u16,73
+ pub(super) supervisor: Supervisor,74
+}75
+76
+pub(super) struct TimelineSession {77
+ pub(super) id: TimelineId,78
+ timeline: Arc<Timeline>,79
+ context: SessionContext,80
+ scope: MediaScope,81
+ watcher: JoinHandle<()>,82
+ pagination: mpsc::Sender<PaginationRequest>,83
+ pagination_worker: JoinHandle<()>,84
+}85
+86
+struct PaginationRequest {87
+ limit: u16,88
+ reply: Option<Responder>,89
+}90
+91
+impl TimelineSession {92
+ pub(super) async fn start(93
+ id: TimelineId,94
+ timeline: Timeline,95
+ context: SessionContext,96
+ ) -> Result<Self, SendEventError> {97
+ let timeline = Arc::new(timeline);98
+ let scope = MediaScope::new();99
+ let watcher = start_watcher(&id, &timeline, &context, &scope).await?;100
+ let (pagination, requests) = mpsc::channel(PAGINATION_QUEUE);101
+ let pagination_worker = context.supervisor.spawn(102
+ "pagination worker",103
+ run_pagination(104
+ requests,105
+ timeline.clone(),106
+ context.events.clone(),107
+ id.clone(),108
+ ),109
+ );110
+ let session = Self {111
+ id,112
+ timeline,113
+ scope,114
+ watcher,115
+ pagination,116
+ pagination_worker,117
+ context,118
+ };119
+ if session.context.initial_history_limit > 0 {120
+ session.paginate(session.context.initial_history_limit, None);121
+ }122
+ Ok(session)123
+ }124
+125
+ /// swaps in a fresh watcher. the old one is dead before the reset goes out,126
+ /// so no stale diff can land after it127
+ pub(super) async fn resync(&mut self) -> Result<(), Error> {128
+ self.watcher.abort();129
+ let _ = (&mut self.watcher).await;130
+ self.watcher = start_watcher(&self.id, &self.timeline, &self.context, &self.scope)131
+ .await132
+ .map_err(|_| Error::new(ErrorKind::Timeline, "timeline reset could not be sent"))?;133
+ Ok(())134
+ }135
+136
+ pub(super) fn paginate(&self, limit: u16, reply: Option<Responder>) {137
+ if let Err(error) = self.pagination.try_send(PaginationRequest { limit, reply }) {138
+ let (request, error) = match error {139
+ mpsc::error::TrySendError::Full(request) => (140
+ request,141
+ Error::new(ErrorKind::QueueFull, "pagination queue is full"),142
+ ),143
+ mpsc::error::TrySendError::Closed(request) => (144
+ request,145
+ Error::new(ErrorKind::TimelineNotOpen, "timeline is closing"),146
+ ),147
+ };148
+ if let Some(reply) = request.reply {149
+ reply.done(Err(error));150
+ }151
+ }152
+ }153
+154
+ /// pending paginations answer `Interrupted` as their responders drop155
+ pub(super) async fn close(mut self) {156
+ self.pagination_worker.abort();157
+ let _ = (&mut self.pagination_worker).await;158
+ self.watcher.abort();159
+ let _ = (&mut self.watcher).await;160
+ // after the watcher, or a last diff could register media for a timeline that's gone161
+ self.context.media.release(&self.scope).await;162
+ let _ = self163
+ .context164
+ .events165
+ .send(Event::TimelineClosed {166
+ timeline_id: self.id.clone(),167
+ })168
+ .await;169
+ }170
+171
+ pub(super) async fn execute(172
+ &self,173
+ command: TimelineCommand,174
+ reply: Responder,175
+ work: &mut TaskSet<()>,176
+ ) {177
+ let timeline = self.timeline.clone();178
+ let thread_root = self.id.thread_root().cloned();179
+ let background: EngineBoxFuture<'static, Result<(), Error>> = match command {180
+ TimelineCommand::Paginate { limit, .. } => return self.paginate(limit, Some(reply)),181
+ // network-bound, so it runs beside the actor instead of queueing the room behind it182
+ TimelineCommand::Redact { target, reason, .. } => Box::pin(async move {183
+ timeline184
+ .redact(&item_id(target), reason.as_deref())185
+ .await186
+ .map_err(timeline_error)187
+ }),188
+ TimelineCommand::ToggleReaction { target, key, .. } => Box::pin(async move {189
+ timeline190
+ .toggle_reaction(&item_id(target), &key)191
+ .await192
+ .map(drop)193
+ .map_err(timeline_error)194
+ }),195
+ TimelineCommand::MarkRead { event_id, .. } => Box::pin(async move {196
+ let receipt_failed =197
+ |source| Error::from_sdk("sending read receipt failed", source);198
+ timeline199
+ .send_single_receipt(ReceiptType::Read, event_id.clone())200
+ .await201
+ .map_err(receipt_failed)?;202
+ if thread_root.is_none() {203
+ timeline204
+ .send_single_receipt(ReceiptType::FullyRead, event_id)205
+ .await206
+ .map_err(receipt_failed)?;207
+ }208
+ Ok(())209
+ }),210
+ command => return reply.done(self.execute_serial(command).await),211
+ };212
+ work.spawn(async move { reply.done(background.await) });213
+ }214
+215
+ /// sends stay on the actor so they hit the send queue in the order they were asked for216
+ async fn execute_serial(&self, command: TimelineCommand) -> Result<(), Error> {217
+ let timeline = &self.timeline;218
+ match command {219
+ TimelineCommand::Send { content, .. } => {220
+ let body = match &content {221
+ MessageContent::Text { content, .. }222
+ | MessageContent::Emote { content, .. }223
+ | MessageContent::Reply { content, .. } => &content.body,224
+ };225
+ if body.trim().is_empty() {226
+ return Err(Error::new(ErrorKind::EmptyBody, "message body is empty"));227
+ }228
+ let content = match content {229
+ MessageContent::Text { content, mentions } => {230
+ message_content(content, mentions, false).with_relation(None)231
+ }232
+ MessageContent::Emote { content, mentions } => {233
+ message_content(content, mentions, true).with_relation(None)234
+ }235
+ MessageContent::Reply {236
+ event_id,237
+ content,238
+ mentions,239
+ notify_replied_to,240
+ } => {241
+ self.reply_content(event_id, content, mentions, notify_replied_to)242
+ .await?243
+ }244
+ };245
+ timeline246
+ .send(content.into())247
+ .await248
+ .map(drop)249
+ .map_err(timeline_error)250
+ }251
+ TimelineCommand::Edit {252
+ target, content, ..253
+ } => {254
+ let (content, mentions, emote) = match content {255
+ MessageEditContent::Text { content, mentions } => (content, mentions, false),256
+ MessageEditContent::Emote { content, mentions } => (content, mentions, true),257
+ };258
+ if content.body.trim().is_empty() {259
+ return Err(Error::new(ErrorKind::EmptyBody, "message body is empty"));260
+ }261
+ timeline262
+ .edit(263
+ &item_id(target),264
+ EditedContent::RoomMessage(message_content(content, mentions, emote)),265
+ )266
+ .await267
+ .map_err(timeline_error)268
+ }269
+ _ => Err(Error::new(270
+ ErrorKind::Other,271
+ "timeline command was misrouted",272
+ )),273
+ }274
+ }275
+276
+ async fn reply_content(277
+ &self,278
+ event_id: OwnedEventId,279
+ content: RichText,280
+ mentions: MessageMentions,281
+ notify_replied_to: bool,282
+ ) -> Result<matrix_sdk::ruma::events::room::message::RoomMessageEventContent, Error> {283
+ let thread_root = self.id.thread_root();284
+ let content = self285
+ .timeline286
+ .room()287
+ .make_reply_event(288
+ message_content(content, mentions, false),289
+ SdkReply {290
+ event_id,291
+ enforce_thread: if thread_root.is_some() {292
+ EnforceThread::Threaded(ReplyWithinThread::Yes)293
+ } else {294
+ EnforceThread::MaybeThreaded295
+ },296
+ add_mentions: if notify_replied_to {297
+ AddMentions::Yes298
+ } else {299
+ AddMentions::No300
+ },301
+ },302
+ )303
+ .await304
+ .map_err(|source| {305
+ Error::with_source(ErrorKind::Timeline, "reply operation failed", source)306
+ })?;307
+ if let Some(thread_root) = thread_root308
+ && !matches!(309
+ content.relates_to.as_ref(),310
+ Some(Relation::Thread(thread)) if &thread.event_id == thread_root311
+ )312
+ {313
+ return Err(Error::new(314
+ ErrorKind::Invalid,315
+ "reply target does not belong to the selected thread",316
+ ));317
+ }318
+ Ok(content)319
+ }320
+}321
+322
+impl Drop for TimelineSession {323
+ fn drop(&mut self) {324
+ self.watcher.abort();325
+ self.pagination_worker.abort();326
+ }327
+}328
+329
+fn item_id(target: EventRef) -> TimelineEventItemId {330
+ match target {331
+ EventRef::Remote(event_id) => TimelineEventItemId::EventId(event_id),332
+ EventRef::Local(transaction_id) => TimelineEventItemId::TransactionId(transaction_id),333
+ }334
+}335
+336
+/// the sdk leaves a reply's target unloaded until someone asks, so the watcher asks, once per event337
+struct ReplyFetcher {338
+ timeline: Arc<Timeline>,339
+ asked: HashSet<OwnedEventId>,340
+ fetches: TaskSet<()>,341
+}342
+343
+impl ReplyFetcher {344
+ fn fetch(&mut self, missing: Vec<OwnedEventId>) {345
+ for event_id in missing {346
+ if self.asked.insert(event_id.clone()) {347
+ let timeline = self.timeline.clone();348
+ self.fetches.spawn(async move {349
+ if let Err(error) = timeline.fetch_details_for_event(&event_id).await {350
+ tracing::debug!(%event_id, %error, "fetching reply details failed");351
+ }352
+ });353
+ }354
+ }355
+ }356
+}357
+358
+async fn start_watcher(359
+ id: &TimelineId,360
+ timeline: &Arc<Timeline>,361
+ context: &SessionContext,362
+ scope: &MediaScope,363
+) -> Result<JoinHandle<()>, SendEventError> {364
+ let (items, mut stream) = timeline.subscribe().await;365
+ let current_user_id = context.current_user_id.clone();366
+ let normalised = normalise_timeline(items.iter(), current_user_id.as_deref(), scope);367
+ context.media.register(normalised.media).await;368
+ context369
+ .events370
+ .send(Event::TimelineReset {371
+ timeline_id: id.clone(),372
+ items: normalised.items,373
+ })374
+ .await?;375
+ let mut replies = ReplyFetcher {376
+ timeline: timeline.clone(),377
+ asked: HashSet::new(),378
+ fetches: TaskSet::new(),379
+ };380
+ replies.fetch(normalised.missing_replies);381
+ let id = id.clone();382
+ let events = context.events.clone();383
+ let media = context.media.clone();384
+ let scope = scope.clone();385
+ Ok(context.supervisor.spawn("timeline watcher", async move {386
+ loop {387
+ let diffs = tokio::select! {388
+ diffs = stream.next() => match diffs {389
+ Some(diffs) => diffs,390
+ None => return,391
+ },392
+ Some(_) = replies.fetches.join_next(), if !replies.fetches.is_empty() => continue,393
+ };394
+ let normalised = normalise_timeline_diffs(diffs, current_user_id.as_deref(), &scope);395
+ media.register(normalised.media).await;396
+ replies.fetch(normalised.missing_replies);397
+ let event = Event::TimelineChanged {398
+ timeline_id: id.clone(),399
+ diffs: normalised.diffs,400
+ };401
+ if events.send(event).await.is_err() {402
+ return;403
+ }404
+ }405
+ }))406
+}407
+408
+async fn run_pagination(409
+ mut requests: mpsc::Receiver<PaginationRequest>,410
+ timeline: Arc<Timeline>,411
+ events: EventSender,412
+ timeline_id: TimelineId,413
+) {414
+ while let Some(PaginationRequest { limit, reply }) = requests.recv().await {415
+ let history = |state| Event::HistoryChanged {416
+ timeline_id: timeline_id.clone(),417
+ state,418
+ };419
+ if events.send(history(HistoryState::Loading)).await.is_err() {420
+ return;421
+ }422
+ let result = timeline423
+ .paginate_backwards(limit.max(1))424
+ .await425
+ .map_err(timeline_error);426
+ let state = match &result {427
+ Ok(true) => HistoryState::StartReached,428
+ Ok(false) => HistoryState::Idle,429
+ Err(error) => HistoryState::Failed(error.to_string()),430
+ };431
+ let _ = events.send(history(state)).await;432
+ if let Some(reply) = reply {433
+ reply.done(result.map(drop));434
+ }435
+ }436
+}437
+438
+fn message_content(439
+ content: RichText,440
+ mentions: MessageMentions,441
+ emote: bool,442
+) -> RoomMessageEventContentWithoutRelation {443
+ let content = match content.formatted_body {444
+ Some(formatted_body) if emote => {445
+ RoomMessageEventContentWithoutRelation::emote_html(content.body, formatted_body)446
+ }447
+ Some(formatted_body) => {448
+ RoomMessageEventContentWithoutRelation::text_html(content.body, formatted_body)449
+ }450
+ None if emote => RoomMessageEventContentWithoutRelation::emote_plain(content.body),451
+ None => RoomMessageEventContentWithoutRelation::text_plain(content.body),452
+ };453
+ let content = content.add_mentions(Mentions::with_user_ids(mentions.user_ids));454
+ if mentions.room {455
+ content.add_mentions(Mentions::with_room_mention())456
+ } else {457
+ content458
+ }459
+}460
+461
+pub(super) fn timeline_error(source: matrix_sdk_ui::timeline::Error) -> Error {462
+ Error::with_source(ErrorKind::Timeline, "timeline operation failed", source)463
+}@@ -0,0 +1,531 @@
1
+use std::{collections::HashSet, sync::Arc, time::Duration};2
+3
+use futures_util::StreamExt;4
+use matrix_sdk::{5
+ Client, Room,6
+ ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},7
+ send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},8
+};9
+use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};10
+use ruma::{OwnedRoomId, OwnedTransactionId, time::Instant};11
+use tokio::sync::{broadcast::error::RecvError, mpsc, watch};12
+13
+use super::executor::TaskSet;14
+use crate::{15
+ ConnectionState, Event, UploadOutcome, encryption::encryption_state, error::classify_sdk_error,16
+ transport::EventSender,17
+};18
+19
+const SYNC_RESTART_MIN: Duration = Duration::from_secs(1);20
+const SYNC_RESTART_MAX: Duration = Duration::from_secs(60);21
+const SEND_QUEUE_RETRY: Duration = Duration::from_secs(5);22
+const SYNC_STABLE: Duration = Duration::from_secs(30);23
+24
+pub(super) async fn room_subscriptions(25
+ sync: Arc<SyncService>,26
+ mut rooms: watch::Receiver<Vec<ruma::OwnedRoomId>>,27
+) {28
+ while rooms.changed().await.is_ok() {29
+ let room_ids = rooms.borrow_and_update().clone();30
+ let room_ids = room_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>();31
+ sync.room_list_service().subscribe_to_rooms(&room_ids).await;32
+ }33
+}34
+35
+/// `states` must be taken before sync starts, a late subscriber never hears the transition it missed36
+pub(super) async fn connection(37
+ mut states: impl futures_util::Stream<Item = SyncState> + Unpin,38
+ sync: Arc<SyncService>,39
+ client: Client,40
+ events: EventSender,41
+ connection: watch::Sender<ConnectionState>,42
+) {43
+ let mut backoff = SYNC_RESTART_MIN;44
+ let mut online_since: Option<Instant> = None;45
+ while let Some(state) = states.next().await {46
+ // only a run that lasted counts as recovered, or a server that answers /versions47
+ // but refuses sync would have us bounce between offline and online forever48
+ let stable = online_since49
+ .take()50
+ .map(|since| since.elapsed() >= SYNC_STABLE);51
+ if stable == Some(true) {52
+ backoff = SYNC_RESTART_MIN;53
+ }54
+ let (state, restart) = match state {55
+ // only ever us stopping it for a restart, nothing a client needs to hear56
+ SyncState::Idle => continue,57
+ SyncState::Running => {58
+ online_since = Some(Instant::now());59
+ // the sdk parks a room queue on any send error and never unparks it60
+ client.send_queue().set_enabled(true).await;61
+ (ConnectionState::Online, false)62
+ }63
+ SyncState::Offline => (ConnectionState::Offline, stable == Some(false)),64
+ SyncState::Terminated => (ConnectionState::Offline, true),65
+ SyncState::Error(error) => {66
+ tracing::warn!(%error, "sync service reported an error");67
+ (ConnectionState::Offline, true)68
+ }69
+ };70
+ if *connection.borrow() != state {71
+ connection.send_replace(state);72
+ if events.send(Event::ConnectionChanged(state)).await.is_err() {73
+ return;74
+ }75
+ }76
+ if restart {77
+ sync.stop().await;78
+ matrix_sdk::sleep::sleep(backoff).await;79
+ backoff = (backoff * 2).min(SYNC_RESTART_MAX);80
+ sync.start().await;81
+ }82
+ }83
+}84
+85
+/// a recoverable failure while we're online is a hiccup, so give the room another go86
+pub(super) async fn send_queue_errors(87
+ client: Client,88
+ mut errors: tokio::sync::broadcast::Receiver<matrix_sdk::send_queue::SendQueueRoomError>,89
+ online: watch::Receiver<ConnectionState>,90
+) {91
+ let mut retries = TaskSet::new();92
+ loop {93
+ let error = tokio::select! {94
+ error = errors.recv() => error,95
+ Some(_) = retries.join_next(), if !retries.is_empty() => continue,96
+ };97
+ match error {98
+ Ok(error) if error.is_recoverable => {99
+ let client = client.clone();100
+ let online = online.clone();101
+ retries.spawn(async move {102
+ matrix_sdk::sleep::sleep(SEND_QUEUE_RETRY).await;103
+ if *online.borrow() == ConnectionState::Online104
+ && let Some(room) = client.get_room(&error.room_id)105
+ {106
+ room.send_queue().set_enabled(true);107
+ }108
+ });109
+ }110
+ Ok(_) | Err(RecvError::Lagged(_)) => {}111
+ Err(RecvError::Closed) => return,112
+ }113
+ }114
+}115
+116
+/// upload progress only goes out on the room's own channel, never the client-wide one.117
+/// the room's sends and cancels come along behind it, so an upload's end can't118
+/// overtake its own progress119
+pub(super) async fn room_uploads(120
+ room: Room,121
+ tracker: mpsc::Sender<RoomUpload>,122
+ ready: watch::Sender<bool>,123
+) {124
+ let room_id = room.room_id().to_owned();125
+ let mut updates = match room.send_queue().subscribe().await {126
+ Ok((_, updates)) => updates,127
+ Err(error) => {128
+ tracing::warn!(%room_id, %error, "reading the media send queue failed");129
+ return;130
+ }131
+ };132
+ ready.send_replace(true);133
+ loop {134
+ let report = match updates.recv().await {135
+ Ok(RoomSendQueueUpdate::MediaUpload {136
+ related_to,137
+ file,138
+ progress,139
+ ..140
+ }) => RoomUpload::Progress {141
+ upload: (room_id.clone(), related_to),142
+ uploaded_bytes: progress.current as u64,143
+ total_bytes: progress.total as u64,144
+ complete: file.is_some() && progress.current == progress.total,145
+ },146
+ Ok(RoomSendQueueUpdate::SentEvent {147
+ transaction_id,148
+ event_id,149
+ }) => RoomUpload::Ended(150
+ (room_id.clone(), transaction_id),151
+ UploadOutcome::Sent { event_id },152
+ ),153
+ Ok(RoomSendQueueUpdate::CancelledLocalEvent { transaction_id }) => {154
+ RoomUpload::Ended((room_id.clone(), transaction_id), UploadOutcome::Cancelled)155
+ }156
+ Ok(_) => continue,157
+ Err(RecvError::Lagged(_)) => RoomUpload::Lagged,158
+ Err(RecvError::Closed) => return,159
+ };160
+ if tracker.send(report).await.is_err() {161
+ return;162
+ }163
+ }164
+}165
+166
+pub(super) enum RoomUpload {167
+ Progress {168
+ upload: Upload,169
+ uploaded_bytes: u64,170
+ total_bytes: u64,171
+ complete: bool,172
+ },173
+ Ended(Upload, UploadOutcome),174
+ /// an end may have been lost with the ticks175
+ Lagged,176
+}177
+178
+/// the one place upload events leave from, so an upload is never reported after it179
+/// ended. progress comes in from the room forwarders, the rest from the client-wide180
+/// channel, which exists before any upload does181
+pub(super) async fn uploads(182
+ client: Client,183
+ mut updates: tokio::sync::broadcast::Receiver<SendQueueUpdate>,184
+ mut rooms_reports: mpsc::Receiver<RoomUpload>,185
+ events: EventSender,186
+) {187
+ let (_, mut rooms) = client.rooms_stream();188
+ let mut tracker = UploadTracker {189
+ live: attachment_echoes(&client).await,190
+ ended: HashSet::new(),191
+ };192
+ loop {193
+ let reports = tokio::select! {194
+ update = updates.recv() => match update {195
+ Ok(update) => tracker.update(update).into_iter().collect(),196
+ Err(RecvError::Lagged(skipped)) => {197
+ tracing::warn!(skipped, "upload tracker lagged");198
+ tracker.resnapshot(attachment_echoes(&client).await)199
+ }200
+ Err(RecvError::Closed) => return,201
+ },202
+ Some(report) = rooms_reports.recv() => match report {203
+ RoomUpload::Lagged => tracker.resnapshot(attachment_echoes(&client).await),204
+ report => tracker.room(report).into_iter().collect(),205
+ },206
+ update = rooms.next() => {207
+ if update.is_none() {208
+ return;209
+ }210
+ tracker.finish_where(211
+ |(room_id, _)| client.get_room(room_id).is_none(),212
+ &UploadOutcome::RoomForgotten,213
+ )214
+ }215
+ };216
+ for event in reports {217
+ if events.send(event).await.is_err() {218
+ return;219
+ }220
+ }221
+ }222
+}223
+224
+type Upload = (OwnedRoomId, OwnedTransactionId);225
+226
+/// everything reported gets exactly one `MediaUploadFinished`. an upload ended from227
+/// anywhere but its room's forwarder goes in `ended` until the forwarder's own end for228
+/// it arrives, since until then the forwarder can still be holding progress for it229
+struct UploadTracker {230
+ live: HashSet<Upload>,231
+ ended: HashSet<Upload>,232
+}233
+234
+impl UploadTracker {235
+ fn update(&mut self, update: SendQueueUpdate) -> Option<Event> {236
+ let room_id = update.room_id;237
+ let (transaction_id, outcome) = match update.update {238
+ RoomSendQueueUpdate::NewLocalEvent(echo) => {239
+ let upload = (room_id, echo.transaction_id);240
+ if is_attachment_echo(&echo.content) && !self.ended.contains(&upload) {241
+ self.live.insert(upload);242
+ }243
+ return None;244
+ }245
+ RoomSendQueueUpdate::SendError {246
+ transaction_id,247
+ error,248
+ is_recoverable,249
+ } => {250
+ let upload = (room_id, transaction_id);251
+ if !self.live.contains(&upload) {252
+ return None;253
+ }254
+ let (room_id, transaction_id) = upload;255
+ return Some(Event::MediaUploadFailed {256
+ room_id,257
+ transaction_id,258
+ kind: classify_sdk_error(&error),259
+ will_retry: is_recoverable,260
+ });261
+ }262
+ RoomSendQueueUpdate::SentEvent {263
+ transaction_id,264
+ event_id,265
+ } => (transaction_id, UploadOutcome::Sent { event_id }),266
+ RoomSendQueueUpdate::CancelledLocalEvent { transaction_id } => {267
+ (transaction_id, UploadOutcome::Cancelled)268
+ }269
+ _ => return None,270
+ };271
+ self.finish((room_id, transaction_id), outcome, true)272
+ }273
+274
+ fn room(&mut self, report: RoomUpload) -> Option<Event> {275
+ match report {276
+ RoomUpload::Progress {277
+ upload,278
+ uploaded_bytes,279
+ total_bytes,280
+ complete,281
+ } => {282
+ if self.ended.contains(&upload) {283
+ return None;284
+ }285
+ self.live.insert(upload.clone());286
+ let (room_id, transaction_id) = upload;287
+ Some(Event::MediaUploadProgress {288
+ room_id,289
+ transaction_id,290
+ uploaded_bytes,291
+ total_bytes,292
+ complete,293
+ })294
+ }295
+ RoomUpload::Ended(upload, outcome) => {296
+ self.ended.remove(&upload);297
+ self.finish(upload, outcome, false)298
+ }299
+ RoomUpload::Lagged => None,300
+ }301
+ }302
+303
+ fn resnapshot(&mut self, current: HashSet<Upload>) -> Vec<Event> {304
+ let reports = self.finish_where(|upload| !current.contains(upload), &UploadOutcome::Missed);305
+ let ended = &self.ended;306
+ self.live307
+ .extend(current.into_iter().filter(|upload| !ended.contains(upload)));308
+ reports309
+ }310
+311
+ fn finish_where(312
+ &mut self,313
+ ends: impl Fn(&Upload) -> bool,314
+ outcome: &UploadOutcome,315
+ ) -> Vec<Event> {316
+ let ending = self317
+ .live318
+ .iter()319
+ .filter(|upload| ends(upload))320
+ .cloned()321
+ .collect::<Vec<_>>();322
+ ending323
+ .into_iter()324
+ .filter_map(|upload| self.finish(upload, outcome.clone(), true))325
+ .collect()326
+ }327
+328
+ fn finish(&mut self, upload: Upload, outcome: UploadOutcome, remember: bool) -> Option<Event> {329
+ if !self.live.remove(&upload) {330
+ return None;331
+ }332
+ if remember {333
+ self.ended.insert(upload.clone());334
+ }335
+ let (room_id, transaction_id) = upload;336
+ Some(Event::MediaUploadFinished {337
+ room_id,338
+ transaction_id,339
+ outcome,340
+ })341
+ }342
+}343
+344
+async fn attachment_echoes(client: &Client) -> HashSet<Upload> {345
+ let Ok(echoes) = client.send_queue().local_echoes().await else {346
+ return HashSet::new();347
+ };348
+ echoes349
+ .into_iter()350
+ .flat_map(|(room_id, echoes)| {351
+ echoes352
+ .into_iter()353
+ .filter(|echo| is_attachment_echo(&echo.content))354
+ .map(move |echo| (room_id.clone(), echo.transaction_id))355
+ })356
+ .collect()357
+}358
+359
+fn is_attachment_echo(content: &LocalEchoContent) -> bool {360
+ let LocalEchoContent::Event {361
+ serialized_event, ..362
+ } = content363
+ else {364
+ return false;365
+ };366
+ matches!(367
+ serialized_event.deserialize(),368
+ Ok(AnyMessageLikeEventContent::RoomMessage(content))369
+ if matches!(370
+ content.msgtype,371
+ MessageType::Audio(_) | MessageType::File(_) | MessageType::Image(_) | MessageType::Video(_)372
+ )373
+ )374
+}375
+376
+pub(super) async fn encryption(client: Client, events: EventSender) {377
+ let Some(device_id) = client.device_id().map(ToOwned::to_owned) else {378
+ tracing::warn!("encryption watcher stopped because the client has no device id");379
+ return;380
+ };381
+ let recovery = client.encryption().recovery();382
+ let backups = client.encryption().backups();383
+ let mut recovery_stream = recovery.state_stream();384
+ let mut backup_stream = backups.state_stream();385
+ let mut verification_stream = client.encryption().verification_state();386
+ let mut recovery_state = recovery.state();387
+ let mut backup_state = backups.state();388
+ let mut verification_state = verification_stream.get();389
+ loop {390
+ let state = encryption_state(391
+ device_id.clone(),392
+ recovery_state,393
+ verification_state,394
+ backup_state,395
+ );396
+ if events.send(Event::EncryptionChanged(state)).await.is_err() {397
+ return;398
+ }399
+ tokio::select! {400
+ Some(update) = recovery_stream.next() => recovery_state = update,401
+ Some(update) = verification_stream.next() => verification_state = update,402
+ Some(update) = backup_stream.next() => {403
+ backup_state = update.unwrap_or_else(|_| backups.state());404
+ }405
+ else => return,406
+ }407
+ }408
+}409
+410
+pub(super) async fn rooms(client: Client, events: EventSender) {411
+ let (_, mut rooms) = client.rooms_stream();412
+ let mut info = client.room_info_notable_update_receiver();413
+ loop {414
+ if events.send(Event::RoomsChanged).await.is_err() {415
+ return;416
+ }417
+ tokio::select! {418
+ update = rooms.next() => if update.is_none() { return },419
+ update = info.recv() => if matches!(update, Err(RecvError::Closed)) { return },420
+ }421
+ // a sync touching fifty rooms is one invalidation, not fifty422
+ while let Ok(_) | Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) =423
+ info.try_recv()424
+ {}425
+ }426
+}427
+428
+pub(super) async fn notification_settings(client: Client, events: EventSender) {429
+ let settings = client.notification_settings().await;430
+ let mut changes = settings.subscribe_to_changes();431
+ while let Ok(()) | Err(RecvError::Lagged(_)) = changes.recv().await {432
+ // room summaries carry the effective mode, and push rule changes never touch room info433
+ for event in [Event::NotificationSettingsChanged, Event::RoomsChanged] {434
+ if events.send(event).await.is_err() {435
+ return;436
+ }437
+ }438
+ }439
+}440
+441
+#[cfg(test)]442
+mod tests {443
+ use matrix_sdk::send_queue::{RoomSendQueueUpdate, SendQueueUpdate};444
+ use ruma::{OwnedTransactionId, event_id, owned_room_id};445
+446
+ use super::{HashSet, RoomUpload, Upload, UploadTracker};447
+ use crate::{Event, UploadOutcome};448
+449
+ fn upload() -> Upload {450
+ (451
+ owned_room_id!("!room:localhost"),452
+ OwnedTransactionId::from("upload"),453
+ )454
+ }455
+456
+ fn tick() -> RoomUpload {457
+ RoomUpload::Progress {458
+ upload: upload(),459
+ uploaded_bytes: 1,460
+ total_bytes: 2,461
+ complete: false,462
+ }463
+ }464
+465
+ fn sent() -> UploadOutcome {466
+ UploadOutcome::Sent {467
+ event_id: event_id!("$sent:localhost").to_owned(),468
+ }469
+ }470
+471
+ fn tracker() -> UploadTracker {472
+ UploadTracker {473
+ live: HashSet::new(),474
+ ended: HashSet::new(),475
+ }476
+ }477
+478
+ fn finished(event: Option<Event>) -> Option<UploadOutcome> {479
+ match event? {480
+ Event::MediaUploadFinished { outcome, .. } => Some(outcome),481
+ _ => None,482
+ }483
+ }484
+485
+ #[test]486
+ fn a_stale_tick_after_a_missed_end_changes_nothing() {487
+ let mut tracker = tracker();488
+ assert!(tracker.room(tick()).is_some());489
+ let missed = tracker.resnapshot(HashSet::new());490
+ assert!(matches!(491
+ missed.as_slice(),492
+ [Event::MediaUploadFinished {493
+ outcome: UploadOutcome::Missed,494
+ ..495
+ }]496
+ ));497
+498
+ assert!(tracker.room(tick()).is_none());499
+ let (room_id, transaction_id) = upload();500
+ let global = SendQueueUpdate {501
+ room_id,502
+ update: RoomSendQueueUpdate::SentEvent {503
+ transaction_id,504
+ event_id: event_id!("$sent:localhost").to_owned(),505
+ },506
+ };507
+ assert!(tracker.update(global).is_none());508
+ assert!(tracker.room(RoomUpload::Ended(upload(), sent())).is_none());509
+ assert!(tracker.live.is_empty() && tracker.ended.is_empty());510
+ }511
+512
+ #[test]513
+ fn an_end_the_tracker_never_knew_about_still_follows_its_progress() {514
+ let mut tracker = tracker();515
+ let (room_id, transaction_id) = upload();516
+ let global = SendQueueUpdate {517
+ room_id,518
+ update: RoomSendQueueUpdate::SentEvent {519
+ transaction_id,520
+ event_id: event_id!("$sent:localhost").to_owned(),521
+ },522
+ };523
+ assert!(tracker.update(global).is_none());524
+ assert!(tracker.room(tick()).is_some());525
+ assert_eq!(526
+ finished(tracker.room(RoomUpload::Ended(upload(), sent()))),527
+ Some(sent())528
+ );529
+ assert!(tracker.live.is_empty() && tracker.ended.is_empty());530
+ }531
+}@@ -0,0 +1,245 @@
1
+use std::sync::Arc;2
+3
+use matrix_sdk::{4
+ HttpError, RefreshTokenError, SendOutsideWasm, SyncOutsideWasm,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
+#[cfg(feature = "serde")]16
+use serde::{Deserialize, Serialize};17
+18
+use crate::InteractiveAuthChallenge;19
+20
+/// the sdk's `SendOutsideWasm` isn't an auto trait, so it can't go in a trait object21
+#[cfg(not(target_family = "wasm"))]22
+pub(crate) type SourceError = dyn std::error::Error + Send + Sync;23
+#[cfg(target_family = "wasm")]24
+pub(crate) type SourceError = dyn std::error::Error;25
+26
+/// the one thing that goes wrong, for commands and queries alike. match on `kind`, show `message`27
+#[derive(Clone, Debug, thiserror::Error)]28
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]29
+#[error("{message}")]30
+#[non_exhaustive]31
+pub struct Error {32
+ pub kind: ErrorKind,33
+ pub message: String,34
+ /// set when the server wants interactive auth before it'll do the thing35
+ pub challenge: Option<Box<InteractiveAuthChallenge>>,36
+ #[source]37
+ #[cfg_attr(feature = "serde", serde(skip))]38
+ source: Option<Arc<SourceError>>,39
+}40
+41
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]42
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]43
+#[non_exhaustive]44
+pub enum ErrorKind {45
+ /// the engine stopped, or the task doing the work died, before answering46
+ Interrupted,47
+ Unauthenticated,48
+ InteractiveAuthRequired,49
+ Invalid,50
+ RoomUnknown,51
+ RoomNotOpen,52
+ TimelineNotOpen,53
+ RoomBusy,54
+ QueueFull,55
+ EmptyBody,56
+ MediaUnknown,57
+ MediaTooLarge,58
+ MediaUnavailable,59
+ VerificationInactive,60
+ VerificationUnsupported,61
+ NoCrossSigningIdentity,62
+ RecoveryRejected,63
+ Network,64
+ Server,65
+ Store,66
+ Timeline,67
+ Encryption,68
+ Other,69
+}70
+71
+impl Error {72
+ pub(crate) fn new(kind: ErrorKind, message: impl Into<String>) -> Self {73
+ Self {74
+ kind,75
+ message: message.into(),76
+ challenge: None,77
+ source: None,78
+ }79
+ }80
+81
+ pub(crate) fn with_source(82
+ kind: ErrorKind,83
+ message: impl Into<String>,84
+ source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,85
+ ) -> Self {86
+ Self {87
+ source: Some(Arc::new(source)),88
+ ..Self::new(kind, message)89
+ }90
+ }91
+92
+ pub(crate) fn interrupted() -> Self {93
+ Self::new(94
+ ErrorKind::Interrupted,95
+ "the engine stopped before the operation finished",96
+ )97
+ }98
+99
+ pub(crate) fn room_unknown() -> Self {100
+ Self::new(101
+ ErrorKind::RoomUnknown,102
+ "room is not available in the local store",103
+ )104
+ }105
+106
+ pub(crate) fn interactive_auth(challenge: InteractiveAuthChallenge) -> Self {107
+ Self {108
+ challenge: Some(Box::new(challenge)),109
+ ..Self::new(110
+ ErrorKind::InteractiveAuthRequired,111
+ "interactive authentication is required",112
+ )113
+ }114
+ }115
+116
+ pub(crate) fn from_sdk(message: impl Into<String>, source: matrix_sdk::Error) -> Self {117
+ Self::with_source(classify_sdk_error(&source), message, source)118
+ }119
+120
+ pub(crate) fn from_http(message: impl Into<String>, source: HttpError) -> Self {121
+ Self::with_source(classify_http_error(&source), message, source)122
+ }123
+124
+ pub(crate) fn from_refresh_token(125
+ message: impl Into<String>,126
+ source: RefreshTokenError,127
+ ) -> Self {128
+ Self::with_source(classify_refresh_token_error(&source), message, source)129
+ }130
+131
+ pub(crate) fn from_recovery(message: impl Into<String>, source: RecoveryError) -> Self {132
+ let kind = match &source {133
+ RecoveryError::Sdk(source) => classify_sdk_error(source),134
+ RecoveryError::SecretStorage(SecretStorageError::Sdk(source)) => {135
+ classify_sdk_error(source)136
+ }137
+ RecoveryError::SecretStorage(SecretStorageError::ImportError { error, .. }) => {138
+ match error {139
+ ImportError::Sdk(source) => classify_sdk_error(source),140
+ _ => ErrorKind::RecoveryRejected,141
+ }142
+ }143
+ RecoveryError::SecretStorage(SecretStorageError::Verification(source)) => {144
+ match source {145
+ ManualVerifyError::Http(source) => classify_http_error(source),146
+ _ => ErrorKind::RecoveryRejected,147
+ }148
+ }149
+ RecoveryError::SecretStorage(SecretStorageError::SecretStorageKey(_)) => {150
+ ErrorKind::RecoveryRejected151
+ }152
+ RecoveryError::SecretStorage(SecretStorageError::Storage(_)) => ErrorKind::Store,153
+ RecoveryError::BackupExistsOnServer | RecoveryError::SecretStorage(_) => {154
+ ErrorKind::RecoveryRejected155
+ }156
+ };157
+ Self::with_source(kind, message, source)158
+ }159
+160
+ pub(crate) fn from_verification_request(161
+ message: impl Into<String>,162
+ source: RequestVerificationError,163
+ ) -> Self {164
+ let kind = match &source {165
+ RequestVerificationError::Sdk(source) => classify_sdk_error(source),166
+ RequestVerificationError::RoomCreation(_) => ErrorKind::Encryption,167
+ };168
+ Self::with_source(kind, message, source)169
+ }170
+}171
+172
+impl PartialEq for Error {173
+ fn eq(&self, other: &Self) -> bool {174
+ self.kind == other.kind175
+ && self.message == other.message176
+ && self.challenge == other.challenge177
+ }178
+}179
+180
+pub(crate) fn classify_sdk_error(source: &matrix_sdk::Error) -> ErrorKind {181
+ match source {182
+ matrix_sdk::Error::Http(source) => classify_http_error(source),183
+ matrix_sdk::Error::OAuth(source) => classify_oauth_error(source),184
+ matrix_sdk::Error::AuthenticationRequired => ErrorKind::Unauthenticated,185
+ matrix_sdk::Error::BadCryptoStoreState186
+ | matrix_sdk::Error::CryptoStoreError(_)187
+ | matrix_sdk::Error::CrossProcessLockError(_)188
+ | matrix_sdk::Error::StateStore(_)189
+ | matrix_sdk::Error::EventCacheStore(_)190
+ | matrix_sdk::Error::MediaStore(_) => ErrorKind::Store,191
+ matrix_sdk::Error::NoOlmMachine192
+ | matrix_sdk::Error::OlmError(_)193
+ | matrix_sdk::Error::MegolmError(_)194
+ | matrix_sdk::Error::DecryptorError(_) => ErrorKind::Encryption,195
+ matrix_sdk::Error::Media(_) => ErrorKind::MediaUnavailable,196
+ matrix_sdk::Error::Timeout => ErrorKind::Network,197
+ _ => ErrorKind::Other,198
+ }199
+}200
+201
+fn classify_http_error(source: &HttpError) -> ErrorKind {202
+ match source {203
+ HttpError::Reqwest(_) => ErrorKind::Network,204
+ HttpError::Api(_) => ErrorKind::Server,205
+ HttpError::RefreshToken(source) => classify_refresh_token_error(source),206
+ HttpError::Cached(source) => classify_http_error(source),207
+ _ => ErrorKind::Other,208
+ }209
+}210
+211
+fn classify_refresh_token_error(source: &RefreshTokenError) -> ErrorKind {212
+ match source {213
+ RefreshTokenError::RefreshTokenRequired => ErrorKind::Unauthenticated,214
+ RefreshTokenError::MatrixAuth(source) => classify_http_error(source),215
+ RefreshTokenError::OAuth(source) => classify_oauth_error(source),216
+ }217
+}218
+219
+fn classify_oauth_error(source: &OAuthError) -> ErrorKind {220
+ let classify_token_request_error = |source: &_| match source {221
+ RequestTokenError::Request(_) => ErrorKind::Network,222
+ RequestTokenError::ServerResponse(_) => ErrorKind::Unauthenticated,223
+ _ => ErrorKind::Other,224
+ };225
+ match source {226
+ OAuthError::Discovery(source) => match source {227
+ OAuthDiscoveryError::Http(source) => classify_http_error(source),228
+ OAuthDiscoveryError::Oidc(source) => classify_token_request_error(source),229
+ _ => ErrorKind::Unauthenticated,230
+ },231
+ OAuthError::RefreshToken(source) => classify_token_request_error(source),232
+ OAuthError::AuthorizationCode(OAuthAuthorizationCodeError::RequestToken(source)) => {233
+ classify_token_request_error(source)234
+ }235
+ OAuthError::AuthorizationCode(_) => ErrorKind::Unauthenticated,236
+ OAuthError::Logout(source) => match source {237
+ OAuthTokenRevocationError::Revoke(RequestTokenError::Request(_)) => ErrorKind::Network,238
+ OAuthTokenRevocationError::Revoke(RequestTokenError::ServerResponse(_)) => {239
+ ErrorKind::Unauthenticated240
+ }241
+ _ => ErrorKind::Other,242
+ },243
+ _ => ErrorKind::Unauthenticated,244
+ }245
+}@@ -0,0 +1,67 @@
1
+//! A compact application backend contract on top of the Matrix Rust SDK.2
+3
+mod encryption;4
+mod engine;5
+mod error;6
+#[cfg(feature = "mirror")]7
+mod mirror;8
+mod model;9
+mod normalise;10
+mod transport;11
+mod uiaa;12
+mod upload;13
+#[cfg(target_family = "wasm")]14
+mod wasm;15
+16
+#[cfg(all(feature = "sqlite", target_family = "wasm"))]17
+compile_error!("sqlite has no filesystem to live on in wasm, use indexeddb");18
+#[cfg(all(feature = "indexeddb", not(target_family = "wasm")))]19
+compile_error!("indexeddb only exists in a browser, use sqlite");20
+21
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]22
+mod account;23
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]24
+mod session_store;25
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]26
+mod store_lock;27
+28
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]29
+pub use account::{30
+ AccountError, OAuthApplicationType, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore,31
+ Session, SessionRecord, SoftLogout, StoreConfig, UnsavedSession,32
+};33
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]34
+pub use session_store::{EncryptedSessionStore, SessionStore};35
+36
+pub use engine::{Engine, EngineConfig, EngineError};37
+pub use error::{Error, ErrorKind};38
+pub use imbl;39
+#[cfg(feature = "mirror")]40
+pub use mirror::{41
+ Mirror, MirrorHandle, SessionStatus, State, StateChange, Subscription, TimelineChange,42
+ TimelineState, Update, UploadFailure, UploadState,43
+};44
+pub use model::{45
+ AccountCommand, AttachmentEncryption, AttachmentUpload, Change, Command, CommandOutput,46
+ ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind,47
+ EngineFailure, Event, EventRef, HistoryState, KeyBackupState, LatestEvent, LifecycleCommand,48
+ Media, MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,49
+ MemberFilter, MemberMembership, MemberSummary, MembershipChange, Message, MessageContent,50
+ MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice,51
+ PowerLevelChanges, Profile, PushNotification, PushNotificationEvent, PushNotificationRoom,52
+ PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState, ReplyDetails,53
+ ReplyReference, Retain, RichText, RoomCategory, RoomCommand, RoomHero, RoomListFilter,54
+ RoomMatcher, RoomMembership, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels,55
+ RoomPreset, RoomSummary, RoomVisibility, SendState, Sender, SpaceChild, Termination,56
+ ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,57
+ TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,58
+ UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,59
+ VerificationState, VerificationTarget,60
+};61
+pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};62
+pub use uiaa::{63
+ InteractiveAuth, InteractiveAuthChallenge, InteractiveAuthFailure, InteractiveAuthFlow,64
+ InteractiveAuthOutcome,65
+};66
+#[cfg(target_family = "wasm")]67
+pub use wasm::on_panic;@@ -0,0 +1,639 @@
1
+use std::{2
+ collections::HashMap,3
+ fmt,4
+ pin::Pin,5
+ sync::{Arc, Mutex, MutexGuard, PoisonError},6
+ task::{Context, Poll},7
+ time::Duration,8
+};9
+10
+use futures_util::{Stream, StreamExt};11
+use imbl::Vector;12
+use matrix_sdk::{13
+ SendOutsideWasm,14
+ executor::{AbortHandle, spawn},15
+};16
+use ruma::{OwnedDeviceId, OwnedRoomId, OwnedTransactionId, OwnedUserId};17
+#[cfg(feature = "serde")]18
+use serde::{Deserialize, Serialize};19
+use tokio::sync::broadcast;20
+21
+use crate::{22
+ ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,23
+ HistoryState, RoomSummary, TimelineCommand, TimelineDiff, TimelineId, TimelineItem,24
+ TypingState, UploadOutcome, VerificationState, VerificationTarget,25
+ engine::executor::EngineBoxFuture,26
+};27
+28
+#[cfg(not(target_family = "wasm"))]29
+type EngineBoxStream<T> = futures_util::stream::BoxStream<'static, T>;30
+#[cfg(target_family = "wasm")]31
+type EngineBoxStream<T> = futures_util::stream::LocalBoxStream<'static, T>;32
+33
+const DEFAULT_CAPACITY: usize = 256;34
+const RESYNC_BACKOFF: Duration = Duration::from_millis(100);35
+const RESYNC_BACKOFF_CAP: Duration = Duration::from_secs(10);36
+37
+/// everything the engine has said so far, folded together38
+#[derive(Clone, Debug, PartialEq)]39
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]40
+#[non_exhaustive]41
+pub struct State {42
+ pub user_id: Option<OwnedUserId>,43
+ pub session: SessionStatus,44
+ pub connection: ConnectionState,45
+ pub encryption: Option<EncryptionState>,46
+ pub verification: Option<VerificationState>,47
+ pub verification_target: Option<VerificationTarget>,48
+ pub rooms: Vector<RoomSummary>,49
+ pub timelines: imbl::HashMap<TimelineId, TimelineState>,50
+ pub typing: imbl::HashMap<OwnedRoomId, Vec<OwnedUserId>>,51
+ pub uploads: imbl::HashMap<OwnedTransactionId, UploadState>,52
+ /// what stopped the engine, if something went wrong53
+ pub engine_failure: Option<EngineFailure>,54
+ /// the last save of the session record that failed, until one succeeds55
+ pub session_save_failure: Option<String>,56
+}57
+58
+#[derive(Clone, Debug, PartialEq)]59
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]60
+#[non_exhaustive]61
+pub struct TimelineState {62
+ pub items: Vector<TimelineItem>,63
+ pub history: HistoryState,64
+ /// a batch didn't fit, so `items` is the last good copy until the next reset65
+ pub diverged: bool,66
+}67
+68
+#[derive(Clone, Debug, Eq, PartialEq)]69
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]70
+#[non_exhaustive]71
+pub struct UploadState {72
+ pub room_id: OwnedRoomId,73
+ pub uploaded_bytes: u64,74
+ pub total_bytes: u64,75
+ pub complete: bool,76
+ pub failure: Option<UploadFailure>,77
+}78
+79
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]80
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]81
+#[non_exhaustive]82
+pub struct UploadFailure {83
+ pub kind: ErrorKind,84
+ pub will_retry: bool,85
+}86
+87
+/// the `Termination` it'll end in, as far as the events tell88
+#[derive(Clone, Debug, Eq, PartialEq)]89
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]90
+#[non_exhaustive]91
+pub enum SessionStatus {92
+ Active,93
+ SoftLoggedOut {94
+ device_id: OwnedDeviceId,95
+ },96
+ AccessRevoked,97
+ LoggedOut,98
+ /// stopped with the session still good99
+ Stopped,100
+}101
+102
+#[derive(Clone, Debug)]103
+#[non_exhaustive]104
+pub struct Update {105
+ /// one more than the update before it, and a `State` carries the one it's current as of106
+ pub sequence: u64,107
+ pub change: StateChange,108
+}109
+110
+#[derive(Clone, Debug)]111
+#[non_exhaustive]112
+pub enum StateChange {113
+ /// start over from this. a subscription opens with one and gets another after lagging114
+ State(Arc<State>),115
+ Authenticated(OwnedUserId),116
+ Session(SessionStatus),117
+ /// the record on disk may now hold a spent refresh token118
+ SessionSaveFailed(String),119
+ /// saved again after `SessionSaveFailed`120
+ SessionSaved,121
+ Connection(ConnectionState),122
+ Encryption(EncryptionState),123
+ Verification(VerificationState),124
+ VerificationTarget(VerificationTarget),125
+ /// room modes arrive with the `Rooms` that follows126
+ NotificationSettings,127
+ Rooms(Vector<RoomSummary>),128
+ Timeline {129
+ timeline_id: TimelineId,130
+ change: TimelineChange,131
+ },132
+ History {133
+ timeline_id: TimelineId,134
+ state: HistoryState,135
+ },136
+ Typing(TypingState),137
+ Upload {138
+ transaction_id: OwnedTransactionId,139
+ upload: UploadState,140
+ },141
+ UploadFinished {142
+ room_id: OwnedRoomId,143
+ transaction_id: OwnedTransactionId,144
+ outcome: UploadOutcome,145
+ },146
+ Failed(EngineFailure),147
+}148
+149
+#[derive(Clone, Debug)]150
+#[non_exhaustive]151
+pub enum TimelineChange {152
+ Reset {153
+ items: Vector<TimelineItem>,154
+ },155
+ /// `items` is what applying `diffs` left156
+ Changed {157
+ diffs: Arc<[TimelineDiff]>,158
+ items: Vector<TimelineItem>,159
+ },160
+ /// a batch didn't fit. a resync is on its way and nothing changes until its reset161
+ Diverged,162
+ Closed,163
+}164
+165
+/// drains the event stream into a `State` that any number of `MirrorHandle`s watch166
+pub struct Mirror {167
+ handle: ClientHandle,168
+ events: EngineBoxStream<Event>,169
+ shared: Arc<Shared>,170
+ resyncs: HashMap<TimelineId, AbortHandle>,171
+ rooms_fetch: Option<AbortHandle>,172
+}173
+174
+impl fmt::Debug for Mirror {175
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {176
+ formatter.debug_struct("Mirror").finish_non_exhaustive()177
+ }178
+}179
+180
+impl Mirror {181
+ /// takes any stream of the engine's events, so a host can still look at them on the way past182
+ pub fn new(183
+ handle: ClientHandle,184
+ events: impl Stream<Item = Event> + SendOutsideWasm + 'static,185
+ ) -> (Self, MirrorHandle) {186
+ Self::with_capacity(handle, events, DEFAULT_CAPACITY)187
+ }188
+189
+ /// `capacity` updates can queue for a subscriber before it lags and starts over. a zero190
+ /// capacity counts as one191
+ pub fn with_capacity(192
+ handle: ClientHandle,193
+ events: impl Stream<Item = Event> + SendOutsideWasm + 'static,194
+ capacity: usize,195
+ ) -> (Self, MirrorHandle) {196
+ let state = State {197
+ user_id: None,198
+ session: SessionStatus::Active,199
+ connection: *handle.connection_state().borrow(),200
+ encryption: handle.encryption_state(),201
+ verification: None,202
+ verification_target: None,203
+ rooms: Vector::new(),204
+ timelines: imbl::HashMap::new(),205
+ typing: imbl::HashMap::new(),206
+ uploads: imbl::HashMap::new(),207
+ engine_failure: None,208
+ session_save_failure: None,209
+ };210
+ let shared = Arc::new(Shared {211
+ published: Mutex::new(Published {212
+ state: Arc::new(state),213
+ sequence: 0,214
+ updates: Some(broadcast::channel(capacity.max(1)).0),215
+ rooms_fetch: RoomsFetch::Idle,216
+ }),217
+ });218
+ #[cfg(not(target_family = "wasm"))]219
+ let events = events.boxed();220
+ #[cfg(target_family = "wasm")]221
+ let events = events.boxed_local();222
+ let mirror = Self {223
+ handle,224
+ events,225
+ shared: shared.clone(),226
+ resyncs: HashMap::new(),227
+ rooms_fetch: None,228
+ };229
+ (mirror, MirrorHandle { shared })230
+ }231
+232
+ /// resolves once the event stream ends, which is just after `Stopped`233
+ pub fn run(mut self) -> EngineBoxFuture<'static, ()> {234
+ Box::pin(async move {235
+ self.refetch_rooms();236
+ while let Some(event) = self.events.next().await {237
+ self.apply(event);238
+ }239
+ })240
+ }241
+242
+ fn apply(&mut self, event: Event) {243
+ match event {244
+ Event::ConnectionChanged(connection) => self.publish(|state| {245
+ state.connection = connection;246
+ Some(StateChange::Connection(connection))247
+ }),248
+ Event::Authenticated { user_id } => self.publish(|state| {249
+ state.user_id = Some(user_id.clone());250
+ Some(StateChange::Authenticated(user_id))251
+ }),252
+ Event::SessionSaveFailed { message } => self.publish(|state| {253
+ state.session_save_failure = Some(message.clone());254
+ Some(StateChange::SessionSaveFailed(message))255
+ }),256
+ Event::SessionTokensChanged => self.publish(|state| {257
+ state.session_save_failure.take()?;258
+ Some(StateChange::SessionSaved)259
+ }),260
+ Event::SessionAccessRejected { soft_logout } => self.publish(|state| {261
+ state.session = match soft_logout {262
+ Some(device_id) => SessionStatus::SoftLoggedOut { device_id },263
+ None => SessionStatus::AccessRevoked,264
+ };265
+ Some(StateChange::Session(state.session.clone()))266
+ }),267
+ Event::LoggedOut => self.publish(|state| {268
+ state.session = SessionStatus::LoggedOut;269
+ Some(StateChange::Session(SessionStatus::LoggedOut))270
+ }),271
+ Event::Stopped => {272
+ self.cancel_resyncs();273
+ self.publish(|state| {274
+ if state.session != SessionStatus::Active {275
+ return None;276
+ }277
+ state.session = SessionStatus::Stopped;278
+ Some(StateChange::Session(SessionStatus::Stopped))279
+ });280
+ }281
+ Event::Failed(failure) => self.publish(|state| {282
+ state.engine_failure = Some(failure.clone());283
+ Some(StateChange::Failed(failure))284
+ }),285
+ Event::NotificationSettingsChanged => {286
+ self.publish(|_| Some(StateChange::NotificationSettings));287
+ }288
+ Event::RoomsChanged => self.refetch_rooms(),289
+ Event::EncryptionChanged(encryption) => self.publish(|state| {290
+ state.encryption = Some(encryption.clone());291
+ Some(StateChange::Encryption(encryption))292
+ }),293
+ Event::VerificationChanged(verification) => self.publish(|state| {294
+ state.verification = Some(verification.clone());295
+ Some(StateChange::Verification(verification))296
+ }),297
+ Event::VerificationTargetChanged(target) => self.publish(|state| {298
+ state.verification_target = Some(target.clone());299
+ Some(StateChange::VerificationTarget(target))300
+ }),301
+ Event::TimelineReset { timeline_id, items } => {302
+ self.cancel_resync(&timeline_id);303
+ self.publish(|state| {304
+ let history = state305
+ .timelines306
+ .get(&timeline_id)307
+ .map_or(HistoryState::Idle, |timeline| timeline.history.clone());308
+ let timeline = TimelineState {309
+ items: items.clone(),310
+ history,311
+ diverged: false,312
+ };313
+ state.timelines.insert(timeline_id.clone(), timeline);314
+ Some(StateChange::Timeline {315
+ timeline_id,316
+ change: TimelineChange::Reset { items },317
+ })318
+ });319
+ }320
+ Event::TimelineChanged { timeline_id, diffs } => self.apply_diffs(timeline_id, diffs),321
+ Event::HistoryChanged {322
+ timeline_id,323
+ state: history,324
+ } => self.publish(|state| {325
+ state.timelines.get_mut(&timeline_id)?.history = history.clone();326
+ Some(StateChange::History {327
+ timeline_id,328
+ state: history,329
+ })330
+ }),331
+ Event::TimelineClosed { timeline_id } => {332
+ self.cancel_resync(&timeline_id);333
+ self.publish(|state| {334
+ state.timelines.remove(&timeline_id)?;335
+ Some(StateChange::Timeline {336
+ timeline_id,337
+ change: TimelineChange::Closed,338
+ })339
+ });340
+ }341
+ Event::TypingChanged(typing) => self.publish(|state| {342
+ if typing.users.is_empty() {343
+ state.typing.remove(&typing.room_id);344
+ } else {345
+ state346
+ .typing347
+ .insert(typing.room_id.clone(), typing.users.clone());348
+ }349
+ Some(StateChange::Typing(typing))350
+ }),351
+ Event::MediaUploadProgress {352
+ room_id,353
+ transaction_id,354
+ uploaded_bytes,355
+ total_bytes,356
+ complete,357
+ } => self.publish(|state| {358
+ let upload = UploadState {359
+ room_id,360
+ uploaded_bytes,361
+ total_bytes,362
+ complete,363
+ failure: None,364
+ };365
+ state.uploads.insert(transaction_id.clone(), upload.clone());366
+ Some(StateChange::Upload {367
+ transaction_id,368
+ upload,369
+ })370
+ }),371
+ Event::MediaUploadFailed {372
+ room_id,373
+ transaction_id,374
+ kind,375
+ will_retry,376
+ } => self.publish(|state| {377
+ let upload = state378
+ .uploads379
+ .entry(transaction_id.clone())380
+ .or_insert_with(|| UploadState {381
+ room_id,382
+ uploaded_bytes: 0,383
+ total_bytes: 0,384
+ complete: false,385
+ failure: None,386
+ });387
+ upload.failure = Some(UploadFailure { kind, will_retry });388
+ Some(StateChange::Upload {389
+ transaction_id,390
+ upload: upload.clone(),391
+ })392
+ }),393
+ Event::MediaUploadFinished {394
+ room_id,395
+ transaction_id,396
+ outcome,397
+ } => self.publish(|state| {398
+ state.uploads.remove(&transaction_id);399
+ Some(StateChange::UploadFinished {400
+ room_id,401
+ transaction_id,402
+ outcome,403
+ })404
+ }),405
+ }406
+ }407
+408
+ fn apply_diffs(&mut self, timeline_id: TimelineId, diffs: Vec<TimelineDiff>) {409
+ let mut diverged = false;410
+ self.publish(|state| {411
+ let Some(timeline) = state.timelines.get_mut(&timeline_id) else {412
+ diverged = true;413
+ state.timelines.insert(414
+ timeline_id.clone(),415
+ TimelineState {416
+ items: Vector::new(),417
+ history: HistoryState::Idle,418
+ diverged: true,419
+ },420
+ );421
+ return Some(StateChange::Timeline {422
+ timeline_id: timeline_id.clone(),423
+ change: TimelineChange::Diverged,424
+ });425
+ };426
+ if timeline.diverged {427
+ return None;428
+ }429
+ let diffs: Arc<[TimelineDiff]> = diffs.into();430
+ let change = match TimelineDiff::try_apply_all(diffs.to_vec(), &mut timeline.items) {431
+ Ok(()) => TimelineChange::Changed {432
+ diffs,433
+ items: timeline.items.clone(),434
+ },435
+ Err(error) => {436
+ tracing::debug!(?timeline_id, length = error.length, "timeline diverged");437
+ timeline.diverged = true;438
+ diverged = true;439
+ TimelineChange::Diverged440
+ }441
+ };442
+ Some(StateChange::Timeline {443
+ timeline_id: timeline_id.clone(),444
+ change,445
+ })446
+ });447
+ if diverged {448
+ let task = spawn(resync(self.handle.clone(), timeline_id.clone()));449
+ self.resyncs.insert(timeline_id, task.abort_handle());450
+ }451
+ }452
+453
+ fn refetch_rooms(&mut self) {454
+ let mut published = self.shared.lock();455
+ match published.rooms_fetch {456
+ RoomsFetch::Idle => {457
+ published.rooms_fetch = RoomsFetch::Running;458
+ drop(published);459
+ let task = spawn(fetch_rooms(self.handle.clone(), self.shared.clone()));460
+ self.rooms_fetch = Some(task.abort_handle());461
+ }462
+ RoomsFetch::Running => published.rooms_fetch = RoomsFetch::Again,463
+ RoomsFetch::Again => {}464
+ }465
+ }466
+467
+ fn cancel_resync(&mut self, timeline_id: &TimelineId) {468
+ if let Some(task) = self.resyncs.remove(timeline_id) {469
+ task.abort();470
+ }471
+ }472
+473
+ fn cancel_resyncs(&mut self) {474
+ for (_, task) in self.resyncs.drain() {475
+ task.abort();476
+ }477
+ }478
+479
+ fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {480
+ self.shared.publish(change);481
+ }482
+}483
+484
+impl Drop for Mirror {485
+ fn drop(&mut self) {486
+ self.cancel_resyncs();487
+ if let Some(task) = self.rooms_fetch.take() {488
+ task.abort();489
+ }490
+ self.shared.lock().updates = None;491
+ }492
+}493
+494
+/// asks until the engine answers, since a busy room or a full queue is only ever for now.495
+/// a reset or a close for the timeline aborts it, and the engine sends a reset before it answers496
+async fn resync(handle: ClientHandle, timeline_id: TimelineId) {497
+ let mut backoff = RESYNC_BACKOFF;498
+ loop {499
+ let command = Command::Timeline(TimelineCommand::Resync {500
+ timeline_id: timeline_id.clone(),501
+ });502
+ match handle.execute(command).await {503
+ Ok(_) => return,504
+ Err(error) if error.kind == ErrorKind::Interrupted => return,505
+ Err(error) => {506
+ tracing::debug!(%error, ?timeline_id, "resync refused, trying again");507
+ matrix_sdk::sleep::sleep(backoff).await;508
+ backoff = (backoff * 2).min(RESYNC_BACKOFF_CAP);509
+ }510
+ }511
+ }512
+}513
+514
+async fn fetch_rooms(handle: ClientHandle, shared: Arc<Shared>) {515
+ loop {516
+ let rooms: Vector<RoomSummary> = handle.rooms().await.into();517
+ shared.publish(|state| {518
+ state.rooms = rooms.clone();519
+ Some(StateChange::Rooms(rooms))520
+ });521
+ let mut published = shared.lock();522
+ if published.rooms_fetch != RoomsFetch::Again {523
+ published.rooms_fetch = RoomsFetch::Idle;524
+ return;525
+ }526
+ published.rooms_fetch = RoomsFetch::Running;527
+ }528
+}529
+530
+/// cheap to clone, one per frontend531
+#[derive(Clone, Debug)]532
+pub struct MirrorHandle {533
+ shared: Arc<Shared>,534
+}535
+536
+impl MirrorHandle {537
+ #[must_use]538
+ pub fn snapshot(&self) -> Arc<State> {539
+ self.shared.lock().state.clone()540
+ }541
+542
+ /// opens with the current state, then every update after it. ends once the mirror stops543
+ #[must_use]544
+ pub fn subscribe(&self) -> Subscription {545
+ let (first, receiving) = self.shared.clone().subscribe();546
+ let rest = futures_util::stream::unfold(receiving, |(shared, receiver)| async move {547
+ let mut receiver = receiver?;548
+ match receiver.recv().await {549
+ Ok(update) => Some((update, (shared, Some(receiver)))),550
+ Err(broadcast::error::RecvError::Lagged(missed)) => {551
+ tracing::debug!(missed, "mirror subscriber lagged, starting it over");552
+ Some(shared.subscribe())553
+ }554
+ Err(broadcast::error::RecvError::Closed) => None,555
+ }556
+ });557
+ let updates = futures_util::stream::once(std::future::ready(first)).chain(rest);558
+ #[cfg(not(target_family = "wasm"))]559
+ let updates = updates.boxed();560
+ #[cfg(target_family = "wasm")]561
+ let updates = updates.boxed_local();562
+ Subscription(updates)563
+ }564
+}565
+566
+pub struct Subscription(EngineBoxStream<Update>);567
+568
+impl fmt::Debug for Subscription {569
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {570
+ formatter571
+ .debug_struct("Subscription")572
+ .finish_non_exhaustive()573
+ }574
+}575
+576
+impl Stream for Subscription {577
+ type Item = Update;578
+579
+ fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Update>> {580
+ self.0.poll_next_unpin(context)581
+ }582
+}583
+584
+#[derive(Debug)]585
+struct Shared {586
+ published: Mutex<Published>,587
+}588
+589
+#[derive(Debug)]590
+struct Published {591
+ state: Arc<State>,592
+ sequence: u64,593
+ /// gone once the mirror stops, which ends every subscription594
+ updates: Option<broadcast::Sender<Update>>,595
+ rooms_fetch: RoomsFetch,596
+}597
+598
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]599
+enum RoomsFetch {600
+ Idle,601
+ Running,602
+ Again,603
+}604
+605
+impl Shared {606
+ fn lock(&self) -> MutexGuard<'_, Published> {607
+ self.published608
+ .lock()609
+ .unwrap_or_else(PoisonError::into_inner)610
+ }611
+612
+ /// state, sequence and send move together under the one lock, which is what lets613
+ /// `subscribe` take a snapshot and a receiver with nothing between them614
+ fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {615
+ let mut published = self.lock();616
+ let Some(change) = change(Arc::make_mut(&mut published.state)) else {617
+ return;618
+ };619
+ published.sequence += 1;620
+ let update = Update {621
+ sequence: published.sequence,622
+ change,623
+ };624
+ if let Some(updates) = &published.updates {625
+ let _ = updates.send(update);626
+ }627
+ }628
+629
+ fn subscribe(self: Arc<Self>) -> (Update, (Arc<Self>, Option<broadcast::Receiver<Update>>)) {630
+ let published = self.lock();631
+ let update = Update {632
+ sequence: published.sequence,633
+ change: StateChange::State(published.state.clone()),634
+ };635
+ let receiver = published.updates.as_ref().map(broadcast::Sender::subscribe);636
+ drop(published);637
+ (update, (self, receiver))638
+ }639
+}@@ -0,0 +1,1854 @@
1
+use std::{fmt, sync::Arc};2
+3
+use imbl::Vector;4
+use ruma::{5
+ OwnedDeviceId, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedRoomOrAliasId,6
+ OwnedServerName, OwnedTransactionId, OwnedUserId,7
+ events::{8
+ AnyGlobalAccountDataEventContent, AnyRoomAccountDataEventContent, AnySyncTimelineEvent,9
+ GlobalAccountDataEventType, RoomAccountDataEventType,10
+ room::member::StrippedRoomMemberEvent,11
+ },12
+ serde::Raw,13
+};14
+#[cfg(feature = "serde")]15
+use serde::{Deserialize, Serialize};16
+17
+/// every command answers exactly once through `ClientHandle::send` or `execute`.18
+/// the event stream only ever carries state19
+#[derive(Debug)]20
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]21
+#[non_exhaustive]22
+pub enum Command {23
+ Account(AccountCommand),24
+ Room(RoomCommand),25
+ Timeline(TimelineCommand),26
+ Media(MediaCommand),27
+ Encryption(EncryptionCommand),28
+ Notification(NotificationCommand),29
+ Lifecycle(LifecycleCommand),30
+}31
+32
+#[derive(Debug)]33
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]34
+#[non_exhaustive]35
+pub enum AccountCommand {36
+ Logout,37
+ SetAccountData {38
+ event_type: GlobalAccountDataEventType,39
+ content: Raw<AnyGlobalAccountDataEventContent>,40
+ },41
+ SetDisplayName(Option<String>),42
+ SetAvatar(Option<OwnedMxcUri>),43
+ /// uploads the image and points the profile at it, same byte cap as any upload44
+ UploadAvatar {45
+ mimetype: String,46
+ bytes: Vec<u8>,47
+ },48
+ RenameDevice {49
+ device_id: OwnedDeviceId,50
+ display_name: String,51
+ },52
+ DeleteDevices {53
+ device_ids: Vec<OwnedDeviceId>,54
+ auth: Option<crate::InteractiveAuth>,55
+ },56
+}57
+58
+#[derive(Clone, Debug)]59
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]60
+#[non_exhaustive]61
+pub enum RoomCommand {62
+ /// the snapshot turns up as `Event::TimelineReset`. opening an open room sends a fresh one63
+ Open(OwnedRoomId),64
+ Close(OwnedRoomId),65
+ Join {66
+ room: OwnedRoomOrAliasId,67
+ via: Vec<OwnedServerName>,68
+ },69
+ Leave(OwnedRoomId),70
+ Forget(OwnedRoomId),71
+ AcceptInvite(OwnedRoomId),72
+ RejectInvite(OwnedRoomId),73
+ InviteUser {74
+ room_id: OwnedRoomId,75
+ user_id: OwnedUserId,76
+ },77
+ KickUser {78
+ room_id: OwnedRoomId,79
+ user_id: OwnedUserId,80
+ reason: Option<String>,81
+ },82
+ BanUser {83
+ room_id: OwnedRoomId,84
+ user_id: OwnedUserId,85
+ reason: Option<String>,86
+ },87
+ UnbanUser {88
+ room_id: OwnedRoomId,89
+ user_id: OwnedUserId,90
+ reason: Option<String>,91
+ },92
+ SetPowerLevels {93
+ room_id: OwnedRoomId,94
+ changes: PowerLevelChanges,95
+ },96
+ SetUserPowerLevel {97
+ room_id: OwnedRoomId,98
+ user_id: OwnedUserId,99
+ level: i64,100
+ },101
+ Create(CreateRoomOptions),102
+ /// hands back the existing dm if there is one103
+ CreateDirect(OwnedUserId),104
+ CancelSend {105
+ room_id: OwnedRoomId,106
+ transaction_id: OwnedTransactionId,107
+ },108
+ RetrySend {109
+ room_id: OwnedRoomId,110
+ transaction_id: OwnedTransactionId,111
+ },112
+ SetTyping {113
+ room_id: OwnedRoomId,114
+ typing: bool,115
+ },116
+ /// the key work a send into an encrypted room would otherwise start with, done now.117
+ /// nothing in a plain room, and a second ask joins the first118
+ PrepareToSend(OwnedRoomId),119
+ SetAccountData {120
+ room_id: OwnedRoomId,121
+ event_type: RoomAccountDataEventType,122
+ content: Raw<AnyRoomAccountDataEventContent>,123
+ },124
+}125
+126
+#[derive(Clone, Debug, Default, Eq, PartialEq)]127
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]128
+#[non_exhaustive]129
+pub struct CreateRoomOptions {130
+ pub name: Option<String>,131
+ pub topic: Option<String>,132
+ pub alias_localpart: Option<String>,133
+ pub invite: Vec<OwnedUserId>,134
+ pub direct: bool,135
+ pub visibility: RoomVisibility,136
+ pub preset: Option<RoomPreset>,137
+}138
+139
+impl CreateRoomOptions {140
+ #[must_use]141
+ pub fn with_name(mut self, name: impl Into<String>) -> Self {142
+ self.name = Some(name.into());143
+ self144
+ }145
+146
+ #[must_use]147
+ pub fn with_topic(mut self, topic: impl Into<String>) -> Self {148
+ self.topic = Some(topic.into());149
+ self150
+ }151
+152
+ #[must_use]153
+ pub fn with_alias_localpart(mut self, localpart: impl Into<String>) -> Self {154
+ self.alias_localpart = Some(localpart.into());155
+ self156
+ }157
+158
+ #[must_use]159
+ pub fn with_invite(mut self, invite: impl IntoIterator<Item = OwnedUserId>) -> Self {160
+ self.invite = invite.into_iter().collect();161
+ self162
+ }163
+164
+ #[must_use]165
+ pub const fn with_direct(mut self, direct: bool) -> Self {166
+ self.direct = direct;167
+ self168
+ }169
+170
+ #[must_use]171
+ pub const fn with_visibility(mut self, visibility: RoomVisibility) -> Self {172
+ self.visibility = visibility;173
+ self174
+ }175
+176
+ #[must_use]177
+ pub const fn with_preset(mut self, preset: RoomPreset) -> Self {178
+ self.preset = Some(preset);179
+ self180
+ }181
+}182
+183
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]184
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]185
+pub enum RoomVisibility {186
+ #[default]187
+ Private,188
+ Public,189
+}190
+191
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]192
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]193
+pub enum RoomPreset {194
+ PrivateChat,195
+ TrustedPrivateChat,196
+ PublicChat,197
+}198
+199
+#[derive(Clone, Debug, Eq, PartialEq)]200
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]201
+#[non_exhaustive]202
+pub enum TimelineCommand {203
+ OpenThread {204
+ room_id: OwnedRoomId,205
+ root_event_id: OwnedEventId,206
+ },207
+ CloseThread {208
+ room_id: OwnedRoomId,209
+ root_event_id: OwnedEventId,210
+ },211
+ /// Re-emit an authoritative reset for an open room or thread without212
+ /// closing it. The reset arrives before this command's terminal result.213
+ Resync {214
+ timeline_id: TimelineId,215
+ },216
+ Paginate {217
+ timeline_id: TimelineId,218
+ limit: u16,219
+ },220
+ Send {221
+ timeline_id: TimelineId,222
+ content: MessageContent,223
+ },224
+ Edit {225
+ timeline_id: TimelineId,226
+ target: EventRef,227
+ content: MessageEditContent,228
+ },229
+ /// redacting your own unsent message just cancels it230
+ Redact {231
+ timeline_id: TimelineId,232
+ target: EventRef,233
+ reason: Option<String>,234
+ },235
+ ToggleReaction {236
+ timeline_id: TimelineId,237
+ target: EventRef,238
+ key: String,239
+ },240
+ /// works on a closed room too, it just can't refuse to move the marker backwards241
+ MarkRead {242
+ timeline_id: TimelineId,243
+ event_id: OwnedEventId,244
+ },245
+}246
+247
+impl TimelineCommand {248
+ #[must_use]249
+ pub const fn room_id(&self) -> &OwnedRoomId {250
+ match self {251
+ Self::OpenThread { room_id, .. } | Self::CloseThread { room_id, .. } => room_id,252
+ Self::Resync { timeline_id }253
+ | Self::Paginate { timeline_id, .. }254
+ | Self::Send { timeline_id, .. }255
+ | Self::Edit { timeline_id, .. }256
+ | Self::Redact { timeline_id, .. }257
+ | Self::ToggleReaction { timeline_id, .. }258
+ | Self::MarkRead { timeline_id, .. } => timeline_id.room_id(),259
+ }260
+ }261
+262
+ #[must_use]263
+ pub const fn timeline_id(&self) -> Option<&TimelineId> {264
+ match self {265
+ Self::OpenThread { .. } | Self::CloseThread { .. } => None,266
+ Self::Resync { timeline_id }267
+ | Self::Paginate { timeline_id, .. }268
+ | Self::Send { timeline_id, .. }269
+ | Self::Edit { timeline_id, .. }270
+ | Self::Redact { timeline_id, .. }271
+ | Self::ToggleReaction { timeline_id, .. }272
+ | Self::MarkRead { timeline_id, .. } => Some(timeline_id),273
+ }274
+ }275
+}276
+277
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]278
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]279
+#[non_exhaustive]280
+pub enum TimelineId {281
+ Room(OwnedRoomId),282
+ Thread {283
+ room_id: OwnedRoomId,284
+ root_event_id: OwnedEventId,285
+ },286
+}287
+288
+impl TimelineId {289
+ #[must_use]290
+ pub const fn room_id(&self) -> &OwnedRoomId {291
+ match self {292
+ Self::Room(room_id) | Self::Thread { room_id, .. } => room_id,293
+ }294
+ }295
+296
+ #[must_use]297
+ pub fn thread(room_id: OwnedRoomId, root_event_id: OwnedEventId) -> Self {298
+ Self::Thread {299
+ room_id,300
+ root_event_id,301
+ }302
+ }303
+304
+ #[must_use]305
+ pub fn thread_root(&self) -> Option<&OwnedEventId> {306
+ match self {307
+ Self::Room(_) => None,308
+ Self::Thread { root_event_id, .. } => Some(root_event_id),309
+ }310
+ }311
+}312
+313
+impl From<OwnedRoomId> for TimelineId {314
+ fn from(room_id: OwnedRoomId) -> Self {315
+ Self::Room(room_id)316
+ }317
+}318
+319
+#[derive(Clone, Debug, Eq, PartialEq)]320
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]321
+#[non_exhaustive]322
+pub enum MediaCommand {323
+ /// succeeds once the send queue has it. progress and failure arrive as events324
+ Upload(AttachmentUpload),325
+}326
+327
+#[derive(Clone, Debug, Eq, PartialEq)]328
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]329
+#[non_exhaustive]330
+pub struct AttachmentUpload {331
+ pub timeline_id: TimelineId,332
+ pub filename: String,333
+ pub mimetype: String,334
+ pub bytes: Vec<u8>,335
+ pub caption: Option<String>,336
+ pub details: Option<UploadDetails>,337
+ pub thumbnail: Option<UploadThumbnail>,338
+}339
+340
+impl AttachmentUpload {341
+ #[must_use]342
+ pub fn new(343
+ timeline_id: impl Into<TimelineId>,344
+ filename: impl Into<String>,345
+ mimetype: impl Into<String>,346
+ bytes: Vec<u8>,347
+ ) -> Self {348
+ Self {349
+ timeline_id: timeline_id.into(),350
+ filename: filename.into(),351
+ mimetype: mimetype.into(),352
+ bytes,353
+ caption: None,354
+ details: None,355
+ thumbnail: None,356
+ }357
+ }358
+}359
+360
+#[derive(Clone, Debug, Eq, PartialEq)]361
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]362
+#[non_exhaustive]363
+pub enum UploadDetails {364
+ Image {365
+ width: Option<u32>,366
+ height: Option<u32>,367
+ blurhash: Option<String>,368
+ },369
+ Video {370
+ width: Option<u32>,371
+ height: Option<u32>,372
+ duration_ms: Option<u64>,373
+ blurhash: Option<String>,374
+ },375
+ Audio {376
+ duration_ms: Option<u64>,377
+ },378
+ Voice {379
+ duration_ms: u64,380
+ waveform: Vec<u16>,381
+ },382
+ File,383
+}384
+385
+#[derive(Clone, Debug, Eq, PartialEq)]386
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]387
+#[non_exhaustive]388
+pub struct UploadThumbnail {389
+ pub bytes: Vec<u8>,390
+ pub mimetype: String,391
+ pub width: u32,392
+ pub height: u32,393
+}394
+395
+impl UploadThumbnail {396
+ #[must_use]397
+ pub fn new(bytes: Vec<u8>, mimetype: impl Into<String>, width: u32, height: u32) -> Self {398
+ Self {399
+ bytes,400
+ mimetype: mimetype.into(),401
+ width,402
+ height,403
+ }404
+ }405
+}406
+407
+#[derive(Eq, PartialEq)]408
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]409
+#[non_exhaustive]410
+pub enum EncryptionCommand {411
+ Recover(String),412
+ CreateKeyBackup,413
+ EnableKeyBackup,414
+ DisableKeyBackup { delete_from_server: bool },415
+ RecoverKeyBackup(String),416
+ StartVerification(VerificationTarget),417
+ Verify(VerificationAction),418
+}419
+420
+impl fmt::Debug for EncryptionCommand {421
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {422
+ match self {423
+ Self::Recover(_) => formatter.debug_tuple("Recover").field(&"...").finish(),424
+ Self::CreateKeyBackup => formatter.write_str("CreateKeyBackup"),425
+ Self::EnableKeyBackup => formatter.write_str("EnableKeyBackup"),426
+ Self::DisableKeyBackup { delete_from_server } => formatter427
+ .debug_struct("DisableKeyBackup")428
+ .field("delete_from_server", delete_from_server)429
+ .finish(),430
+ Self::RecoverKeyBackup(_) => formatter431
+ .debug_tuple("RecoverKeyBackup")432
+ .field(&"...")433
+ .finish(),434
+ Self::StartVerification(target) => formatter435
+ .debug_tuple("StartVerification")436
+ .field(target)437
+ .finish(),438
+ Self::Verify(action) => formatter.debug_tuple("Verify").field(action).finish(),439
+ }440
+ }441
+}442
+443
+#[derive(Clone, Debug, Eq, PartialEq)]444
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]445
+#[non_exhaustive]446
+pub enum NotificationCommand {447
+ SetRoomMode {448
+ room_id: OwnedRoomId,449
+ mode: RoomNotificationMode,450
+ },451
+ ResetRoomMode(OwnedRoomId),452
+ SetDefaultMode {453
+ encrypted: bool,454
+ one_to_one: bool,455
+ mode: RoomNotificationMode,456
+ },457
+ AddKeyword(String),458
+ RemoveKeyword(String),459
+}460
+461
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]462
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]463
+#[non_exhaustive]464
+pub enum LifecycleCommand {465
+ Shutdown,466
+}467
+468
+#[derive(Clone, Debug, Eq, PartialEq)]469
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]470
+#[non_exhaustive]471
+pub enum CommandOutput {472
+ Done,473
+ Room(OwnedRoomId),474
+}475
+476
+#[derive(Clone, Debug, Eq, PartialEq)]477
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]478
+#[non_exhaustive]479
+pub enum MessageContent {480
+ Text {481
+ content: RichText,482
+ mentions: MessageMentions,483
+ },484
+ Emote {485
+ content: RichText,486
+ mentions: MessageMentions,487
+ },488
+ Reply {489
+ event_id: OwnedEventId,490
+ content: RichText,491
+ mentions: MessageMentions,492
+ notify_replied_to: bool,493
+ },494
+}495
+496
+impl MessageContent {497
+ #[must_use]498
+ pub fn text(body: impl Into<String>) -> Self {499
+ Self::Text {500
+ content: RichText::plain(body),501
+ mentions: MessageMentions::default(),502
+ }503
+ }504
+505
+ #[must_use]506
+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {507
+ Self::Text {508
+ content: RichText::html(body, formatted_body),509
+ mentions: MessageMentions::default(),510
+ }511
+ }512
+513
+ #[must_use]514
+ pub fn emote(content: RichText, mentions: MessageMentions) -> Self {515
+ Self::Emote { content, mentions }516
+ }517
+518
+ #[must_use]519
+ pub fn reply(event_id: OwnedEventId, content: RichText, mentions: MessageMentions) -> Self {520
+ Self::Reply {521
+ event_id,522
+ content,523
+ mentions,524
+ notify_replied_to: true,525
+ }526
+ }527
+528
+ #[must_use]529
+ pub fn reply_without_notification(530
+ event_id: OwnedEventId,531
+ content: RichText,532
+ mentions: MessageMentions,533
+ ) -> Self {534
+ Self::Reply {535
+ event_id,536
+ content,537
+ mentions,538
+ notify_replied_to: false,539
+ }540
+ }541
+}542
+543
+#[derive(Clone, Debug, Eq, PartialEq)]544
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]545
+#[non_exhaustive]546
+pub enum MessageEditContent {547
+ Text {548
+ content: RichText,549
+ mentions: MessageMentions,550
+ },551
+ Emote {552
+ content: RichText,553
+ mentions: MessageMentions,554
+ },555
+}556
+557
+impl MessageEditContent {558
+ #[must_use]559
+ pub fn text(body: impl Into<String>) -> Self {560
+ Self::Text {561
+ content: RichText::plain(body),562
+ mentions: MessageMentions::default(),563
+ }564
+ }565
+566
+ #[must_use]567
+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {568
+ Self::Text {569
+ content: RichText::html(body, formatted_body),570
+ mentions: MessageMentions::default(),571
+ }572
+ }573
+574
+ #[must_use]575
+ pub fn emote(content: RichText, mentions: MessageMentions) -> Self {576
+ Self::Emote { content, mentions }577
+ }578
+}579
+580
+#[derive(Clone, Debug, Default, Eq, PartialEq)]581
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]582
+#[non_exhaustive]583
+pub struct RichText {584
+ pub body: String,585
+ pub formatted_body: Option<String>,586
+}587
+588
+impl RichText {589
+ #[must_use]590
+ pub fn plain(body: impl Into<String>) -> Self {591
+ Self {592
+ body: body.into(),593
+ formatted_body: None,594
+ }595
+ }596
+597
+ #[must_use]598
+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {599
+ Self {600
+ body: body.into(),601
+ formatted_body: Some(formatted_body.into()),602
+ }603
+ }604
+}605
+606
+#[derive(Clone, Debug, Default, Eq, PartialEq)]607
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]608
+#[non_exhaustive]609
+pub struct MessageMentions {610
+ pub user_ids: Vec<OwnedUserId>,611
+ pub room: bool,612
+}613
+614
+impl MessageMentions {615
+ #[must_use]616
+ pub fn with_user_ids(user_ids: impl IntoIterator<Item = OwnedUserId>) -> Self {617
+ Self {618
+ user_ids: user_ids.into_iter().collect(),619
+ room: false,620
+ }621
+ }622
+623
+ #[must_use]624
+ pub fn room() -> Self {625
+ Self {626
+ user_ids: Vec::new(),627
+ room: true,628
+ }629
+ }630
+}631
+632
+#[derive(Clone, Debug, Eq, PartialEq)]633
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]634
+#[non_exhaustive]635
+pub enum Event {636
+ ConnectionChanged(ConnectionState),637
+ Authenticated {638
+ user_id: OwnedUserId,639
+ },640
+ /// the tokens rotated. with a `SessionStore` the record is already saved by now,641
+ /// without one persist a fresh `ClientHandle::session_record()` atomically642
+ SessionTokensChanged,643
+ /// the rotated tokens didn't reach the `SessionStore`, so the saved record may not644
+ /// restore. the engine tries again on the next rotation645
+ SessionSaveFailed {646
+ message: String,647
+ },648
+ /// the server stopped honouring our token and the engine stops. a soft logout649
+ /// names the device to log in again as, keeping the store. otherwise the record650
+ /// and the store are both dead651
+ SessionAccessRejected {652
+ soft_logout: Option<OwnedDeviceId>,653
+ },654
+ NotificationSettingsChanged,655
+ VerificationTargetChanged(VerificationTarget),656
+ RoomsChanged,657
+ /// replaces whatever you had for this timeline. every diff after it applies to it658
+ TimelineReset {659
+ timeline_id: TimelineId,660
+ items: Vector<TimelineItem>,661
+ },662
+ TimelineChanged {663
+ timeline_id: TimelineId,664
+ diffs: Vec<TimelineDiff>,665
+ },666
+ HistoryChanged {667
+ timeline_id: TimelineId,668
+ state: HistoryState,669
+ },670
+ /// the timeline was closed, or its room was left, and nothing more comes for it671
+ /// until another `TimelineReset`672
+ TimelineClosed {673
+ timeline_id: TimelineId,674
+ },675
+ /// a room that stops being watched says so with an empty `users`676
+ TypingChanged(TypingState),677
+ MediaUploadProgress {678
+ room_id: OwnedRoomId,679
+ transaction_id: OwnedTransactionId,680
+ uploaded_bytes: u64,681
+ total_bytes: u64,682
+ complete: bool,683
+ },684
+ /// not the end of it. `will_retry` means the send queue has another go by itself,685
+ /// otherwise it waits for `retry_send` or `cancel_send`686
+ MediaUploadFailed {687
+ room_id: OwnedRoomId,688
+ transaction_id: OwnedTransactionId,689
+ kind: crate::ErrorKind,690
+ will_retry: bool,691
+ },692
+ /// an upload that turned up in a progress or failure event gets exactly one of693
+ /// these while the engine runs, and nothing about it after. stopping ends694
+ /// nothing, `Stopped` covers whatever is still going and the sdk resumes it next run695
+ MediaUploadFinished {696
+ room_id: OwnedRoomId,697
+ transaction_id: OwnedTransactionId,698
+ outcome: UploadOutcome,699
+ },700
+ EncryptionChanged(EncryptionState),701
+ VerificationChanged(VerificationState),702
+ /// logout went through and the engine stops. delete the record and the store703
+ LoggedOut,704
+ Failed(EngineFailure),705
+ /// always the last event. keep the saved record unless `LoggedOut` or a706
+ /// hard `SessionAccessRejected` came first. commands still in flight answer `Interrupted`707
+ Stopped,708
+}709
+710
+#[derive(Clone, Debug, Eq, PartialEq)]711
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]712
+#[non_exhaustive]713
+pub enum UploadOutcome {714
+ Sent {715
+ event_id: OwnedEventId,716
+ },717
+ Cancelled,718
+ /// its room was forgotten, and the queue with it719
+ RoomForgotten,720
+ /// the sdk's update channel overflowed while it ended, so whether it was sent is unknown721
+ Missed,722
+}723
+724
+/// why `Engine::run` came back, when nothing went wrong725
+#[derive(Clone, Debug, Eq, PartialEq)]726
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]727
+#[non_exhaustive]728
+pub enum Termination {729
+ ShutDown,730
+ /// every `ClientHandle` was dropped, so nobody could have asked for anything else731
+ HandlesDropped,732
+ LoggedOut,733
+ /// the server rejected our token for good734
+ AccessRevoked,735
+ /// the server wants this device to log in again, and its e2ee keys live in the store736
+ SoftLoggedOut {737
+ device_id: OwnedDeviceId,738
+ },739
+}740
+741
+impl Termination {742
+ #[must_use]743
+ pub const fn retain(&self) -> Retain {744
+ match self {745
+ Self::ShutDown | Self::HandlesDropped => Retain::RecordAndStore,746
+ Self::SoftLoggedOut { .. } => Retain::Store,747
+ Self::LoggedOut | Self::AccessRevoked => Retain::Nothing,748
+ }749
+ }750
+}751
+752
+/// what's still worth keeping of the saved record and the store753
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]754
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]755
+pub enum Retain {756
+ RecordAndStore,757
+ /// keep the store and a record naming the device to log in again as758
+ Store,759
+ Nothing,760
+}761
+762
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]763
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]764
+#[non_exhaustive]765
+pub enum ConnectionState {766
+ Idle,767
+ Connecting,768
+ Online,769
+ Offline,770
+ Stopped,771
+}772
+773
+#[derive(Clone, Debug, Eq, PartialEq)]774
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]775
+#[non_exhaustive]776
+pub struct RoomSummary {777
+ pub room_id: OwnedRoomId,778
+ pub name: String,779
+ pub membership: RoomMembership,780
+ pub unread_messages: u64,781
+ pub unread_mentions: u64,782
+ /// unread events the push rules said to notify about, which is neither of the above783
+ pub unread_notifications: u64,784
+ pub encrypted: bool,785
+ pub space: bool,786
+ pub direct: bool,787
+ pub favourite: bool,788
+ pub low_priority: bool,789
+ pub avatar: Option<OwnedMxcUri>,790
+ pub latest_event_timestamp_ms: Option<u64>,791
+ pub latest_event: Option<LatestEvent>,792
+ pub notification_mode: Option<RoomNotificationMode>,793
+ pub notification_mode_custom: bool,794
+ pub topic: Option<String>,795
+ pub canonical_alias: Option<OwnedRoomAliasId>,796
+ pub joined_members: u64,797
+ pub invited_members: u64,798
+ /// who a nameless room gets named after799
+ pub heroes: Vec<RoomHero>,800
+ /// set once the room has been upgraded, this is where everyone went801
+ pub successor: Option<OwnedRoomId>,802
+ pub inviter: Option<OwnedUserId>,803
+ /// joined spaces that list this room as a child804
+ pub parent_spaces: Vec<OwnedRoomId>,805
+ /// for a space, the children it lists, joined or not806
+ pub children: Vec<OwnedRoomId>,807
+}808
+809
+#[derive(Clone, Debug, Eq, PartialEq)]810
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]811
+#[non_exhaustive]812
+pub struct RoomHero {813
+ pub user_id: OwnedUserId,814
+ pub display_name: Option<String>,815
+ pub avatar: Option<OwnedMxcUri>,816
+}817
+818
+/// enough for a room list preview line. `kind` and `body` are only set for messages,819
+/// and an edit shows its new text820
+#[derive(Clone, Debug, Eq, PartialEq)]821
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]822
+#[non_exhaustive]823
+pub struct LatestEvent {824
+ pub sender: Option<OwnedUserId>,825
+ pub kind: Option<MessageKind>,826
+ pub body: Option<String>,827
+ pub timestamp_ms: Option<u64>,828
+ pub send_state: SendState,829
+}830
+831
+#[derive(Clone, Debug, Eq, PartialEq)]832
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]833
+#[non_exhaustive]834
+pub struct Profile {835
+ pub user_id: OwnedUserId,836
+ pub display_name: Option<String>,837
+ pub avatar: Option<OwnedMxcUri>,838
+}839
+840
+/// one room in a space's hierarchy, as the server describes it, which may be a room you're not in841
+#[derive(Clone, Debug, Eq, PartialEq)]842
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]843
+#[non_exhaustive]844
+pub struct SpaceChild {845
+ pub room_id: OwnedRoomId,846
+ pub name: String,847
+ pub topic: Option<String>,848
+ pub avatar: Option<OwnedMxcUri>,849
+ pub canonical_alias: Option<OwnedRoomAliasId>,850
+ pub joined_members: u64,851
+ pub space: bool,852
+ pub children_count: u64,853
+ pub membership: Option<RoomMembership>,854
+ pub via: Vec<OwnedServerName>,855
+ pub suggested: bool,856
+ pub world_readable: Option<bool>,857
+ pub guest_can_join: bool,858
+}859
+860
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]861
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]862
+#[non_exhaustive]863
+pub enum RoomNotificationMode {864
+ AllMessages,865
+ MentionsAndKeywordsOnly,866
+ Mute,867
+}868
+869
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]870
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]871
+#[non_exhaustive]872
+pub struct RoomNotificationSettings {873
+ pub mode: RoomNotificationMode,874
+ pub custom: bool,875
+}876
+877
+#[derive(Clone, Debug)]878
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]879
+#[non_exhaustive]880
+pub enum PushNotificationStatus {881
+ Event(Box<PushNotification>),882
+ NotFound,883
+ FilteredOut,884
+ Redacted,885
+}886
+887
+#[derive(Clone, Debug)]888
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]889
+#[non_exhaustive]890
+pub struct PushNotification {891
+ pub event_id: OwnedEventId,892
+ pub event: PushNotificationEvent,893
+ pub sender: PushNotificationSender,894
+ pub room: PushNotificationRoom,895
+ pub noisy: Option<bool>,896
+ pub has_mention: Option<bool>,897
+ pub thread_id: Option<OwnedEventId>,898
+}899
+900
+#[derive(Clone, Debug, Eq, PartialEq)]901
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]902
+#[non_exhaustive]903
+pub struct PushNotificationSender {904
+ pub user_id: OwnedUserId,905
+ pub display_name: Option<String>,906
+ pub avatar_url: Option<OwnedMxcUri>,907
+ pub name_ambiguous: bool,908
+}909
+910
+#[derive(Clone, Debug, Eq, PartialEq)]911
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]912
+#[non_exhaustive]913
+pub struct PushNotificationRoom {914
+ pub room_id: OwnedRoomId,915
+ pub name: String,916
+ pub avatar_url: Option<OwnedMxcUri>,917
+ pub canonical_alias: Option<OwnedRoomAliasId>,918
+ pub topic: Option<String>,919
+ pub encrypted: Option<bool>,920
+ pub direct: bool,921
+ pub joined_members_count: u64,922
+ pub service_members: Vec<OwnedUserId>,923
+ pub active_service_members_count: u64,924
+ pub space: bool,925
+}926
+927
+#[derive(Clone, Debug)]928
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]929
+#[non_exhaustive]930
+pub enum PushNotificationEvent {931
+ Timeline(Raw<AnySyncTimelineEvent>),932
+ Invite(Raw<StrippedRoomMemberEvent>),933
+}934
+935
+#[derive(Clone, Debug, Eq, PartialEq)]936
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]937
+#[non_exhaustive]938
+pub struct RoomPowerLevels {939
+ pub ban: i64,940
+ pub invite: i64,941
+ pub kick: i64,942
+ pub redact: i64,943
+ pub events_default: i64,944
+ pub state_default: i64,945
+ pub users_default: i64,946
+ pub room_notification: i64,947
+ pub users: Vec<(OwnedUserId, i64)>,948
+ pub events: Vec<(String, i64)>,949
+ /// in rooms from version 12 the creators outrank everyone and aren't listed in `users`950
+ pub creators: Vec<OwnedUserId>,951
+}952
+953
+#[derive(Clone, Debug, Default, Eq, PartialEq)]954
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]955
+#[non_exhaustive]956
+pub struct PowerLevelChanges {957
+ pub ban: Option<i64>,958
+ pub invite: Option<i64>,959
+ pub kick: Option<i64>,960
+ pub redact: Option<i64>,961
+ pub events_default: Option<i64>,962
+ pub state_default: Option<i64>,963
+ pub users_default: Option<i64>,964
+ pub room_notification: Option<i64>,965
+ pub room_name: Option<i64>,966
+ pub room_avatar: Option<i64>,967
+ pub room_topic: Option<i64>,968
+ pub space_child: Option<i64>,969
+ pub beacon: Option<i64>,970
+ pub beacon_info: Option<i64>,971
+}972
+973
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]974
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]975
+#[non_exhaustive]976
+pub enum RoomMembership {977
+ Joined,978
+ Invited,979
+ Left,980
+ Knocked,981
+ Banned,982
+}983
+984
+#[derive(Clone, Debug, Default, Eq, PartialEq)]985
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]986
+#[non_exhaustive]987
+pub struct RoomListFilter {988
+ pub membership: Option<RoomMembership>,989
+ pub category: Option<RoomCategory>,990
+ pub favourite: Option<bool>,991
+ pub unread_only: bool,992
+ pub search: Option<String>,993
+}994
+995
+impl RoomListFilter {996
+ #[must_use]997
+ pub fn with_membership(mut self, membership: RoomMembership) -> Self {998
+ self.membership = Some(membership);999
+ self1000
+ }1001
+1002
+ #[must_use]1003
+ pub fn with_category(mut self, category: RoomCategory) -> Self {1004
+ self.category = Some(category);1005
+ self1006
+ }1007
+1008
+ #[must_use]1009
+ pub fn with_favourite(mut self, favourite: bool) -> Self {1010
+ self.favourite = Some(favourite);1011
+ self1012
+ }1013
+1014
+ #[must_use]1015
+ pub const fn with_unread_only(mut self, unread_only: bool) -> Self {1016
+ self.unread_only = unread_only;1017
+ self1018
+ }1019
+1020
+ #[must_use]1021
+ pub fn with_search(mut self, search: impl Into<String>) -> Self {1022
+ self.search = Some(search.into());1023
+ self1024
+ }1025
+1026
+ #[must_use]1027
+ pub fn matches(&self, room: &RoomSummary) -> bool {1028
+ self.matcher().matches(room)1029
+ }1030
+1031
+ /// lowercases the search once, for filtering a whole list1032
+ #[must_use]1033
+ pub fn matcher(&self) -> RoomMatcher<'_> {1034
+ RoomMatcher {1035
+ filter: self,1036
+ search: self.search.as_deref().map(str::to_lowercase),1037
+ }1038
+ }1039
+}1040
+1041
+#[derive(Debug)]1042
+pub struct RoomMatcher<'a> {1043
+ filter: &'a RoomListFilter,1044
+ search: Option<String>,1045
+}1046
+1047
+impl RoomMatcher<'_> {1048
+ #[must_use]1049
+ pub fn matches(&self, room: &RoomSummary) -> bool {1050
+ let filter = self.filter;1051
+ filter1052
+ .membership1053
+ .is_none_or(|membership| room.membership == membership)1054
+ && filter.category.is_none_or(|category| match category {1055
+ RoomCategory::People => room.direct && !room.space,1056
+ RoomCategory::Rooms => !room.direct && !room.space,1057
+ RoomCategory::Spaces => room.space,1058
+ })1059
+ && filter1060
+ .favourite1061
+ .is_none_or(|favourite| room.favourite == favourite)1062
+ && (!filter.unread_only || room.unread_messages > 0 || room.unread_mentions > 0)1063
+ && self.search.as_ref().is_none_or(|search| {1064
+ room.name.to_lowercase().contains(search)1065
+ || room.room_id.as_str().to_lowercase().contains(search)1066
+ })1067
+ }1068
+}1069
+1070
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1071
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1072
+#[non_exhaustive]1073
+pub enum RoomCategory {1074
+ People,1075
+ Rooms,1076
+ Spaces,1077
+}1078
+1079
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1080
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1081
+#[non_exhaustive]1082
+pub enum MemberFilter {1083
+ Active,1084
+ Joined,1085
+ Invited,1086
+ Banned,1087
+ Left,1088
+ All,1089
+}1090
+1091
+#[derive(Clone, Debug, Eq, PartialEq)]1092
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1093
+#[non_exhaustive]1094
+pub enum MemberMembership {1095
+ Joined,1096
+ Invited,1097
+ Left,1098
+ Knocked,1099
+ Banned,1100
+ Custom(String),1101
+}1102
+1103
+#[derive(Clone, Debug, Eq, PartialEq)]1104
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1105
+#[non_exhaustive]1106
+pub struct MemberSummary {1107
+ pub user_id: OwnedUserId,1108
+ pub name: String,1109
+ pub display_name: Option<String>,1110
+ pub avatar: Option<OwnedMxcUri>,1111
+ pub membership: MemberMembership,1112
+ pub own: bool,1113
+ pub name_ambiguous: bool,1114
+ pub ignored: bool,1115
+ pub service: bool,1116
+}1117
+1118
+#[derive(Clone, Debug, Eq, PartialEq)]1119
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1120
+#[non_exhaustive]1121
+pub struct OwnDevice {1122
+ pub device_id: OwnedDeviceId,1123
+ pub display_name: Option<String>,1124
+ pub last_seen_ip: Option<String>,1125
+ pub last_seen_timestamp_ms: Option<u64>,1126
+ pub current: bool,1127
+}1128
+1129
+#[derive(Clone, Debug, Eq, PartialEq)]1130
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1131
+#[non_exhaustive]1132
+pub enum TimelineItem {1133
+ Event(Box<TimelineEvent>),1134
+ DayDivider { timestamp_ms: u64 },1135
+ ReadMarker,1136
+ TimelineStart,1137
+}1138
+1139
+#[derive(Clone, Debug, Eq, PartialEq)]1140
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1141
+#[non_exhaustive]1142
+pub enum TimelineDiff {1143
+ Append { values: Vec<TimelineItem> },1144
+ Clear,1145
+ PushFront { value: TimelineItem },1146
+ PushBack { value: TimelineItem },1147
+ PopFront,1148
+ PopBack,1149
+ Insert { index: usize, value: TimelineItem },1150
+ Set { index: usize, value: TimelineItem },1151
+ Remove { index: usize },1152
+ Truncate { length: usize },1153
+ Reset { values: Vec<TimelineItem> },1154
+}1155
+1156
+#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]1157
+#[error("timeline diff is incompatible with the current {length}-item snapshot")]1158
+pub struct TimelineDiffError {1159
+ pub length: usize,1160
+}1161
+1162
+impl TimelineDiff {1163
+ /// Apply a diff only when its indices match the current snapshot.1164
+ /// On error the snapshot is unchanged; request `TimelineCommand::Resync`1165
+ /// and ignore further diffs until the corresponding `TimelineReset`.1166
+ pub fn try_apply(self, items: &mut Vector<TimelineItem>) -> Result<(), TimelineDiffError> {1167
+ let length = items.len();1168
+ if self.length_after(length).is_none() {1169
+ return Err(TimelineDiffError { length });1170
+ }1171
+ self.apply(items);1172
+ Ok(())1173
+ }1174
+1175
+ /// a whole `TimelineChanged` or none of it. whether a diff fits depends only on the1176
+ /// length before it, so the batch is checked against lengths alone before anything1177
+ /// moves. the error carries the length the snapshot would have had at the misfit1178
+ pub fn try_apply_all(1179
+ diffs: Vec<Self>,1180
+ items: &mut Vector<TimelineItem>,1181
+ ) -> Result<(), TimelineDiffError> {1182
+ diffs.iter().try_fold(items.len(), |length, diff| {1183
+ diff.length_after(length)1184
+ .ok_or(TimelineDiffError { length })1185
+ })?;1186
+ for diff in diffs {1187
+ diff.apply(items);1188
+ }1189
+ Ok(())1190
+ }1191
+1192
+ fn length_after(&self, length: usize) -> Option<usize> {1193
+ match self {1194
+ Self::Append { values } => Some(length + values.len()),1195
+ Self::Clear => Some(0),1196
+ Self::PushFront { .. } | Self::PushBack { .. } => Some(length + 1),1197
+ Self::PopFront | Self::PopBack => length.checked_sub(1),1198
+ Self::Insert { index, .. } => (*index <= length).then_some(length + 1),1199
+ Self::Set { index, .. } => (*index < length).then_some(length),1200
+ Self::Remove { index } => (*index < length).then(|| length - 1),1201
+ Self::Truncate { length: target } => (*target <= length).then_some(*target),1202
+ Self::Reset { values } => Some(values.len()),1203
+ }1204
+ }1205
+1206
+ fn apply(self, items: &mut Vector<TimelineItem>) {1207
+ match self {1208
+ Self::Append { values } => items.extend(values),1209
+ Self::Clear => items.clear(),1210
+ Self::PushFront { value } => items.push_front(value),1211
+ Self::PushBack { value } => items.push_back(value),1212
+ Self::PopFront => {1213
+ items.pop_front();1214
+ }1215
+ Self::PopBack => {1216
+ items.pop_back();1217
+ }1218
+ Self::Insert { index, value } => items.insert(index, value),1219
+ Self::Set { index, value } => {1220
+ items.set(index, value);1221
+ }1222
+ Self::Remove { index } => {1223
+ items.remove(index);1224
+ }1225
+ Self::Truncate { length } => items.truncate(length),1226
+ Self::Reset { values } => *items = values.into_iter().collect(),1227
+ }1228
+ }1229
+}1230
+1231
+#[cfg(test)]1232
+mod tests {1233
+ use imbl::{Vector, vector};1234
+1235
+ use super::{TimelineDiff, TimelineItem};1236
+1237
+ fn item(timestamp_ms: u64) -> TimelineItem {1238
+ TimelineItem::DayDivider { timestamp_ms }1239
+ }1240
+1241
+ #[test]1242
+ fn timeline_diff_try_apply_handles_every_variant() {1243
+ let mut items = vector![item(1), item(2)];1244
+1245
+ TimelineDiff::Append {1246
+ values: vec![item(3)],1247
+ }1248
+ .try_apply(&mut items)1249
+ .unwrap();1250
+ assert_eq!(items, vector![item(1), item(2), item(3)]);1251
+1252
+ TimelineDiff::Clear.try_apply(&mut items).unwrap();1253
+ assert!(items.is_empty());1254
+1255
+ TimelineDiff::PushFront { value: item(2) }1256
+ .try_apply(&mut items)1257
+ .unwrap();1258
+ TimelineDiff::PushBack { value: item(3) }1259
+ .try_apply(&mut items)1260
+ .unwrap();1261
+ assert_eq!(items, vector![item(2), item(3)]);1262
+1263
+ TimelineDiff::PopFront.try_apply(&mut items).unwrap();1264
+ TimelineDiff::PopBack.try_apply(&mut items).unwrap();1265
+ assert!(items.is_empty());1266
+1267
+ TimelineDiff::Reset {1268
+ values: vec![item(1), item(3)],1269
+ }1270
+ .try_apply(&mut items)1271
+ .unwrap();1272
+ TimelineDiff::Insert {1273
+ index: 1,1274
+ value: item(2),1275
+ }1276
+ .try_apply(&mut items)1277
+ .unwrap();1278
+ assert_eq!(items, vector![item(1), item(2), item(3)]);1279
+1280
+ TimelineDiff::Set {1281
+ index: 1,1282
+ value: item(4),1283
+ }1284
+ .try_apply(&mut items)1285
+ .unwrap();1286
+ assert_eq!(items, vector![item(1), item(4), item(3)]);1287
+1288
+ TimelineDiff::Remove { index: 1 }1289
+ .try_apply(&mut items)1290
+ .unwrap();1291
+ assert_eq!(items, vector![item(1), item(3)]);1292
+1293
+ TimelineDiff::Truncate { length: 1 }1294
+ .try_apply(&mut items)1295
+ .unwrap();1296
+ assert_eq!(items, vector![item(1)]);1297
+ }1298
+1299
+ #[test]1300
+ fn invalid_timeline_diff_leaves_snapshot_unchanged() {1301
+ let invalid = [1302
+ TimelineDiff::Insert {1303
+ index: 3,1304
+ value: item(3),1305
+ },1306
+ TimelineDiff::Set {1307
+ index: 2,1308
+ value: item(3),1309
+ },1310
+ TimelineDiff::Remove { index: 2 },1311
+ TimelineDiff::Truncate { length: 3 },1312
+ ];1313
+ for diff in invalid {1314
+ let mut items = vector![item(1), item(2)];1315
+ assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 2);1316
+ assert_eq!(items, vector![item(1), item(2)]);1317
+ }1318
+ for diff in [TimelineDiff::PopFront, TimelineDiff::PopBack] {1319
+ let mut items = Vector::new();1320
+ assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 0);1321
+ assert!(items.is_empty());1322
+ }1323
+ }1324
+1325
+ #[test]1326
+ fn a_batch_that_goes_wrong_halfway_changes_nothing() {1327
+ let mut items = vector![item(1), item(2)];1328
+ let batch = vec![1329
+ TimelineDiff::PushBack { value: item(3) },1330
+ TimelineDiff::PushBack { value: item(4) },1331
+ TimelineDiff::Remove { index: 0 },1332
+ TimelineDiff::Set {1333
+ index: 3,1334
+ value: item(5),1335
+ },1336
+ ];1337
+ assert_eq!(1338
+ TimelineDiff::try_apply_all(batch, &mut items)1339
+ .unwrap_err()1340
+ .length,1341
+ 31342
+ );1343
+ assert_eq!(items, vector![item(1), item(2)]);1344
+1345
+ let batch = vec![1346
+ TimelineDiff::PushBack { value: item(3) },1347
+ TimelineDiff::Remove { index: 0 },1348
+ TimelineDiff::Set {1349
+ index: 1,1350
+ value: item(4),1351
+ },1352
+ ];1353
+ TimelineDiff::try_apply_all(batch, &mut items).unwrap();1354
+ assert_eq!(items, vector![item(2), item(4)]);1355
+ }1356
+}1357
+1358
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]1359
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1360
+pub struct TimelineItemId(String);1361
+1362
+impl TimelineItemId {1363
+ pub(crate) fn new(value: String) -> Self {1364
+ Self(value)1365
+ }1366
+1367
+ #[must_use]1368
+ pub fn as_str(&self) -> &str {1369
+ &self.01370
+ }1371
+}1372
+1373
+#[derive(Clone, Debug, Eq, PartialEq)]1374
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1375
+#[non_exhaustive]1376
+pub struct TimelineEvent {1377
+ pub item_id: TimelineItemId,1378
+ pub event_id: Option<OwnedEventId>,1379
+ pub transaction_id: Option<OwnedTransactionId>,1380
+ pub sender: Sender,1381
+ pub timestamp_ms: u64,1382
+ pub own: bool,1383
+ pub send_state: SendState,1384
+ pub content: TimelineContent,1385
+ pub reply_to: Option<ReplyReference>,1386
+ pub thread_root: Option<OwnedEventId>,1387
+ pub thread_summary: Option<ThreadSummary>,1388
+ pub reactions: Vec<Reaction>,1389
+ pub read_by: Vec<OwnedUserId>,1390
+ pub editable: bool,1391
+}1392
+1393
+impl TimelineEvent {1394
+ /// what to hand edit, redact and react. works before the server has echoed it back1395
+ #[must_use]1396
+ pub fn target(&self) -> Option<EventRef> {1397
+ self.event_id1398
+ .clone()1399
+ .map(EventRef::Remote)1400
+ .or_else(|| self.transaction_id.clone().map(EventRef::Local))1401
+ }1402
+}1403
+1404
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]1405
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1406
+#[non_exhaustive]1407
+pub enum EventRef {1408
+ Remote(OwnedEventId),1409
+ /// a local echo, by the transaction id it was queued under1410
+ Local(OwnedTransactionId),1411
+}1412
+1413
+impl From<OwnedEventId> for EventRef {1414
+ fn from(event_id: OwnedEventId) -> Self {1415
+ Self::Remote(event_id)1416
+ }1417
+}1418
+1419
+#[derive(Clone, Debug, Eq, PartialEq)]1420
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1421
+#[non_exhaustive]1422
+pub struct Sender {1423
+ pub user_id: OwnedUserId,1424
+ pub display_name: Option<String>,1425
+ pub avatar: Option<OwnedMxcUri>,1426
+ /// someone else in the room goes by the same name, so show the user id too1427
+ pub name_ambiguous: bool,1428
+}1429
+1430
+impl Sender {1431
+ #[must_use]1432
+ pub fn name(&self) -> &str {1433
+ self.display_name1434
+ .as_deref()1435
+ .unwrap_or_else(|| self.user_id.as_str())1436
+ }1437
+}1438
+1439
+#[derive(Clone, Debug, Eq, PartialEq)]1440
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1441
+#[non_exhaustive]1442
+pub enum TimelineContent {1443
+ Message(Message),1444
+ Sticker {1445
+ body: String,1446
+ media: MediaDescriptor,1447
+ },1448
+ Poll {1449
+ question: String,1450
+ },1451
+ Redacted,1452
+ UnableToDecrypt(UtdCause),1453
+ Membership {1454
+ user_id: OwnedUserId,1455
+ display_name: Option<String>,1456
+ change: MembershipChange,1457
+ },1458
+ Profile {1459
+ user_id: OwnedUserId,1460
+ display_name: Option<Change<Option<String>>>,1461
+ avatar: Option<Change<Option<OwnedMxcUri>>>,1462
+ },1463
+ /// `None` means it was cleared, or the event was redacted1464
+ RoomName(Option<String>),1465
+ RoomTopic(Option<String>),1466
+ RoomAvatar(Option<OwnedMxcUri>),1467
+ EncryptionEnabled,1468
+ RoomCreated,1469
+ RoomUpgraded {1470
+ successor: OwnedRoomId,1471
+ },1472
+ OtherState {1473
+ event_type: String,1474
+ state_key: String,1475
+ },1476
+ Other {1477
+ event_type: String,1478
+ },1479
+}1480
+1481
+#[derive(Clone, Debug, Eq, PartialEq)]1482
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1483
+#[non_exhaustive]1484
+pub struct Message {1485
+ pub kind: MessageKind,1486
+ pub body: String,1487
+ /// only for `MessageKind::Location`1488
+ pub geo_uri: Option<String>,1489
+ pub formatted_body: Option<String>,1490
+ pub mentions: Option<MessageMentions>,1491
+ pub edited: bool,1492
+ pub media: Option<MediaDescriptor>,1493
+}1494
+1495
+#[derive(Clone, Debug, Eq, PartialEq)]1496
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1497
+#[non_exhaustive]1498
+pub enum MessageKind {1499
+ Text,1500
+ Emote,1501
+ Notice,1502
+ ServerNotice,1503
+ Image,1504
+ Video,1505
+ Audio,1506
+ File,1507
+ Location,1508
+ VerificationRequest,1509
+ Other(String),1510
+}1511
+1512
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1513
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1514
+#[non_exhaustive]1515
+pub enum MembershipChange {1516
+ Joined,1517
+ Left,1518
+ Banned,1519
+ Unbanned,1520
+ Kicked,1521
+ KickedAndBanned,1522
+ Invited,1523
+ InvitationAccepted,1524
+ InvitationRejected,1525
+ InvitationRevoked,1526
+ Knocked,1527
+ KnockAccepted,1528
+ KnockRetracted,1529
+ KnockDenied,1530
+ /// nothing a person would call a change, or something the sdk couldn't make sense of1531
+ Other,1532
+}1533
+1534
+#[derive(Clone, Debug, Eq, PartialEq)]1535
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1536
+pub struct Change<T> {1537
+ pub old: T,1538
+ pub new: T,1539
+}1540
+1541
+/// why a message couldn't be read, worth telling the user since some of these they can fix1542
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1543
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1544
+#[non_exhaustive]1545
+pub enum UtdCause {1546
+ Unknown,1547
+ SentBeforeWeJoined,1548
+ VerificationViolation,1549
+ UnsignedDevice,1550
+ UnknownDevice,1551
+ HistoricalMessageAndBackupIsDisabled,1552
+ WithheldForUnverifiedOrInsecureDevice,1553
+ WithheldBySender,1554
+ HistoricalMessageAndDeviceIsUnverified,1555
+}1556
+1557
+#[derive(Clone, Debug, Eq, PartialEq)]1558
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1559
+#[non_exhaustive]1560
+pub struct ThreadSummary {1561
+ pub reply_count: u32,1562
+ pub latest_event_id: Option<OwnedEventId>,1563
+ pub latest_event_timestamp_ms: Option<u64>,1564
+}1565
+1566
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1567
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1568
+#[non_exhaustive]1569
+pub enum SendState {1570
+ Sent,1571
+ Sending,1572
+ /// `will_retry` means macltok has it in hand. otherwise it's wedged until you1573
+ /// `retry_send` or `cancel_send`, so that's when to show a retry button1574
+ Failed {1575
+ will_retry: bool,1576
+ },1577
+}1578
+1579
+#[derive(Clone, Debug, Eq, PartialEq)]1580
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1581
+#[non_exhaustive]1582
+pub struct ReplyReference {1583
+ pub event_id: OwnedEventId,1584
+ pub details: ReplyDetails,1585
+}1586
+1587
+/// the replied-to event. macltok asks for it when it isn't loaded, `Pending` then turns into one of the others1588
+#[derive(Clone, Debug, Eq, PartialEq)]1589
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1590
+#[non_exhaustive]1591
+pub enum ReplyDetails {1592
+ Pending,1593
+ Ready {1594
+ sender: Sender,1595
+ content: Box<TimelineContent>,1596
+ timestamp_ms: u64,1597
+ },1598
+ Unavailable,1599
+}1600
+1601
+#[derive(Clone, Debug, Eq, PartialEq)]1602
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1603
+#[non_exhaustive]1604
+pub struct Reaction {1605
+ pub key: String,1606
+ pub count: usize,1607
+ pub own: bool,1608
+ pub senders: Vec<OwnedUserId>,1609
+}1610
+1611
+#[derive(Clone, Debug, Eq, PartialEq)]1612
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1613
+#[non_exhaustive]1614
+pub struct MediaDescriptor {1615
+ pub id: MediaId,1616
+ pub kind: MediaKind,1617
+ pub filename: String,1618
+ pub caption: Option<String>,1619
+ pub mimetype: Option<String>,1620
+ pub size_bytes: Option<u64>,1621
+ pub width: Option<u64>,1622
+ pub height: Option<u64>,1623
+ pub duration_ms: Option<u64>,1624
+ pub blurhash: Option<String>,1625
+ /// the uploader sent a thumbnail, so `MediaSize::Thumbnail` won't need the server to resize1626
+ pub has_thumbnail: bool,1627
+}1628
+1629
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]1630
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1631
+pub struct MediaId(String);1632
+1633
+impl MediaId {1634
+ pub(crate) fn new(value: String) -> Self {1635
+ Self(value)1636
+ }1637
+1638
+ #[must_use]1639
+ pub fn as_str(&self) -> &str {1640
+ &self.01641
+ }1642
+}1643
+1644
+/// media ids are opaque but they do have to survive a trip through a browser. a made up one just fails `MediaUnknown`1645
+impl std::str::FromStr for MediaId {1646
+ type Err = std::convert::Infallible;1647
+1648
+ fn from_str(value: &str) -> Result<Self, Self::Err> {1649
+ Ok(Self(value.to_owned()))1650
+ }1651
+}1652
+1653
+impl From<String> for MediaId {1654
+ fn from(value: String) -> Self {1655
+ Self(value)1656
+ }1657
+}1658
+1659
+impl std::fmt::Display for MediaId {1660
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {1661
+ self.0.fmt(formatter)1662
+ }1663
+}1664
+1665
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]1666
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1667
+#[non_exhaustive]1668
+pub enum MediaSize {1669
+ Full,1670
+ /// a hint. an uploader's own thumbnail wins over the server resizing anything1671
+ Thumbnail {1672
+ width: u32,1673
+ height: u32,1674
+ },1675
+}1676
+1677
+#[derive(Clone, Debug, Eq, PartialEq)]1678
+#[non_exhaustive]1679
+pub struct Media {1680
+ pub bytes: Arc<[u8]>,1681
+ pub mimetype: String,1682
+}1683
+1684
+/// where media lives, for a host that fetches and decrypts it itself1685
+#[derive(Clone, Debug, Eq, PartialEq)]1686
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1687
+#[non_exhaustive]1688
+pub enum MediaLocation {1689
+ File(MediaFile),1690
+ /// a resize of plaintext, so never encrypted. plenty of servers refuse some, and `media`1691
+ /// then fetches `fallback` instead, so should you1692
+ ServerThumbnail {1693
+ url: String,1694
+ fallback: MediaFile,1695
+ },1696
+ /// still in the send queue with no url yet. `media` has the bytes1697
+ Local,1698
+}1699
+1700
+#[derive(Clone, Debug, Eq, PartialEq)]1701
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1702
+#[non_exhaustive]1703
+pub struct MediaFile {1704
+ pub url: String,1705
+ pub mimetype: Option<String>,1706
+ pub declared_size: Option<u64>,1707
+ pub encryption: Option<AttachmentEncryption>,1708
+}1709
+1710
+/// AES-256-CTR, the only attachment cipher matrix has1711
+#[derive(Clone, Debug, Eq, PartialEq)]1712
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1713
+#[non_exhaustive]1714
+pub struct AttachmentEncryption {1715
+ /// the JWK `k`, unpadded url-safe base641716
+ pub key: String,1717
+ /// unpadded base64, the initial counter block1718
+ pub iv: String,1719
+ /// unpadded base64 SHA-256 of the whole ciphertext, so a partial read goes unchecked1720
+ pub sha256: String,1721
+}1722
+1723
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1724
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1725
+#[non_exhaustive]1726
+pub enum MediaKind {1727
+ Image,1728
+ Video,1729
+ Audio,1730
+ File,1731
+ Sticker,1732
+}1733
+1734
+#[derive(Clone, Debug, Eq, PartialEq)]1735
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1736
+#[non_exhaustive]1737
+pub struct TypingState {1738
+ pub room_id: OwnedRoomId,1739
+ pub users: Vec<OwnedUserId>,1740
+}1741
+1742
+#[derive(Clone, Debug, Eq, PartialEq)]1743
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1744
+#[non_exhaustive]1745
+pub enum HistoryState {1746
+ Idle,1747
+ Loading,1748
+ StartReached,1749
+ Failed(String),1750
+}1751
+1752
+#[derive(Clone, Debug, Eq, PartialEq)]1753
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1754
+#[non_exhaustive]1755
+pub struct EncryptionState {1756
+ pub device_id: OwnedDeviceId,1757
+ pub device_verified: bool,1758
+ pub recovery: RecoveryState,1759
+ pub key_backup: KeyBackupState,1760
+}1761
+1762
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1763
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1764
+#[non_exhaustive]1765
+pub enum KeyBackupState {1766
+ Unknown,1767
+ Creating,1768
+ Enabling,1769
+ Resuming,1770
+ Downloading,1771
+ Enabled,1772
+ Disabling,1773
+}1774
+1775
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1776
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1777
+#[non_exhaustive]1778
+pub enum RecoveryState {1779
+ Unknown,1780
+ Disabled,1781
+ Incomplete,1782
+ Enabled,1783
+}1784
+1785
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1786
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1787
+#[non_exhaustive]1788
+pub enum VerificationAction {1789
+ /// take up a request another device sent, which arrives as `VerificationState::Incoming`1790
+ Accept,1791
+ Confirm,1792
+ Reject,1793
+ Cancel,1794
+}1795
+1796
+#[derive(Clone, Debug, Eq, PartialEq)]1797
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1798
+#[non_exhaustive]1799
+pub enum VerificationTarget {1800
+ OwnIdentity,1801
+ User(OwnedUserId),1802
+ Device {1803
+ user_id: OwnedUserId,1804
+ device_id: OwnedDeviceId,1805
+ },1806
+}1807
+1808
+#[derive(Clone, Debug, Eq, PartialEq)]1809
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1810
+#[non_exhaustive]1811
+pub enum VerificationState {1812
+ Waiting,1813
+ /// someone else asked. `Accept` to go ahead, `Cancel` to turn them down1814
+ Incoming {1815
+ user_id: OwnedUserId,1816
+ device_id: Option<OwnedDeviceId>,1817
+ },1818
+ Comparison {1819
+ emojis: Vec<(String, String)>,1820
+ decimals: (u16, u16, u16),1821
+ },1822
+ Confirmed,1823
+ Done,1824
+ Cancelled(String),1825
+ Failed(VerificationFailure),1826
+}1827
+1828
+#[derive(Clone, Debug, Eq, PartialEq)]1829
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1830
+#[non_exhaustive]1831
+pub enum VerificationFailure {1832
+ RequestAcceptance(String),1833
+ SasStartup(String),1834
+ SasAcceptance(String),1835
+ RequestStreamEnded,1836
+ SasStreamEnded,1837
+}1838
+1839
+#[derive(Clone, Debug, Eq, PartialEq)]1840
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1841
+#[non_exhaustive]1842
+pub struct EngineFailure {1843
+ pub kind: EngineErrorKind,1844
+ pub message: String,1845
+}1846
+1847
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1848
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1849
+#[non_exhaustive]1850
+pub enum EngineErrorKind {1851
+ EventReceiverClosed,1852
+ Watcher,1853
+ Other,1854
+}@@ -0,0 +1,328 @@
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
+ pub(crate) fn owns(&self, id: &MediaId) -> bool {36
+ id.as_str()37
+ .split_once('#')38
+ .is_some_and(|(nonce, _)| nonce.parse() == Ok(self.nonce))39
+ }40
+}41
+42
+#[derive(Clone)]43
+pub(crate) struct MediaRegistration {44
+ pub(crate) id: MediaId,45
+ pub(crate) kind: MediaKind,46
+ pub(crate) full: MediaSourceInfo,47
+ pub(crate) thumbnail: Option<MediaSourceInfo>,48
+}49
+50
+#[derive(Clone)]51
+pub(crate) struct MediaSourceInfo {52
+ pub(crate) source: MediaSource,53
+ pub(crate) mimetype: Option<String>,54
+ pub(crate) declared_size: Option<u64>,55
+}56
+57
+pub(super) fn media_registration_for_item(58
+ item: &SdkTimelineItem,59
+ scope: &MediaScope,60
+) -> Option<MediaRegistration> {61
+ let event = item.as_event()?;62
+ let identity = media_identity(item, scope);63
+ let content = match event.content() {64
+ TimelineItemContent::MsgLike(content) => content,65
+ _ => return None,66
+ };67
+ match &content.kind {68
+ MsgLikeKind::Message(message) => media_registration(&identity, message.msgtype()),69
+ MsgLikeKind::Sticker(sticker) => media_sticker_registration(&identity, sticker.content()),70
+ _ => None,71
+ }72
+}73
+74
+pub(super) fn media_identity(item: &SdkTimelineItem, scope: &MediaScope) -> String {75
+ format!("{}#{}", scope.nonce, item.unique_id().0)76
+}77
+78
+pub(super) fn media_from_message(identity: &str, message: &MessageType) -> Option<MediaDescriptor> {79
+ let descriptor = |kind, filename: &str, caption: Option<&str>| MediaDescriptor {80
+ id: media_id(identity, "media"),81
+ kind,82
+ filename: filename.to_owned(),83
+ caption: caption.map(str::to_owned),84
+ mimetype: None,85
+ size_bytes: None,86
+ width: None,87
+ height: None,88
+ duration_ms: None,89
+ blurhash: None,90
+ has_thumbnail: false,91
+ };92
+ let millis = |duration: std::time::Duration| u64::try_from(duration.as_millis()).ok();93
+ Some(match message {94
+ MessageType::Image(content) => {95
+ let info = content.info.as_deref();96
+ MediaDescriptor {97
+ mimetype: info.and_then(|info| info.mimetype.clone()),98
+ size_bytes: info.and_then(|info| info.size).map(u64::from),99
+ width: info.and_then(|info| info.width).map(u64::from),100
+ height: info.and_then(|info| info.height).map(u64::from),101
+ blurhash: info.and_then(|info| info.blurhash.clone()),102
+ has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),103
+ ..descriptor(MediaKind::Image, content.filename(), content.caption())104
+ }105
+ }106
+ MessageType::Video(content) => {107
+ let info = content.info.as_deref();108
+ MediaDescriptor {109
+ mimetype: info.and_then(|info| info.mimetype.clone()),110
+ size_bytes: info.and_then(|info| info.size).map(u64::from),111
+ width: info.and_then(|info| info.width).map(u64::from),112
+ height: info.and_then(|info| info.height).map(u64::from),113
+ duration_ms: info.and_then(|info| info.duration).and_then(millis),114
+ blurhash: info.and_then(|info| info.blurhash.clone()),115
+ has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),116
+ ..descriptor(MediaKind::Video, content.filename(), content.caption())117
+ }118
+ }119
+ MessageType::Audio(content) => {120
+ let info = content.info.as_deref();121
+ MediaDescriptor {122
+ mimetype: info.and_then(|info| info.mimetype.clone()),123
+ size_bytes: info.and_then(|info| info.size).map(u64::from),124
+ duration_ms: info.and_then(|info| info.duration).and_then(millis),125
+ ..descriptor(MediaKind::Audio, content.filename(), content.caption())126
+ }127
+ }128
+ MessageType::File(content) => {129
+ let info = content.info.as_deref();130
+ MediaDescriptor {131
+ mimetype: info.and_then(|info| info.mimetype.clone()),132
+ size_bytes: info.and_then(|info| info.size).map(u64::from),133
+ has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),134
+ ..descriptor(MediaKind::File, content.filename(), content.caption())135
+ }136
+ }137
+ _ => return None,138
+ })139
+}140
+141
+pub(super) fn media_descriptor_for_sticker(142
+ identity: &str,143
+ content: &StickerEventContent,144
+) -> MediaDescriptor {145
+ MediaDescriptor {146
+ id: media_id(identity, "sticker"),147
+ kind: MediaKind::Sticker,148
+ filename: content.body.clone(),149
+ caption: None,150
+ mimetype: content.info.mimetype.clone(),151
+ size_bytes: content.info.size.map(u64::from),152
+ width: content.info.width.map(u64::from),153
+ height: content.info.height.map(u64::from),154
+ duration_ms: None,155
+ blurhash: content.info.blurhash.clone(),156
+ has_thumbnail: content.info.thumbnail_source.is_some(),157
+ }158
+}159
+160
+pub(super) fn media_id(identity: &str, suffix: &str) -> MediaId {161
+ MediaId::new(format!("{identity}#{suffix}"))162
+}163
+164
+fn media_sticker_registration(165
+ identity: &str,166
+ content: &StickerEventContent,167
+) -> Option<MediaRegistration> {168
+ Some(MediaRegistration {169
+ id: media_id(identity, "sticker"),170
+ kind: MediaKind::Sticker,171
+ full: MediaSourceInfo {172
+ source: content.source()?,173
+ mimetype: content.info.mimetype.clone(),174
+ declared_size: content.info.size.map(u64::from),175
+ },176
+ thumbnail: thumbnail_media_source(177
+ content.info.thumbnail_source.clone(),178
+ content.info.thumbnail_info.as_deref(),179
+ ),180
+ })181
+}182
+183
+fn media_registration(identity: &str, message: &MessageType) -> Option<MediaRegistration> {184
+ match message {185
+ MessageType::Image(content) => media_registration_with_info(186
+ identity,187
+ MediaKind::Image,188
+ content.source(),189
+ content.info.as_deref(),190
+ ),191
+ MessageType::Video(content) => media_registration_with_info(192
+ identity,193
+ MediaKind::Video,194
+ content.source(),195
+ content.info.as_deref(),196
+ ),197
+ MessageType::File(content) => media_registration_with_info(198
+ identity,199
+ MediaKind::File,200
+ content.source(),201
+ content.info.as_deref(),202
+ ),203
+ MessageType::Audio(content) => media_registration_with_parts(204
+ identity,205
+ MediaKind::Audio,206
+ content.source(),207
+ content.info.as_ref().and_then(|info| info.mimetype.clone()),208
+ content209
+ .info210
+ .as_ref()211
+ .and_then(|info| info.size)212
+ .map(u64::from),213
+ None,214
+ ),215
+ _ => None,216
+ }217
+}218
+219
+fn media_registration_with_info<T: MediaInfo>(220
+ identity: &str,221
+ kind: MediaKind,222
+ source: Option<MediaSource>,223
+ info: Option<&T>,224
+) -> Option<MediaRegistration> {225
+ media_registration_with_parts(226
+ identity,227
+ kind,228
+ source,229
+ info.and_then(MediaInfo::mimetype),230
+ info.and_then(MediaInfo::declared_size),231
+ info.and_then(MediaInfo::thumbnail),232
+ )233
+}234
+235
+fn media_registration_with_parts(236
+ identity: &str,237
+ kind: MediaKind,238
+ source: Option<MediaSource>,239
+ mimetype: Option<String>,240
+ declared_size: Option<u64>,241
+ thumbnail: Option<MediaSourceInfo>,242
+) -> Option<MediaRegistration> {243
+ Some(MediaRegistration {244
+ id: media_id(identity, "media"),245
+ kind,246
+ full: MediaSourceInfo {247
+ source: source?,248
+ mimetype,249
+ declared_size,250
+ },251
+ thumbnail,252
+ })253
+}254
+255
+trait MediaInfo {256
+ fn mimetype(&self) -> Option<String>;257
+ fn declared_size(&self) -> Option<u64>;258
+ fn thumbnail(&self) -> Option<MediaSourceInfo>;259
+}260
+261
+macro_rules! impl_media_info {262
+ ($type:ty) => {263
+ impl MediaInfo for $type {264
+ fn mimetype(&self) -> Option<String> {265
+ self.mimetype.clone()266
+ }267
+268
+ fn declared_size(&self) -> Option<u64> {269
+ self.size.map(u64::from)270
+ }271
+272
+ fn thumbnail(&self) -> Option<MediaSourceInfo> {273
+ thumbnail_media_source(274
+ self.thumbnail_source.clone(),275
+ self.thumbnail_info.as_deref(),276
+ )277
+ }278
+ }279
+ };280
+}281
+282
+impl_media_info!(ImageInfo);283
+impl_media_info!(VideoInfo);284
+impl_media_info!(FileInfo);285
+286
+fn thumbnail_media_source(287
+ source: Option<MediaSource>,288
+ info: Option<&ThumbnailInfo>,289
+) -> Option<MediaSourceInfo> {290
+ source.map(|source| MediaSourceInfo {291
+ source,292
+ mimetype: info.and_then(|info| info.mimetype.clone()),293
+ declared_size: info.and_then(|info| info.size).map(u64::from),294
+ })295
+}296
+297
+#[cfg(test)]298
+mod tests {299
+ use matrix_sdk::ruma::{300
+ events::room::{ImageInfo, message::ImageMessageEventContent},301
+ mxc_uri,302
+ };303
+304
+ use super::*;305
+306
+ #[test]307
+ fn image_messages_keep_media_metadata() {308
+ let mut info = ImageInfo::new();309
+ info.mimetype = Some("image/png".to_owned());310
+ info.size = Some(65_536_u32.into());311
+ info.width = Some(640_u32.into());312
+ info.height = Some(480_u32.into());313
+ let content = ImageMessageEventContent::plain(314
+ "screen.png".to_owned(),315
+ mxc_uri!("mxc://example.org/screen").to_owned(),316
+ )317
+ .info(Box::new(info));318
+319
+ let media = media_from_message("$event:example.org", &MessageType::Image(content)).unwrap();320
+ assert_eq!(media.id.as_str(), "$event:example.org#media");321
+ assert_eq!(media.kind, MediaKind::Image);322
+ assert_eq!(media.filename, "screen.png");323
+ assert_eq!(media.caption, None);324
+ assert_eq!(media.mimetype.as_deref(), Some("image/png"));325
+ assert_eq!(media.size_bytes, Some(65_536));326
+ assert_eq!((media.width, media.height), (Some(640), Some(480)));327
+ }328
+}@@ -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, space_child};10
+pub(crate) use timeline::{message_kind, normalise_timeline, normalise_timeline_diffs};@@ -0,0 +1,225 @@
1
+use futures_util::{StreamExt, stream};2
+use matrix_sdk::{3
+ Client, Room, RoomState,4
+ deserialized_responses::SyncOrStrippedState,5
+ latest_events::LatestEventValue,6
+ ruma::events::{7
+ AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncStateEvent,8
+ room::message::{Relation, RoomMessageEventContent},9
+ space::child::SpaceChildEventContent,10
+ },11
+};12
+use matrix_sdk_ui::spaces::{SpaceRoom, SpaceService};13
+use ruma::OwnedRoomId;14
+15
+use super::message_kind;16
+use crate::{17
+ LatestEvent, MessageKind, RoomHero, RoomMembership, RoomNotificationMode, RoomSummary,18
+ SendState, SpaceChild,19
+};20
+21
+pub(crate) async fn room_summaries(client: &Client, spaces: &SpaceService) -> Vec<RoomSummary> {22
+ let mut summaries = stream::iter(client.rooms())23
+ .map(|room| async move { room_summary(&room, spaces).await })24
+ .buffer_unordered(16)25
+ .collect::<Vec<_>>()26
+ .await;27
+ summaries.sort_by(|left, right| {28
+ right29
+ .latest_event_timestamp_ms30
+ .cmp(&left.latest_event_timestamp_ms)31
+ .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))32
+ .then_with(|| left.room_id.cmp(&right.room_id))33
+ });34
+ summaries35
+}36
+37
+async fn room_summary(room: &Room, spaces: &SpaceService) -> RoomSummary {38
+ let name = room39
+ .display_name()40
+ .await41
+ .map(|name| name.to_string())42
+ .unwrap_or_else(|_| room.room_id().to_string());43
+ let custom_notification_mode = room.user_defined_notification_mode().await;44
+ let effective_notification_mode = match custom_notification_mode {45
+ Some(mode) => Some(mode),46
+ None => room.notification_mode().await,47
+ };48
+ let inviter = match room.state() {49
+ RoomState::Invited => room50
+ .invite_details()51
+ .await52
+ .ok()53
+ .map(|invite| invite.inviter_id),54
+ _ => None,55
+ };56
+ let children = if room.is_space() {57
+ space_children(room).await58
+ } else {59
+ Vec::new()60
+ };61
+ RoomSummary {62
+ room_id: room.room_id().to_owned(),63
+ name,64
+ membership: membership(room.state()),65
+ unread_messages: room.num_unread_messages(),66
+ unread_mentions: room.num_unread_mentions(),67
+ unread_notifications: room.num_unread_notifications(),68
+ encrypted: room.encryption_state().is_encrypted(),69
+ space: room.is_space(),70
+ direct: room.is_direct().await.unwrap_or(false),71
+ favourite: room.is_favourite(),72
+ low_priority: room.is_low_priority(),73
+ avatar: room.avatar_url(),74
+ latest_event_timestamp_ms: room75
+ .latest_event_timestamp()76
+ .map(|value| value.get().into()),77
+ latest_event: latest_event(room),78
+ notification_mode: effective_notification_mode.map(notification_mode),79
+ notification_mode_custom: custom_notification_mode.is_some(),80
+ topic: room.topic(),81
+ canonical_alias: room.canonical_alias(),82
+ joined_members: room.joined_members_count(),83
+ invited_members: room.invited_members_count(),84
+ heroes: room85
+ .heroes()86
+ .into_iter()87
+ .map(|hero| RoomHero {88
+ user_id: hero.user_id,89
+ display_name: hero.display_name,90
+ avatar: hero.avatar_url,91
+ })92
+ .collect(),93
+ successor: room.successor_room().map(|successor| successor.room_id),94
+ inviter,95
+ parent_spaces: spaces96
+ .joined_parents_of_child(room.room_id())97
+ .await98
+ .into_iter()99
+ .map(|parent| parent.room_id)100
+ .collect(),101
+ children,102
+ }103
+}104
+105
+const fn membership(state: RoomState) -> RoomMembership {106
+ match state {107
+ RoomState::Joined => RoomMembership::Joined,108
+ RoomState::Invited => RoomMembership::Invited,109
+ RoomState::Left => RoomMembership::Left,110
+ RoomState::Knocked => RoomMembership::Knocked,111
+ RoomState::Banned => RoomMembership::Banned,112
+ }113
+}114
+115
+/// what the space itself lists, straight from local state. redacted children don't count116
+async fn space_children(room: &Room) -> Vec<OwnedRoomId> {117
+ let Ok(events) = room118
+ .get_state_events_static::<SpaceChildEventContent>()119
+ .await120
+ else {121
+ return Vec::new();122
+ };123
+ events124
+ .into_iter()125
+ .filter_map(|event| match event.deserialize().ok()? {126
+ SyncOrStrippedState::Sync(SyncStateEvent::Original(event)) => Some(event.state_key),127
+ SyncOrStrippedState::Stripped(event) => Some(event.state_key),128
+ SyncOrStrippedState::Sync(SyncStateEvent::Redacted(_)) => None,129
+ })130
+ .collect()131
+}132
+133
+fn latest_event(room: &Room) -> Option<LatestEvent> {134
+ let own = || Some(room.own_user_id().to_owned());135
+ let local = |local: &matrix_sdk::latest_events::LocalLatestEventValue| {136
+ let preview = match local.content.deserialize() {137
+ Ok(AnyMessageLikeEventContent::RoomMessage(content)) => preview(&content),138
+ _ => (None, None),139
+ };140
+ (preview, Some(local.timestamp))141
+ };142
+ let (sender, (kind, body), timestamp, send_state) = match room.latest_event() {143
+ LatestEventValue::None => return None,144
+ LatestEventValue::Remote(event) => {145
+ let preview = match event.raw().deserialize() {146
+ Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(147
+ message,148
+ ))) => message149
+ .as_original()150
+ .map_or((None, None), |message| preview(&message.content)),151
+ _ => (None, None),152
+ };153
+ (event.sender(), preview, event.timestamp(), SendState::Sent)154
+ }155
+ LatestEventValue::RemoteInvite {156
+ timestamp, inviter, ..157
+ } => (inviter, (None, None), Some(timestamp), SendState::Sent),158
+ LatestEventValue::LocalIsSending(value) => {159
+ let (preview, timestamp) = local(&value);160
+ (own(), preview, timestamp, SendState::Sending)161
+ }162
+ LatestEventValue::LocalHasBeenSent { value, .. } => {163
+ let (preview, timestamp) = local(&value);164
+ (own(), preview, timestamp, SendState::Sent)165
+ }166
+ // the sdk won't say whether it'll retry, so assume the user has to167
+ LatestEventValue::LocalCannotBeSent(value) => {168
+ let (preview, timestamp) = local(&value);169
+ (170
+ own(),171
+ preview,172
+ timestamp,173
+ SendState::Failed { will_retry: false },174
+ )175
+ }176
+ };177
+ Some(LatestEvent {178
+ sender,179
+ kind,180
+ body,181
+ timestamp_ms: timestamp.map(|timestamp| timestamp.get().into()),182
+ send_state,183
+ })184
+}185
+186
+/// the latest event of an edited message is the edit, whose own body is the `* fallback`187
+fn preview(content: &RoomMessageEventContent) -> (Option<MessageKind>, Option<String>) {188
+ let msgtype = match &content.relates_to {189
+ Some(Relation::Replacement(replacement)) => &replacement.new_content.msgtype,190
+ _ => &content.msgtype,191
+ };192
+ (Some(message_kind(msgtype)), Some(msgtype.body().to_owned()))193
+}194
+195
+pub(crate) fn space_child(room: SpaceRoom) -> SpaceChild {196
+ SpaceChild {197
+ name: room.display_name,198
+ room_id: room.room_id,199
+ topic: room.topic,200
+ avatar: room.avatar_url,201
+ canonical_alias: room.canonical_alias,202
+ joined_members: room.num_joined_members,203
+ space: room.room_type == Some(ruma::room::RoomType::Space),204
+ children_count: room.children_count,205
+ membership: room.state.map(membership),206
+ via: room.via,207
+ suggested: room.suggested,208
+ world_readable: room.world_readable,209
+ guest_can_join: room.guest_can_join,210
+ }211
+}212
+213
+pub(crate) const fn notification_mode(214
+ mode: matrix_sdk::notification_settings::RoomNotificationMode,215
+) -> RoomNotificationMode {216
+ match mode {217
+ matrix_sdk::notification_settings::RoomNotificationMode::AllMessages => {218
+ RoomNotificationMode::AllMessages219
+ }220
+ matrix_sdk::notification_settings::RoomNotificationMode::MentionsAndKeywordsOnly => {221
+ RoomNotificationMode::MentionsAndKeywordsOnly222
+ }223
+ matrix_sdk::notification_settings::RoomNotificationMode::Mute => RoomNotificationMode::Mute,224
+ }225
+}@@ -0,0 +1,443 @@
1
+use std::sync::Arc;2
+3
+use matrix_sdk_crypto::types::events::UtdCause as SdkUtdCause;4
+use matrix_sdk_ui::{5
+ eyeball_im::VectorDiff,6
+ timeline::{7
+ AnyOtherStateEventContentChange, EncryptedMessage, EventSendState, EventTimelineItem,8
+ MembershipChange as SdkMembershipChange, MsgLikeContent, MsgLikeKind, Profile,9
+ TimelineDetails, TimelineEventItemId, TimelineItem as SdkTimelineItem, TimelineItemContent,10
+ TimelineItemKind, VirtualTimelineItem,11
+ },12
+};13
+use ruma::{14
+ OwnedEventId, UserId,15
+ events::{16
+ StateEventContentChange,17
+ room::message::{MessageFormat, MessageType},18
+ },19
+};20
+21
+use crate::{22
+ Change, MembershipChange, Message, MessageKind, MessageMentions, Reaction, ReplyDetails,23
+ ReplyReference, SendState, Sender, ThreadSummary, TimelineContent, TimelineDiff, TimelineEvent,24
+ TimelineItem, TimelineItemId, UtdCause,25
+};26
+27
+use super::media::{28
+ MediaRegistration, MediaScope, media_descriptor_for_sticker, media_from_message,29
+ media_identity, media_registration_for_item,30
+};31
+32
+#[derive(Default)]33
+pub(crate) struct NormalisedTimeline {34
+ pub(crate) items: imbl::Vector<TimelineItem>,35
+ pub(crate) media: Vec<MediaRegistration>,36
+ /// replies whose target isn't loaded, so somebody should go and ask for it37
+ pub(crate) missing_replies: Vec<OwnedEventId>,38
+}39
+40
+pub(crate) struct NormalisedTimelineDiffs {41
+ pub(crate) diffs: Vec<TimelineDiff>,42
+ pub(crate) media: Vec<MediaRegistration>,43
+ pub(crate) missing_replies: Vec<OwnedEventId>,44
+}45
+46
+struct Normaliser<'a> {47
+ current_user_id: Option<&'a UserId>,48
+ scope: &'a MediaScope,49
+ media: Vec<MediaRegistration>,50
+ missing_replies: Vec<OwnedEventId>,51
+}52
+53
+impl Normaliser<'_> {54
+ fn item(&mut self, item: &SdkTimelineItem) -> TimelineItem {55
+ if let Some(media) = media_registration_for_item(item, self.scope) {56
+ self.media.push(media);57
+ }58
+ match item.kind() {59
+ TimelineItemKind::Event(event) => {60
+ TimelineItem::Event(Box::new(self.event(item, event)))61
+ }62
+ TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp)) => {63
+ TimelineItem::DayDivider {64
+ timestamp_ms: timestamp.get().into(),65
+ }66
+ }67
+ TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => TimelineItem::ReadMarker,68
+ TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => {69
+ TimelineItem::TimelineStart70
+ }71
+ }72
+ }73
+74
+ fn event(&mut self, item: &SdkTimelineItem, event: &EventTimelineItem) -> TimelineEvent {75
+ let msg_like = match event.content() {76
+ TimelineItemContent::MsgLike(content) => Some(content),77
+ _ => None,78
+ };79
+ let reply_to = msg_like80
+ .and_then(|content| content.in_reply_to.as_ref())81
+ .map(|reply| {82
+ let details = match &reply.event {83
+ TimelineDetails::Ready(embedded) => ReplyDetails::Ready {84
+ sender: sender(&embedded.sender, &embedded.sender_profile),85
+ content: Box::new(content(&embedded.content, None)),86
+ timestamp_ms: embedded.timestamp.get().into(),87
+ },88
+ TimelineDetails::Unavailable => {89
+ self.missing_replies.push(reply.event_id.clone());90
+ ReplyDetails::Pending91
+ }92
+ TimelineDetails::Pending => ReplyDetails::Pending,93
+ TimelineDetails::Error(_) => ReplyDetails::Unavailable,94
+ };95
+ ReplyReference {96
+ event_id: reply.event_id.clone(),97
+ details,98
+ }99
+ });100
+ let mut read_by: Vec<_> = event101
+ .read_receipts()102
+ .keys()103
+ .filter(|user_id| *user_id != event.sender())104
+ .cloned()105
+ .collect();106
+ read_by.sort();107
+ TimelineEvent {108
+ item_id: TimelineItemId::new(item.unique_id().0.clone()),109
+ event_id: event.event_id().map(ToOwned::to_owned),110
+ transaction_id: event.transaction_id().map(ToOwned::to_owned),111
+ sender: sender(event.sender(), event.sender_profile()),112
+ timestamp_ms: event.timestamp().get().into(),113
+ own: event.is_own(),114
+ send_state: send_state(event.send_state()),115
+ content: content(event.content(), Some(&media_identity(item, self.scope))),116
+ reply_to,117
+ thread_root: msg_like.and_then(|content| content.thread_root.clone()),118
+ thread_summary: msg_like.and_then(thread_summary),119
+ reactions: reactions(event, self.current_user_id),120
+ read_by,121
+ editable: event.is_editable(),122
+ }123
+ }124
+}125
+126
+pub(crate) fn normalise_timeline<'a>(127
+ items: impl Iterator<Item = &'a Arc<SdkTimelineItem>>,128
+ current_user_id: Option<&UserId>,129
+ scope: &MediaScope,130
+) -> NormalisedTimeline {131
+ let mut normaliser = Normaliser {132
+ current_user_id,133
+ scope,134
+ media: Vec::new(),135
+ missing_replies: Vec::new(),136
+ };137
+ let items = items.map(|item| normaliser.item(item)).collect();138
+ NormalisedTimeline {139
+ items,140
+ media: normaliser.media,141
+ missing_replies: normaliser.missing_replies,142
+ }143
+}144
+145
+pub(crate) fn normalise_timeline_diffs(146
+ diffs: Vec<VectorDiff<Arc<SdkTimelineItem>>>,147
+ current_user_id: Option<&UserId>,148
+ scope: &MediaScope,149
+) -> NormalisedTimelineDiffs {150
+ let mut normaliser = Normaliser {151
+ current_user_id,152
+ scope,153
+ media: Vec::new(),154
+ missing_replies: Vec::new(),155
+ };156
+ let diffs = diffs157
+ .into_iter()158
+ .map(|diff| match diff.map(|item| normaliser.item(&item)) {159
+ VectorDiff::Append { values } => TimelineDiff::Append {160
+ values: values.into_iter().collect(),161
+ },162
+ VectorDiff::Clear => TimelineDiff::Clear,163
+ VectorDiff::PushFront { value } => TimelineDiff::PushFront { value },164
+ VectorDiff::PushBack { value } => TimelineDiff::PushBack { value },165
+ VectorDiff::PopFront => TimelineDiff::PopFront,166
+ VectorDiff::PopBack => TimelineDiff::PopBack,167
+ VectorDiff::Insert { index, value } => TimelineDiff::Insert { index, value },168
+ VectorDiff::Set { index, value } => TimelineDiff::Set { index, value },169
+ VectorDiff::Remove { index } => TimelineDiff::Remove { index },170
+ VectorDiff::Truncate { length } => TimelineDiff::Truncate { length },171
+ VectorDiff::Reset { values } => TimelineDiff::Reset {172
+ values: values.into_iter().collect(),173
+ },174
+ })175
+ .collect();176
+ NormalisedTimelineDiffs {177
+ diffs,178
+ media: normaliser.media,179
+ missing_replies: normaliser.missing_replies,180
+ }181
+}182
+183
+/// `media_identity` is `None` for embedded reply content, which never gets fetchable media of its own184
+fn content(content: &TimelineItemContent, media_identity: Option<&str>) -> TimelineContent {185
+ match content {186
+ TimelineItemContent::MsgLike(content) => msg_like(content, media_identity),187
+ TimelineItemContent::MembershipChange(change) => TimelineContent::Membership {188
+ user_id: change.user_id().to_owned(),189
+ display_name: change.display_name(),190
+ change: change191
+ .change()192
+ .map_or(MembershipChange::Other, membership_change),193
+ },194
+ TimelineItemContent::ProfileChange(change) => TimelineContent::Profile {195
+ user_id: change.user_id().to_owned(),196
+ display_name: change.displayname_change().map(|change| Change {197
+ old: change.old.clone(),198
+ new: change.new.clone(),199
+ }),200
+ avatar: change.avatar_url_change().map(|change| Change {201
+ old: change.old.clone(),202
+ new: change.new.clone(),203
+ }),204
+ },205
+ TimelineItemContent::OtherState(state) => match state.content() {206
+ AnyOtherStateEventContentChange::RoomName(change) => TimelineContent::RoomName(207
+ original(change).and_then(|content| non_empty(&content.name)),208
+ ),209
+ AnyOtherStateEventContentChange::RoomTopic(change) => TimelineContent::RoomTopic(210
+ original(change).and_then(|content| non_empty(&content.topic)),211
+ ),212
+ AnyOtherStateEventContentChange::RoomAvatar(change) => TimelineContent::RoomAvatar(213
+ original(change).and_then(|content| content.url.clone()),214
+ ),215
+ AnyOtherStateEventContentChange::RoomEncryption(_) => {216
+ TimelineContent::EncryptionEnabled217
+ }218
+ AnyOtherStateEventContentChange::RoomCreate(_) => TimelineContent::RoomCreated,219
+ AnyOtherStateEventContentChange::RoomTombstone(change) => match original(change) {220
+ Some(content) => TimelineContent::RoomUpgraded {221
+ successor: content.replacement_room.clone(),222
+ },223
+ None => other_state(state.content().event_type().to_string(), state.state_key()),224
+ },225
+ other => other_state(other.event_type().to_string(), state.state_key()),226
+ },227
+ TimelineItemContent::FailedToParseMessageLike { event_type, .. } => {228
+ TimelineContent::Other {229
+ event_type: event_type.to_string(),230
+ }231
+ }232
+ TimelineItemContent::FailedToParseState {233
+ event_type,234
+ state_key,235
+ ..236
+ } => other_state(event_type.to_string(), state_key),237
+ TimelineItemContent::CallInvite => TimelineContent::Other {238
+ event_type: "m.call.invite".to_owned(),239
+ },240
+ TimelineItemContent::RtcNotification { .. } => TimelineContent::Other {241
+ event_type: "org.matrix.msc4075.rtc.notification".to_owned(),242
+ },243
+ }244
+}245
+246
+/// a cleared name or topic arrives as an empty string247
+fn non_empty(value: &str) -> Option<String> {248
+ (!value.is_empty()).then(|| value.to_owned())249
+}250
+251
+fn other_state(event_type: String, state_key: &str) -> TimelineContent {252
+ TimelineContent::OtherState {253
+ event_type,254
+ state_key: state_key.to_owned(),255
+ }256
+}257
+258
+fn original<C>(change: &StateEventContentChange<C>) -> Option<&C>259
+where260
+ C: ruma::events::StaticStateEventContent + ruma::events::RedactContent,261
+{262
+ match change {263
+ StateEventContentChange::Original { content, .. } => Some(content),264
+ StateEventContentChange::Redacted(_) => None,265
+ }266
+}267
+268
+fn msg_like(content: &MsgLikeContent, media_identity: Option<&str>) -> TimelineContent {269
+ match &content.kind {270
+ MsgLikeKind::Message(message) => TimelineContent::Message(Message {271
+ kind: message_kind(message.msgtype()),272
+ body: message.body().to_owned(),273
+ geo_uri: match message.msgtype() {274
+ MessageType::Location(location) => Some(location.geo_uri.clone()),275
+ _ => None,276
+ },277
+ formatted_body: formatted_body(message.msgtype()),278
+ mentions: message.mentions().map(|mentions| MessageMentions {279
+ user_ids: mentions.user_ids.iter().cloned().collect(),280
+ room: mentions.room,281
+ }),282
+ edited: message.is_edited(),283
+ media: media_identity284
+ .and_then(|identity| media_from_message(identity, message.msgtype())),285
+ }),286
+ MsgLikeKind::Sticker(sticker) => {287
+ let sticker = sticker.content();288
+ match media_identity {289
+ Some(identity) => TimelineContent::Sticker {290
+ body: sticker.body.clone(),291
+ media: media_descriptor_for_sticker(identity, sticker),292
+ },293
+ None => TimelineContent::Other {294
+ event_type: "m.sticker".to_owned(),295
+ },296
+ }297
+ }298
+ MsgLikeKind::Poll(poll) => TimelineContent::Poll {299
+ question: poll.results().question,300
+ },301
+ MsgLikeKind::Redacted => TimelineContent::Redacted,302
+ MsgLikeKind::UnableToDecrypt(encrypted) => {303
+ TimelineContent::UnableToDecrypt(match encrypted {304
+ EncryptedMessage::MegolmV1AesSha2 { cause, .. } => utd_cause(*cause),305
+ _ => UtdCause::Unknown,306
+ })307
+ }308
+ MsgLikeKind::Other(other) => TimelineContent::Other {309
+ event_type: other.event_type().to_string(),310
+ },311
+ MsgLikeKind::LiveLocation(_) => TimelineContent::Other {312
+ event_type: "m.beacon_info".to_owned(),313
+ },314
+ }315
+}316
+317
+pub(crate) fn message_kind(message: &MessageType) -> MessageKind {318
+ match message {319
+ MessageType::Text(_) => MessageKind::Text,320
+ MessageType::Emote(_) => MessageKind::Emote,321
+ MessageType::Notice(_) => MessageKind::Notice,322
+ MessageType::ServerNotice(_) => MessageKind::ServerNotice,323
+ MessageType::Image(_) => MessageKind::Image,324
+ MessageType::Video(_) => MessageKind::Video,325
+ MessageType::Audio(_) => MessageKind::Audio,326
+ MessageType::File(_) => MessageKind::File,327
+ MessageType::Location(_) => MessageKind::Location,328
+ MessageType::VerificationRequest(_) => MessageKind::VerificationRequest,329
+ other => MessageKind::Other(other.msgtype().to_owned()),330
+ }331
+}332
+333
+const fn membership_change(change: SdkMembershipChange) -> MembershipChange {334
+ match change {335
+ SdkMembershipChange::Joined => MembershipChange::Joined,336
+ SdkMembershipChange::Left => MembershipChange::Left,337
+ SdkMembershipChange::Banned => MembershipChange::Banned,338
+ SdkMembershipChange::Unbanned => MembershipChange::Unbanned,339
+ SdkMembershipChange::Kicked => MembershipChange::Kicked,340
+ SdkMembershipChange::KickedAndBanned => MembershipChange::KickedAndBanned,341
+ SdkMembershipChange::Invited => MembershipChange::Invited,342
+ SdkMembershipChange::InvitationAccepted => MembershipChange::InvitationAccepted,343
+ SdkMembershipChange::InvitationRejected => MembershipChange::InvitationRejected,344
+ SdkMembershipChange::InvitationRevoked => MembershipChange::InvitationRevoked,345
+ SdkMembershipChange::Knocked => MembershipChange::Knocked,346
+ SdkMembershipChange::KnockAccepted => MembershipChange::KnockAccepted,347
+ SdkMembershipChange::KnockRetracted => MembershipChange::KnockRetracted,348
+ SdkMembershipChange::KnockDenied => MembershipChange::KnockDenied,349
+ SdkMembershipChange::None350
+ | SdkMembershipChange::Error351
+ | SdkMembershipChange::NotImplemented => MembershipChange::Other,352
+ }353
+}354
+355
+const fn utd_cause(cause: SdkUtdCause) -> UtdCause {356
+ match cause {357
+ SdkUtdCause::Unknown => UtdCause::Unknown,358
+ SdkUtdCause::SentBeforeWeJoined => UtdCause::SentBeforeWeJoined,359
+ SdkUtdCause::VerificationViolation => UtdCause::VerificationViolation,360
+ SdkUtdCause::UnsignedDevice => UtdCause::UnsignedDevice,361
+ SdkUtdCause::UnknownDevice => UtdCause::UnknownDevice,362
+ SdkUtdCause::HistoricalMessageAndBackupIsDisabled => {363
+ UtdCause::HistoricalMessageAndBackupIsDisabled364
+ }365
+ SdkUtdCause::WithheldForUnverifiedOrInsecureDevice => {366
+ UtdCause::WithheldForUnverifiedOrInsecureDevice367
+ }368
+ SdkUtdCause::WithheldBySender => UtdCause::WithheldBySender,369
+ SdkUtdCause::HistoricalMessageAndDeviceIsUnverified => {370
+ UtdCause::HistoricalMessageAndDeviceIsUnverified371
+ }372
+ }373
+}374
+375
+fn sender(user_id: &UserId, profile: &TimelineDetails<Profile>) -> Sender {376
+ let profile = match profile {377
+ TimelineDetails::Ready(profile) => Some(profile),378
+ _ => None,379
+ };380
+ Sender {381
+ user_id: user_id.to_owned(),382
+ display_name: profile.and_then(|profile| profile.display_name.clone()),383
+ avatar: profile.and_then(|profile| profile.avatar_url.clone()),384
+ name_ambiguous: profile.is_some_and(|profile| profile.display_name_ambiguous),385
+ }386
+}387
+388
+fn send_state(state: Option<&EventSendState>) -> SendState {389
+ match state {390
+ None | Some(EventSendState::Sent { .. }) => SendState::Sent,391
+ Some(EventSendState::NotSentYet { .. }) => SendState::Sending,392
+ // recoverable means the sdk parks the queue and we unpark it, nothing for the user to do393
+ Some(EventSendState::SendingFailed { is_recoverable, .. }) => SendState::Failed {394
+ will_retry: *is_recoverable,395
+ },396
+ }397
+}398
+399
+fn thread_summary(content: &MsgLikeContent) -> Option<ThreadSummary> {400
+ content.thread_summary.as_ref().map(|summary| {401
+ let latest_event = match &summary.latest_event {402
+ TimelineDetails::Ready(event) => Some(event.as_ref()),403
+ _ => None,404
+ };405
+ ThreadSummary {406
+ reply_count: summary.num_replies,407
+ latest_event_id: latest_event.and_then(|event| match &event.identifier {408
+ TimelineEventItemId::EventId(event_id) => Some(event_id.clone()),409
+ TimelineEventItemId::TransactionId(_) => None,410
+ }),411
+ latest_event_timestamp_ms: latest_event.map(|event| event.timestamp.get().into()),412
+ }413
+ })414
+}415
+416
+fn reactions(event: &EventTimelineItem, current_user_id: Option<&UserId>) -> Vec<Reaction> {417
+ event418
+ .content()419
+ .reactions()420
+ .into_iter()421
+ .flat_map(|reactions| reactions.iter())422
+ .map(|(key, senders)| Reaction {423
+ key: key.clone(),424
+ count: senders.len(),425
+ own: current_user_id.is_some_and(|user_id| senders.contains_key(user_id)),426
+ senders: senders.keys().cloned().collect(),427
+ })428
+ .collect()429
+}430
+431
+fn formatted_body(message: &MessageType) -> Option<String> {432
+ let formatted = match message {433
+ MessageType::Text(content) => content.formatted.as_ref(),434
+ MessageType::Emote(content) => content.formatted.as_ref(),435
+ MessageType::Notice(content) => content.formatted.as_ref(),436
+ MessageType::Audio(content) => content.formatted_caption(),437
+ MessageType::File(content) => content.formatted_caption(),438
+ MessageType::Image(content) => content.formatted_caption(),439
+ MessageType::Video(content) => content.formatted_caption(),440
+ _ => None,441
+ }?;442
+ (formatted.format == MessageFormat::Html).then(|| formatted.body.clone())443
+}@@ -0,0 +1,566 @@
1
+#[cfg(feature = "sqlite")]2
+use std::path::{Path, PathBuf};3
+4
+use matrix_sdk::{BoxFuture, SendOutsideWasm, SyncOutsideWasm};5
+use matrix_sdk_store_encryption::StoreCipher;6
+7
+use crate::{AccountError, SessionRecord, StoreConfig, account::account_error};8
+9
+#[cfg(feature = "sqlite")]10
+type RecordLocation = PathBuf;11
+#[cfg(feature = "indexeddb")]12
+type RecordLocation = String;13
+14
+#[cfg(not(target_family = "wasm"))]15
+type Lock<T> = std::sync::Mutex<T>;16
+#[cfg(target_family = "wasm")]17
+type Lock<T> = tokio::sync::Mutex<T>;18
+19
+/// one login's record and the matrix store it belongs to. the engine rotates refresh tokens20
+/// and each works once, so a `save` has to land before the next refresh or the login is gone21
+pub trait SessionStore: SendOutsideWasm + SyncOutsideWasm {22
+ fn store(&self) -> &StoreConfig;23
+24
+ fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>>;25
+26
+ /// replace the saved record, all or nothing27
+ fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>>;28
+29
+ /// `save`, but without awaiting. the sdk calls it from inside a refresh, before the new30
+ /// refresh token is ever used, so a store that can write synchronously closes the gap31
+ /// a crash would otherwise lose the login through. `None` leaves the refresh to `save`32
+ #[cfg(not(target_family = "wasm"))]33
+ fn save_blocking(&self, _record: &SessionRecord) -> Option<Result<(), AccountError>> {34
+ None35
+ }36
+37
+ /// swap the record for one that only says who to log in again as, keeping the store.38
+ /// `Session::restore` then answers `Restore::SoftLoggedOut`, even after a restart39
+ fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>>;40
+41
+ /// drop the record and delete the store, once nothing has the store open42
+ fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>>;43
+}44
+45
+/// keeps the record sealed with the store's passphrase, in a file natively and in an46
+/// IndexedDB database of its own in a browser47
+pub struct EncryptedSessionStore {48
+ record: RecordLocation,49
+ store: StoreConfig,50
+ sealer: Lock<Option<Sealer>>,51
+}52
+53
+struct Sealer {54
+ cipher: StoreCipher,55
+ exported: Vec<u8>,56
+}57
+58
+impl EncryptedSessionStore {59
+ #[cfg(feature = "sqlite")]60
+ #[must_use]61
+ pub fn file(record: impl Into<PathBuf>, store: StoreConfig) -> Self {62
+ Self::new(record.into(), store)63
+ }64
+65
+ /// the record lives beside the store, in `<name>::macltok-session`66
+ #[cfg(feature = "indexeddb")]67
+ #[must_use]68
+ pub fn indexeddb(store: StoreConfig) -> Self {69
+ let record = format!("{}::macltok-session", store.indexeddb_name());70
+ Self::new(record, store)71
+ }72
+73
+ fn new(record: RecordLocation, store: StoreConfig) -> Self {74
+ Self {75
+ record,76
+ store,77
+ sealer: Lock::new(None),78
+ }79
+ }80
+81
+ fn open(82
+ &self,83
+ sealer: &mut Option<Sealer>,84
+ sealed: Option<Vec<u8>>,85
+ ) -> Result<Option<SessionRecord>, AccountError> {86
+ let Some(sealed) = sealed else {87
+ return Ok(None);88
+ };89
+ let (opened, record) = unseal(self.store.passphrase(), &sealed)?;90
+ *sealer = Some(opened);91
+ Ok(Some(record))92
+ }93
+94
+ fn seal(95
+ &self,96
+ sealer: &mut Option<Sealer>,97
+ record: &SessionRecord,98
+ ) -> Result<Vec<u8>, AccountError> {99
+ let sealer = match sealer {100
+ Some(sealer) => sealer,101
+ empty => empty.insert(Sealer::new(self.store.passphrase())?),102
+ };103
+ sealer.seal(record)104
+ }105
+}106
+107
+#[cfg(not(target_family = "wasm"))]108
+impl EncryptedSessionStore {109
+ fn lock(&self) -> std::sync::MutexGuard<'_, Option<Sealer>> {110
+ self.sealer111
+ .lock()112
+ .unwrap_or_else(std::sync::PoisonError::into_inner)113
+ }114
+115
+ fn read(&self, sealer: &mut Option<Sealer>) -> Result<Option<SessionRecord>, AccountError> {116
+ let sealed = backend::read(&self.record)117
+ .map_err(|error| account_error("reading the session record", error))?;118
+ self.open(sealer, sealed)119
+ }120
+121
+ fn write(122
+ &self,123
+ sealer: &mut Option<Sealer>,124
+ record: &SessionRecord,125
+ ) -> Result<(), AccountError> {126
+ let sealed = self.seal(sealer, record)?;127
+ backend::write(&self.record, &sealed)128
+ .map_err(|error| account_error("writing the session record", error))129
+ }130
+131
+ fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {132
+ self.read(&mut self.lock())133
+ }134
+135
+ fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {136
+ self.write(&mut self.lock(), record)137
+ }138
+139
+ fn soft_log_out_record(&self) -> Result<(), AccountError> {140
+ let mut sealer = self.lock();141
+ match self.read(&mut sealer)? {142
+ Some(record) => self.write(&mut sealer, &record.soft_logged_out()),143
+ None => Ok(()),144
+ }145
+ }146
+147
+ fn forget_record(&self) -> Result<(), AccountError> {148
+ let mut sealer = self.lock();149
+ backend::remove(&self.record)150
+ .map_err(|error| account_error("removing the session record", error))?;151
+ *sealer = None;152
+ Ok(())153
+ }154
+}155
+156
+#[cfg(not(target_family = "wasm"))]157
+impl SessionStore for EncryptedSessionStore {158
+ fn store(&self) -> &StoreConfig {159
+ &self.store160
+ }161
+162
+ fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>> {163
+ Box::pin(async move { self.load_record() })164
+ }165
+166
+ fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>> {167
+ Box::pin(async move { self.save_record(record) })168
+ }169
+170
+ /// blocks the calling worker for the write and its fsyncs, about a millisecond171
+ fn save_blocking(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {172
+ Some(self.save_record(record))173
+ }174
+175
+ fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {176
+ Box::pin(async move { self.soft_log_out_record() })177
+ }178
+179
+ fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>> {180
+ Box::pin(async move {181
+ self.forget_record()?;182
+ self.store.delete().await183
+ })184
+ }185
+}186
+187
+#[cfg(target_family = "wasm")]188
+impl EncryptedSessionStore {189
+ async fn read(190
+ &self,191
+ sealer: &mut Option<Sealer>,192
+ ) -> Result<Option<SessionRecord>, AccountError> {193
+ let sealed = backend::read(&self.record)194
+ .await195
+ .map_err(|error| account_error("reading the session record", error))?;196
+ self.open(sealer, sealed)197
+ }198
+199
+ async fn write(200
+ &self,201
+ sealer: &mut Option<Sealer>,202
+ record: &SessionRecord,203
+ ) -> Result<(), AccountError> {204
+ let sealed = self.seal(sealer, record)?;205
+ backend::write(&self.record, &sealed)206
+ .await207
+ .map_err(|error| account_error("writing the session record", error))208
+ }209
+210
+ async fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {211
+ self.read(&mut *self.sealer.lock().await).await212
+ }213
+214
+ async fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {215
+ self.write(&mut *self.sealer.lock().await, record).await216
+ }217
+218
+ async fn soft_log_out_record(&self) -> Result<(), AccountError> {219
+ let mut sealer = self.sealer.lock().await;220
+ match self.read(&mut sealer).await? {221
+ Some(record) => self.write(&mut sealer, &record.soft_logged_out()).await,222
+ None => Ok(()),223
+ }224
+ }225
+226
+ async fn discard_all(&self) -> Result<(), AccountError> {227
+ let mut sealer = self.sealer.lock().await;228
+ backend::remove(&self.record)229
+ .await230
+ .map_err(|error| error.into_account_error("removing the session record"))?;231
+ *sealer = None;232
+ self.store.delete().await233
+ }234
+}235
+236
+#[cfg(target_family = "wasm")]237
+impl SessionStore for EncryptedSessionStore {238
+ fn store(&self) -> &StoreConfig {239
+ &self.store240
+ }241
+242
+ fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>> {243
+ Box::pin(self.load_record())244
+ }245
+246
+ fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>> {247
+ Box::pin(self.save_record(record))248
+ }249
+250
+ fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {251
+ Box::pin(self.soft_log_out_record())252
+ }253
+254
+ fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>> {255
+ Box::pin(self.discard_all())256
+ }257
+}258
+259
+impl Sealer {260
+ fn new(passphrase: &str) -> Result<Self, AccountError> {261
+ let cipher = StoreCipher::new()262
+ .map_err(|error| account_error("creating the session record cipher", error))?;263
+ let exported = cipher264
+ .export(passphrase)265
+ .map_err(|error| account_error("sealing the session record cipher", error))?;266
+ Ok(Self { cipher, exported })267
+ }268
+269
+ fn seal(&self, record: &SessionRecord) -> Result<Vec<u8>, AccountError> {270
+ let value = self271
+ .cipher272
+ .encrypt_value(record)273
+ .map_err(|error| account_error("sealing the session record", error))?;274
+ let length = u32::try_from(self.exported.len())275
+ .expect("an exported store cipher is a few hundred bytes");276
+ let mut sealed = Vec::with_capacity(4 + self.exported.len() + value.len());277
+ sealed.extend_from_slice(&length.to_be_bytes());278
+ sealed.extend_from_slice(&self.exported);279
+ sealed.extend_from_slice(&value);280
+ Ok(sealed)281
+ }282
+}283
+284
+fn unseal(passphrase: &str, sealed: &[u8]) -> Result<(Sealer, SessionRecord), AccountError> {285
+ let truncated = || {286
+ account_error(287
+ "opening the session record",288
+ std::io::Error::from(std::io::ErrorKind::UnexpectedEof),289
+ )290
+ };291
+ let (length, rest) = sealed.split_first_chunk::<4>().ok_or_else(truncated)?;292
+ let (exported, value) = rest293
+ .split_at_checked(u32::from_be_bytes(*length) as usize)294
+ .ok_or_else(truncated)?;295
+ let cipher = StoreCipher::import(passphrase, exported)296
+ .map_err(|error| account_error("opening the session record cipher", error))?;297
+ let record = cipher298
+ .decrypt_value(value)299
+ .map_err(|error| account_error("opening the session record", error))?;300
+ let sealer = Sealer {301
+ cipher,302
+ exported: exported.to_vec(),303
+ };304
+ Ok((sealer, record))305
+}306
+307
+#[cfg(feature = "sqlite")]308
+mod backend {309
+ use std::{310
+ ffi::OsString,311
+ fs::{self, File, OpenOptions},312
+ io::{self, ErrorKind, Write},313
+ path::{Path, PathBuf},314
+ sync::atomic::{AtomicU64, Ordering},315
+ };316
+317
+ static WRITES: AtomicU64 = AtomicU64::new(0);318
+319
+ pub(super) fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {320
+ match fs::read(path) {321
+ Ok(sealed) => Ok(Some(sealed)),322
+ Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),323
+ Err(error) => Err(error),324
+ }325
+ }326
+327
+ pub(super) fn write(path: &Path, sealed: &[u8]) -> io::Result<()> {328
+ let parent = match path.parent() {329
+ Some(parent) if !parent.as_os_str().is_empty() => parent,330
+ _ => Path::new("."),331
+ };332
+ fs::create_dir_all(parent)?;333
+ let (temporary, mut file) = temporary_file(path)?;334
+ let written = replace(path, &temporary, &mut file, parent, sealed);335
+ if written.is_err() {336
+ let _ = fs::remove_file(&temporary);337
+ }338
+ written339
+ }340
+341
+ fn temporary_file(path: &Path) -> io::Result<(PathBuf, File)> {342
+ loop {343
+ let mut name = OsString::from(path.as_os_str());344
+ let write = WRITES.fetch_add(1, Ordering::Relaxed);345
+ name.push(format!(".{}.{write}.tmp", std::process::id()));346
+ let temporary = PathBuf::from(name);347
+ match private_file(&temporary) {348
+ Ok(file) => return Ok((temporary, file)),349
+ Err(error) if error.kind() == ErrorKind::AlreadyExists => {}350
+ Err(error) => return Err(error),351
+ }352
+ }353
+ }354
+355
+ fn replace(356
+ path: &Path,357
+ temporary: &Path,358
+ file: &mut File,359
+ parent: &Path,360
+ sealed: &[u8],361
+ ) -> io::Result<()> {362
+ file.write_all(sealed)?;363
+ file.sync_all()?;364
+ fs::rename(temporary, path)?;365
+ sync_directory(parent)366
+ }367
+368
+ pub(super) fn remove(path: &Path) -> io::Result<()> {369
+ match fs::remove_file(path) {370
+ Err(error) if error.kind() != ErrorKind::NotFound => Err(error),371
+ _ => Ok(()),372
+ }373
+ }374
+375
+ /// `create_new` is `O_EXCL`, which posix says never follows a symlink, even a dangling one376
+ #[cfg(unix)]377
+ fn private_file(path: &Path) -> io::Result<File> {378
+ use std::os::unix::fs::OpenOptionsExt;379
+ OpenOptions::new()380
+ .write(true)381
+ .create_new(true)382
+ .mode(0o600)383
+ .open(path)384
+ }385
+386
+ #[cfg(not(unix))]387
+ fn private_file(path: &Path) -> io::Result<File> {388
+ OpenOptions::new().write(true).create_new(true).open(path)389
+ }390
+391
+ /// a rename isn't durable until its directory is synced392
+ #[cfg(unix)]393
+ fn sync_directory(path: &Path) -> io::Result<()> {394
+ File::open(path)?.sync_all()395
+ }396
+397
+ #[cfg(not(unix))]398
+ fn sync_directory(_: &Path) -> io::Result<()> {399
+ Ok(())400
+ }401
+}402
+403
+#[cfg(feature = "indexeddb")]404
+mod backend {405
+ use indexed_db_futures::{406
+ Build,407
+ database::Database,408
+ error::{Error, OpenDbError},409
+ prelude::*,410
+ transaction::{Transaction, TransactionMode},411
+ typed_array::{Uint8Array, Uint8ArraySlice},412
+ };413
+414
+ const RECORDS: &str = "records";415
+ const KEY: &str = "record";416
+417
+ async fn open(name: &str) -> Result<Database, OpenDbError> {418
+ Database::open(name)419
+ .with_version(1u32)420
+ .with_on_upgrade_needed(|_, transaction: &Transaction<'_>| -> Result<(), Error> {421
+ transaction.db().create_object_store(RECORDS).build()?;422
+ Ok(())423
+ })424
+ .build()?425
+ .await426
+ }427
+428
+ /// the crate's `Database` never closes itself on drop, and an open one blocks deleting it429
+ pub(super) async fn read(name: &str) -> Result<Option<Vec<u8>>, OpenDbError> {430
+ let database = open(name).await?;431
+ let sealed = get(&database).await;432
+ database.close();433
+ Ok(sealed?.map(Into::into))434
+ }435
+436
+ pub(super) async fn write(name: &str, sealed: &[u8]) -> Result<(), OpenDbError> {437
+ let database = open(name).await?;438
+ let written = put(&database, sealed).await;439
+ database.close();440
+ Ok(written?)441
+ }442
+443
+ pub(super) async fn remove(name: &str) -> Result<(), super::DeleteError> {444
+ super::delete_database(name).await445
+ }446
+447
+ async fn get(database: &Database) -> Result<Option<Uint8Array>, Error> {448
+ database449
+ .transaction(RECORDS)450
+ .with_mode(TransactionMode::Readonly)451
+ .build()?452
+ .object_store(RECORDS)?453
+ .get(KEY)454
+ .await455
+ }456
+457
+ async fn put(database: &Database, sealed: &[u8]) -> Result<(), Error> {458
+ let transaction = database459
+ .transaction(RECORDS)460
+ .with_mode(TransactionMode::Readwrite)461
+ .build()?;462
+ transaction463
+ .object_store(RECORDS)?464
+ .put(Uint8ArraySlice::new(sealed))465
+ .with_key(KEY)466
+ .build()?;467
+ transaction.commit().await468
+ }469
+}470
+471
+#[cfg(feature = "sqlite")]472
+pub(crate) fn delete_sqlite_store(path: &Path) -> std::io::Result<()> {473
+ use std::{fs, io::ErrorKind};474
+475
+ // matrix-sdk-sqlite 0.18's database names, which it keeps private476
+ const DATABASES: [&str; 4] = [477
+ "matrix-sdk-state.sqlite3",478
+ "matrix-sdk-crypto.sqlite3",479
+ "matrix-sdk-event-cache.sqlite3",480
+ "matrix-sdk-media.sqlite3",481
+ ];482
+ for database in DATABASES {483
+ for suffix in ["", "-wal", "-shm", "-journal"] {484
+ match fs::remove_file(path.join(format!("{database}{suffix}"))) {485
+ Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),486
+ _ => {}487
+ }488
+ }489
+ }490
+ match fs::remove_file(path.join(crate::store_lock::LOCK_FILE)) {491
+ Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),492
+ _ => {}493
+ }494
+ match fs::remove_dir(path) {495
+ Err(error) if error.kind() != ErrorKind::NotFound => Err(error),496
+ _ => Ok(()),497
+ }498
+}499
+500
+#[cfg(feature = "indexeddb")]501
+pub(crate) async fn delete_indexeddb_store(name: &str) -> Result<(), DeleteError> {502
+ // the databases matrix-sdk-indexeddb 0.18 opens under one store name503
+ for suffix in [504
+ "",505
+ "::matrix-sdk-state",506
+ "::matrix-sdk-crypto",507
+ "::matrix-sdk-crypto-meta",508
+ "::event_cache",509
+ "::media",510
+ ] {511
+ delete_database(&format!("{name}{suffix}")).await?;512
+ }513
+ Ok(())514
+}515
+516
+#[cfg(feature = "indexeddb")]517
+#[derive(Debug, thiserror::Error)]518
+pub(crate) enum DeleteError {519
+ #[error("the database is still open somewhere, it goes once that closes")]520
+ Blocked,521
+ #[error("this global scope has no IndexedDB")]522
+ Unavailable,523
+ #[error("deleting the database failed, {0}")]524
+ Failed(String),525
+}526
+527
+#[cfg(feature = "indexeddb")]528
+impl DeleteError {529
+ pub(crate) fn into_account_error(self, context: &'static str) -> AccountError {530
+ match self {531
+ Self::Blocked => AccountError::StoreDeletePending,532
+ error => account_error(context, error),533
+ }534
+ }535
+}536
+537
+/// matrix_indexed_db_futures 0.7 ignores `blocked` on a delete, which then waits forever538
+#[cfg(feature = "indexeddb")]539
+async fn delete_database(name: &str) -> Result<(), DeleteError> {540
+ use wasm_bindgen::{JsCast, JsValue};541
+542
+ const BLOCKED: &str = "blocked";543
+ let global = js_sys::global();544
+ let factory = if let Some(window) = global.dyn_ref::<web_sys::Window>() {545
+ window.indexed_db()546
+ } else if let Some(worker) = global.dyn_ref::<web_sys::WorkerGlobalScope>() {547
+ worker.indexed_db()548
+ } else {549
+ return Err(DeleteError::Unavailable);550
+ };551
+ let factory = factory.ok().flatten().ok_or(DeleteError::Unavailable)?;552
+ let request = factory553
+ .delete_database(name)554
+ .map_err(|error| DeleteError::Failed(format!("{error:?}")))?;555
+ let settled = js_sys::Promise::new(&mut |resolve, reject| {556
+ request.set_onsuccess(Some(&resolve));557
+ request.set_onerror(Some(&reject));558
+ let blocked = reject.bind1(&JsValue::NULL, &JsValue::from_str(BLOCKED));559
+ request.set_onblocked(Some(blocked.unchecked_ref()));560
+ });561
+ match wasm_bindgen_futures::JsFuture::from(settled).await {562
+ Ok(_) => Ok(()),563
+ Err(reason) if reason.as_string().as_deref() == Some(BLOCKED) => Err(DeleteError::Blocked),564
+ Err(_) => Err(DeleteError::Failed(format!("{:?}", request.error()))),565
+ }566
+}@@ -0,0 +1,117 @@
1
+use crate::{AccountError, account::account_error};2
+3
+/// the platform's own exclusive lock on a store. matrix-sdk 0.18's cross process lease drops4
+/// itself silently after one failed renewal or a stall past 500ms, so it can't be what keeps a5
+/// second engine out6
+pub(crate) struct StoreLock {7
+ #[cfg(feature = "sqlite")]8
+ _file: std::fs::File,9
+ #[cfg(feature = "indexeddb")]10
+ release: js_sys::Function,11
+}12
+13
+#[cfg(feature = "sqlite")]14
+pub(crate) const LOCK_FILE: &str = "macltok.lock";15
+16
+#[cfg(feature = "sqlite")]17
+impl StoreLock {18
+ pub(crate) fn file(directory: &std::path::Path) -> Result<Self, AccountError> {19
+ use std::fs::{self, File, TryLockError};20
+21
+ fs::create_dir_all(directory)22
+ .map_err(|error| account_error("creating the Matrix store directory", error))?;23
+ let file = File::options()24
+ .create(true)25
+ .truncate(false)26
+ .write(true)27
+ .open(directory.join(LOCK_FILE))28
+ .map_err(|error| account_error("opening the Matrix store's lock file", error))?;29
+ match file.try_lock() {30
+ Ok(()) => Ok(Self { _file: file }),31
+ Err(TryLockError::WouldBlock) => Err(AccountError::StoreInUse),32
+ Err(TryLockError::Error(error)) => {33
+ Err(account_error("locking the Matrix store", error))34
+ }35
+ }36
+ }37
+}38
+39
+#[cfg(feature = "indexeddb")]40
+#[derive(Debug, thiserror::Error)]41
+enum WebLockError {42
+ #[error("this global scope has no Web Locks, they need a secure context")]43
+ Unavailable,44
+ #[error("requesting the Web Lock failed, {0}")]45
+ Failed(String),46
+}47
+48
+#[cfg(feature = "indexeddb")]49
+impl StoreLock {50
+ /// `ifAvailable` answers at once. the lock is ours until the promise we hand back settles51
+ pub(crate) async fn web(name: &str) -> Result<Self, AccountError> {52
+ use futures_util::future::{Either, select};53
+ use js_sys::{Function, Object, Promise, Reflect};54
+ use tokio::sync::oneshot;55
+ use wasm_bindgen::{JsCast, JsValue, closure::Closure};56
+57
+ let failed = |error: JsValue| {58
+ account_error(59
+ "locking the Matrix store",60
+ WebLockError::Failed(format!("{error:?}")),61
+ )62
+ };63
+ let locks = Reflect::get(&js_sys::global(), &JsValue::from_str("navigator"))64
+ .and_then(|navigator| Reflect::get(&navigator, &JsValue::from_str("locks")))65
+ .map_err(failed)?;66
+ if locks.is_undefined() {67
+ return Err(account_error(68
+ "locking the Matrix store",69
+ WebLockError::Unavailable,70
+ ));71
+ }72
+ let request: Function = Reflect::get(&locks, &JsValue::from_str("request"))73
+ .map_err(failed)?74
+ .dyn_into()75
+ .map_err(failed)?;76
+ let options = Object::new();77
+ Reflect::set(&options, &JsValue::from_str("ifAvailable"), &JsValue::TRUE)78
+ .map_err(failed)?;79
+80
+ let (granted, grant) = oneshot::channel();81
+ let callback = Closure::once(move |lock: JsValue| -> JsValue {82
+ if lock.is_null() {83
+ let _ = granted.send(None);84
+ return JsValue::UNDEFINED;85
+ }86
+ let mut release = None;87
+ let held = Promise::new(&mut |resolve, _| release = Some(resolve));88
+ let _ = granted.send(release);89
+ held.into()90
+ });91
+ let outcome: Promise = request92
+ .call3(93
+ &locks,94
+ &JsValue::from_str(&format!("macltok:{name}")),95
+ &options,96
+ callback.as_ref(),97
+ )98
+ .map_err(failed)?99
+ .dyn_into()100
+ .map_err(failed)?;101
+ match select(grant, wasm_bindgen_futures::JsFuture::from(outcome)).await {102
+ Either::Left((Ok(Some(release)), _)) => Ok(Self { release }),103
+ Either::Left((Ok(None), _)) => Err(AccountError::StoreInUse),104
+ Either::Left((Err(_), _)) | Either::Right((Ok(_), _)) => {105
+ Err(failed(JsValue::from_str("the lock callback never ran")))106
+ }107
+ Either::Right((Err(error), _)) => Err(failed(error)),108
+ }109
+ }110
+}111
+112
+#[cfg(feature = "indexeddb")]113
+impl Drop for StoreLock {114
+ fn drop(&mut self) {115
+ let _ = self.release.call0(&wasm_bindgen::JsValue::UNDEFINED);116
+ }117
+}@@ -0,0 +1,1104 @@
1
+use std::{2
+ fmt,3
+ future::Future,4
+ pin::Pin,5
+ sync::Arc,6
+ task::{Context, Poll},7
+};8
+9
+use matrix_sdk::{Client, RoomMemberships};10
+use matrix_sdk_ui::{11
+ notification_client::{12
+ NotificationClient, NotificationProcessSetup, NotificationStatus, RawNotificationEvent,13
+ },14
+ spaces::{SpaceRoomList, SpaceService, room_list::SpaceRoomListPaginationState},15
+ sync_service::SyncService,16
+};17
+use ruma::{18
+ EventId, MxcUri, OwnedDeviceId, OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedRoomOrAliasId,19
+ OwnedServerName, OwnedTransactionId, OwnedUserId, RoomId, UserId,20
+ events::{21
+ AnyGlobalAccountDataEventContent, AnyRoomAccountDataEvent, AnyRoomAccountDataEventContent,22
+ GlobalAccountDataEventType, RoomAccountDataEventType,23
+ },24
+ serde::Raw,25
+};26
+use tokio::sync::{OnceCell, mpsc, oneshot, watch};27
+28
+use crate::{29
+ AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,30
+ EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, InteractiveAuth,31
+ InteractiveAuthOutcome, LifecycleCommand, Media, MediaCommand, MediaId, MediaLocation,32
+ MediaSize, MemberFilter, MemberSummary, MessageContent, MessageEditContent,33
+ NotificationCommand, OwnDevice, PowerLevelChanges, Profile, PushNotification,34
+ PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,35
+ RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild,36
+ TimelineCommand, TimelineId, VerificationAction, VerificationTarget,37
+ encryption::encryption_state,38
+ engine::{MediaService, StoreLock},39
+ model::{PushNotificationRoom, PushNotificationSender},40
+ normalise::{member_summary, room_summaries, space_child},41
+};42
+43
+#[derive(Clone)]44
+pub struct ClientHandle {45
+ requests: mpsc::Sender<Request>,46
+ client: Client,47
+ connection: watch::Receiver<ConnectionState>,48
+ notification_client: Arc<OnceCell<NotificationClient>>,49
+ sync_service: Arc<SyncService>,50
+ media: Arc<MediaService>,51
+ spaces: Arc<OnceCell<SpaceService>>,52
+ _store_lock: Option<Arc<StoreLock>>,53
+}54
+55
+impl fmt::Debug for ClientHandle {56
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {57
+ formatter58
+ .debug_struct("ClientHandle")59
+ .finish_non_exhaustive()60
+ }61
+}62
+63
+/// a command's answer. drop it if you don't care, the command still runs64
+#[derive(Debug)]65
+#[must_use = "dropping it is fine but then you'll never know"]66
+pub struct Pending(oneshot::Receiver<Result<CommandOutput, Error>>);67
+68
+impl Future for Pending {69
+ type Output = Result<CommandOutput, Error>;70
+71
+ fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {72
+ Pin::new(&mut self.0)73
+ .poll(context)74
+ .map(|result| result.unwrap_or_else(|_| Err(Error::interrupted())))75
+ }76
+}77
+78
+impl ClientHandle {79
+ /// current credentials, it holds secrets. the engine saves it for you unless the80
+ /// session was built `without_persistence`81
+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]82
+ pub fn session_record(&self) -> Result<crate::SessionRecord, crate::AccountError> {83
+ crate::account::session_record(&self.client)84
+ }85
+86
+ /// the bearer token. it rotates on every refresh so ask again after a 401 rather than87
+ /// keeping it, and keep it in memory and away from all but the homeserver's media endpoints88
+ pub fn access_token(&self) -> Option<String> {89
+ self.client.access_token()90
+ }91
+92
+ /// queue a command, waiting for room in the queue if there isn't any93
+ pub async fn send(&self, command: Command) -> Result<Pending, Error> {94
+ let (request, pending) = Request::new(command);95
+ self.requests96
+ .send(request)97
+ .await98
+ .map_err(|_| Error::interrupted())?;99
+ Ok(pending)100
+ }101
+102
+ /// queue a command or fail with `QueueFull` right now103
+ pub fn try_send(&self, command: Command) -> Result<Pending, Error> {104
+ let (request, pending) = Request::new(command);105
+ self.requests106
+ .try_send(request)107
+ .map_err(|error| match error {108
+ mpsc::error::TrySendError::Full(_) => {109
+ Error::new(ErrorKind::QueueFull, "the command queue is full")110
+ }111
+ mpsc::error::TrySendError::Closed(_) => Error::interrupted(),112
+ })?;113
+ Ok(pending)114
+ }115
+116
+ /// queue a command and wait for its answer. timeline sends answer once the117
+ /// sdk send queue has them, delivery shows up later as the item's send state118
+ pub async fn execute(&self, command: Command) -> Result<CommandOutput, Error> {119
+ self.send(command).await?.await120
+ }121
+122
+ async fn run(&self, command: Command) -> Result<(), Error> {123
+ self.execute(command).await.map(drop)124
+ }125
+126
+ async fn run_for_room(&self, command: RoomCommand) -> Result<OwnedRoomId, Error> {127
+ match self.execute(Command::Room(command)).await? {128
+ CommandOutput::Room(room_id) => Ok(room_id),129
+ output => Err(Error::new(130
+ ErrorKind::Other,131
+ format!("expected a room but got {output:?}"),132
+ )),133
+ }134
+ }135
+136
+ pub async fn shutdown(&self) -> Result<(), Error> {137
+ self.run(Command::Lifecycle(LifecycleCommand::Shutdown))138
+ .await139
+ }140
+141
+ pub async fn logout(&self) -> Result<(), Error> {142
+ self.run(Command::Account(AccountCommand::Logout)).await143
+ }144
+145
+ pub async fn delete_devices(146
+ &self,147
+ device_ids: Vec<OwnedDeviceId>,148
+ auth: Option<InteractiveAuth>,149
+ ) -> Result<InteractiveAuthOutcome, Error> {150
+ match self151
+ .run(Command::Account(AccountCommand::DeleteDevices {152
+ device_ids,153
+ auth,154
+ }))155
+ .await156
+ {157
+ Ok(()) => Ok(InteractiveAuthOutcome::Completed),158
+ Err(Error {159
+ challenge: Some(challenge),160
+ ..161
+ }) => Ok(InteractiveAuthOutcome::Challenge(*challenge)),162
+ Err(error) => Err(error),163
+ }164
+ }165
+166
+ pub async fn rename_device(167
+ &self,168
+ device_id: OwnedDeviceId,169
+ display_name: impl Into<String>,170
+ ) -> Result<(), Error> {171
+ self.run(Command::Account(AccountCommand::RenameDevice {172
+ device_id,173
+ display_name: display_name.into(),174
+ }))175
+ .await176
+ }177
+178
+ pub async fn set_global_account_data(179
+ &self,180
+ event_type: GlobalAccountDataEventType,181
+ content: Raw<AnyGlobalAccountDataEventContent>,182
+ ) -> Result<(), Error> {183
+ self.run(Command::Account(AccountCommand::SetAccountData {184
+ event_type,185
+ content,186
+ }))187
+ .await188
+ }189
+190
+ pub async fn set_room_account_data(191
+ &self,192
+ room_id: OwnedRoomId,193
+ event_type: RoomAccountDataEventType,194
+ content: Raw<AnyRoomAccountDataEventContent>,195
+ ) -> Result<(), Error> {196
+ self.run(Command::Room(RoomCommand::SetAccountData {197
+ room_id,198
+ event_type,199
+ content,200
+ }))201
+ .await202
+ }203
+204
+ pub async fn set_room_notification_mode(205
+ &self,206
+ room_id: OwnedRoomId,207
+ mode: RoomNotificationMode,208
+ ) -> Result<(), Error> {209
+ self.run(Command::Notification(NotificationCommand::SetRoomMode {210
+ room_id,211
+ mode,212
+ }))213
+ .await214
+ }215
+216
+ pub async fn reset_room_notification_mode(&self, room_id: OwnedRoomId) -> Result<(), Error> {217
+ self.run(Command::Notification(NotificationCommand::ResetRoomMode(218
+ room_id,219
+ )))220
+ .await221
+ }222
+223
+ pub async fn set_default_notification_mode(224
+ &self,225
+ encrypted: bool,226
+ one_to_one: bool,227
+ mode: RoomNotificationMode,228
+ ) -> Result<(), Error> {229
+ self.run(Command::Notification(NotificationCommand::SetDefaultMode {230
+ encrypted,231
+ one_to_one,232
+ mode,233
+ }))234
+ .await235
+ }236
+237
+ pub async fn add_notification_keyword(&self, keyword: impl Into<String>) -> Result<(), Error> {238
+ self.run(Command::Notification(NotificationCommand::AddKeyword(239
+ keyword.into(),240
+ )))241
+ .await242
+ }243
+244
+ pub async fn remove_notification_keyword(245
+ &self,246
+ keyword: impl Into<String>,247
+ ) -> Result<(), Error> {248
+ self.run(Command::Notification(NotificationCommand::RemoveKeyword(249
+ keyword.into(),250
+ )))251
+ .await252
+ }253
+254
+ pub async fn open_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {255
+ self.run(Command::Room(RoomCommand::Open(room_id))).await256
+ }257
+258
+ pub async fn close_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {259
+ self.run(Command::Room(RoomCommand::Close(room_id))).await260
+ }261
+262
+ pub async fn open_thread(263
+ &self,264
+ room_id: OwnedRoomId,265
+ root_event_id: OwnedEventId,266
+ ) -> Result<(), Error> {267
+ self.run(Command::Timeline(TimelineCommand::OpenThread {268
+ room_id,269
+ root_event_id,270
+ }))271
+ .await272
+ }273
+274
+ pub async fn close_thread(275
+ &self,276
+ room_id: OwnedRoomId,277
+ root_event_id: OwnedEventId,278
+ ) -> Result<(), Error> {279
+ self.run(Command::Timeline(TimelineCommand::CloseThread {280
+ room_id,281
+ root_event_id,282
+ }))283
+ .await284
+ }285
+286
+ pub async fn resync(&self, timeline_id: TimelineId) -> Result<(), Error> {287
+ self.run(Command::Timeline(TimelineCommand::Resync { timeline_id }))288
+ .await289
+ }290
+291
+ pub async fn paginate(&self, timeline_id: TimelineId, limit: u16) -> Result<(), Error> {292
+ self.run(Command::Timeline(TimelineCommand::Paginate {293
+ timeline_id,294
+ limit,295
+ }))296
+ .await297
+ }298
+299
+ pub async fn send_message(300
+ &self,301
+ timeline_id: TimelineId,302
+ content: MessageContent,303
+ ) -> Result<(), Error> {304
+ self.run(Command::Timeline(TimelineCommand::Send {305
+ timeline_id,306
+ content,307
+ }))308
+ .await309
+ }310
+311
+ pub async fn edit_message(312
+ &self,313
+ timeline_id: TimelineId,314
+ target: impl Into<EventRef>,315
+ content: MessageEditContent,316
+ ) -> Result<(), Error> {317
+ self.run(Command::Timeline(TimelineCommand::Edit {318
+ timeline_id,319
+ target: target.into(),320
+ content,321
+ }))322
+ .await323
+ }324
+325
+ pub async fn redact(326
+ &self,327
+ timeline_id: TimelineId,328
+ target: impl Into<EventRef>,329
+ reason: Option<String>,330
+ ) -> Result<(), Error> {331
+ self.run(Command::Timeline(TimelineCommand::Redact {332
+ timeline_id,333
+ target: target.into(),334
+ reason,335
+ }))336
+ .await337
+ }338
+339
+ pub async fn toggle_reaction(340
+ &self,341
+ timeline_id: TimelineId,342
+ target: impl Into<EventRef>,343
+ key: impl Into<String>,344
+ ) -> Result<(), Error> {345
+ self.run(Command::Timeline(TimelineCommand::ToggleReaction {346
+ timeline_id,347
+ target: target.into(),348
+ key: key.into(),349
+ }))350
+ .await351
+ }352
+353
+ pub async fn mark_read(354
+ &self,355
+ timeline_id: TimelineId,356
+ event_id: OwnedEventId,357
+ ) -> Result<(), Error> {358
+ self.run(Command::Timeline(TimelineCommand::MarkRead {359
+ timeline_id,360
+ event_id,361
+ }))362
+ .await363
+ }364
+365
+ pub async fn set_typing(&self, room_id: OwnedRoomId, typing: bool) -> Result<(), Error> {366
+ self.run(Command::Room(RoomCommand::SetTyping { room_id, typing }))367
+ .await368
+ }369
+370
+ pub async fn prepare_to_send(&self, room_id: OwnedRoomId) -> Result<(), Error> {371
+ self.run(Command::Room(RoomCommand::PrepareToSend(room_id)))372
+ .await373
+ }374
+375
+ pub async fn join_room(376
+ &self,377
+ room: OwnedRoomOrAliasId,378
+ via: Vec<OwnedServerName>,379
+ ) -> Result<OwnedRoomId, Error> {380
+ self.run_for_room(RoomCommand::Join { room, via }).await381
+ }382
+383
+ pub async fn leave_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {384
+ self.run(Command::Room(RoomCommand::Leave(room_id))).await385
+ }386
+387
+ pub async fn forget_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {388
+ self.run(Command::Room(RoomCommand::Forget(room_id))).await389
+ }390
+391
+ pub async fn accept_invite(&self, room_id: OwnedRoomId) -> Result<(), Error> {392
+ self.run(Command::Room(RoomCommand::AcceptInvite(room_id)))393
+ .await394
+ }395
+396
+ pub async fn reject_invite(&self, room_id: OwnedRoomId) -> Result<(), Error> {397
+ self.run(Command::Room(RoomCommand::RejectInvite(room_id)))398
+ .await399
+ }400
+401
+ pub async fn invite_user(402
+ &self,403
+ room_id: OwnedRoomId,404
+ user_id: OwnedUserId,405
+ ) -> Result<(), Error> {406
+ self.run(Command::Room(RoomCommand::InviteUser { room_id, user_id }))407
+ .await408
+ }409
+410
+ pub async fn kick_user(411
+ &self,412
+ room_id: OwnedRoomId,413
+ user_id: OwnedUserId,414
+ reason: Option<String>,415
+ ) -> Result<(), Error> {416
+ self.run(Command::Room(RoomCommand::KickUser {417
+ room_id,418
+ user_id,419
+ reason,420
+ }))421
+ .await422
+ }423
+424
+ pub async fn ban_user(425
+ &self,426
+ room_id: OwnedRoomId,427
+ user_id: OwnedUserId,428
+ reason: Option<String>,429
+ ) -> Result<(), Error> {430
+ self.run(Command::Room(RoomCommand::BanUser {431
+ room_id,432
+ user_id,433
+ reason,434
+ }))435
+ .await436
+ }437
+438
+ pub async fn unban_user(439
+ &self,440
+ room_id: OwnedRoomId,441
+ user_id: OwnedUserId,442
+ reason: Option<String>,443
+ ) -> Result<(), Error> {444
+ self.run(Command::Room(RoomCommand::UnbanUser {445
+ room_id,446
+ user_id,447
+ reason,448
+ }))449
+ .await450
+ }451
+452
+ pub async fn set_power_levels(453
+ &self,454
+ room_id: OwnedRoomId,455
+ changes: PowerLevelChanges,456
+ ) -> Result<(), Error> {457
+ self.run(Command::Room(RoomCommand::SetPowerLevels {458
+ room_id,459
+ changes,460
+ }))461
+ .await462
+ }463
+464
+ pub async fn set_user_power_level(465
+ &self,466
+ room_id: OwnedRoomId,467
+ user_id: OwnedUserId,468
+ level: i64,469
+ ) -> Result<(), Error> {470
+ self.run(Command::Room(RoomCommand::SetUserPowerLevel {471
+ room_id,472
+ user_id,473
+ level,474
+ }))475
+ .await476
+ }477
+478
+ pub async fn create_room(&self, options: CreateRoomOptions) -> Result<OwnedRoomId, Error> {479
+ self.run_for_room(RoomCommand::Create(options)).await480
+ }481
+482
+ /// hands back the existing dm with them if there is one483
+ pub async fn create_direct(&self, user_id: OwnedUserId) -> Result<OwnedRoomId, Error> {484
+ self.run_for_room(RoomCommand::CreateDirect(user_id)).await485
+ }486
+487
+ pub async fn cancel_send(488
+ &self,489
+ room_id: OwnedRoomId,490
+ transaction_id: OwnedTransactionId,491
+ ) -> Result<(), Error> {492
+ self.run(Command::Room(RoomCommand::CancelSend {493
+ room_id,494
+ transaction_id,495
+ }))496
+ .await497
+ }498
+499
+ pub async fn retry_send(500
+ &self,501
+ room_id: OwnedRoomId,502
+ transaction_id: OwnedTransactionId,503
+ ) -> Result<(), Error> {504
+ self.run(Command::Room(RoomCommand::RetrySend {505
+ room_id,506
+ transaction_id,507
+ }))508
+ .await509
+ }510
+511
+ pub async fn upload(&self, upload: AttachmentUpload) -> Result<(), Error> {512
+ self.run(Command::Media(MediaCommand::Upload(upload))).await513
+ }514
+515
+ pub async fn recover(&self, recovery_key: impl Into<String>) -> Result<(), Error> {516
+ self.run(Command::Encryption(EncryptionCommand::Recover(517
+ recovery_key.into(),518
+ )))519
+ .await520
+ }521
+522
+ pub async fn create_key_backup(&self) -> Result<(), Error> {523
+ self.run(Command::Encryption(EncryptionCommand::CreateKeyBackup))524
+ .await525
+ }526
+527
+ pub async fn enable_key_backup(&self) -> Result<(), Error> {528
+ self.run(Command::Encryption(EncryptionCommand::EnableKeyBackup))529
+ .await530
+ }531
+532
+ pub async fn disable_key_backup(&self, delete_from_server: bool) -> Result<(), Error> {533
+ self.run(Command::Encryption(EncryptionCommand::DisableKeyBackup {534
+ delete_from_server,535
+ }))536
+ .await537
+ }538
+539
+ pub async fn recover_key_backup(&self, recovery_key: impl Into<String>) -> Result<(), Error> {540
+ self.run(Command::Encryption(EncryptionCommand::RecoverKeyBackup(541
+ recovery_key.into(),542
+ )))543
+ .await544
+ }545
+546
+ pub async fn start_verification(&self, target: VerificationTarget) -> Result<(), Error> {547
+ self.run(Command::Encryption(EncryptionCommand::StartVerification(548
+ target,549
+ )))550
+ .await551
+ }552
+553
+ pub async fn verify(&self, action: VerificationAction) -> Result<(), Error> {554
+ self.run(Command::Encryption(EncryptionCommand::Verify(action)))555
+ .await556
+ }557
+558
+ /// bytes for media a retained timeline showed you. concurrent asks share one download559
+ pub async fn media(&self, media_id: &MediaId, size: MediaSize) -> Result<Media, Error> {560
+ self.media.timeline_media(media_id, size).await561
+ }562
+563
+ /// where `media` would fetch from, for a host streaming big files itself. the byte cap564
+ /// doesn't apply since nothing is buffered565
+ pub async fn media_source(566
+ &self,567
+ media_id: &MediaId,568
+ size: MediaSize,569
+ ) -> Result<MediaLocation, Error> {570
+ self.media.media_location(media_id, size).await571
+ }572
+573
+ /// bytes for any plain mxc uri, avatars mostly. same byte cap as everything else574
+ pub async fn mxc_media(&self, uri: &MxcUri, size: MediaSize) -> Result<Media, Error> {575
+ self.media.mxc_media(uri, size).await576
+ }577
+578
+ /// the space graph builds on first use and then keeps itself current579
+ async fn spaces(&self) -> &SpaceService {580
+ self.spaces581
+ .get_or_init(|| SpaceService::new(self.client.clone()))582
+ .await583
+ }584
+585
+ pub async fn rooms(&self) -> Vec<RoomSummary> {586
+ room_summaries(&self.client, self.spaces().await).await587
+ }588
+589
+ /// browse a space the way the server sees it, rooms you haven't joined included590
+ pub async fn space_hierarchy(&self, space_id: OwnedRoomId) -> SpaceHierarchy {591
+ SpaceHierarchy(SpaceRoomList::new(self.client.clone(), space_id).await)592
+ }593
+594
+ pub async fn own_profile(&self) -> Result<Profile, Error> {595
+ let user_id = self596
+ .client597
+ .user_id()598
+ .ok_or_else(|| Error::new(ErrorKind::Unauthenticated, "the client is not logged in"))?599
+ .to_owned();600
+ let response = self601
+ .client602
+ .account()603
+ .fetch_user_profile()604
+ .await605
+ .map_err(|source| Error::from_sdk("fetching the profile failed", source))?;606
+ profile(user_id, &response)607
+ }608
+609
+ pub async fn user_profile(&self, user_id: &UserId) -> Result<Profile, Error> {610
+ let response = self611
+ .client612
+ .account()613
+ .fetch_user_profile_of(user_id)614
+ .await615
+ .map_err(|source| Error::from_sdk("fetching the profile failed", source))?;616
+ profile(user_id.to_owned(), &response)617
+ }618
+619
+ pub async fn set_display_name(&self, name: Option<String>) -> Result<(), Error> {620
+ self.run(Command::Account(AccountCommand::SetDisplayName(name)))621
+ .await622
+ }623
+624
+ pub async fn set_avatar(&self, avatar: Option<OwnedMxcUri>) -> Result<(), Error> {625
+ self.run(Command::Account(AccountCommand::SetAvatar(avatar)))626
+ .await627
+ }628
+629
+ pub async fn upload_avatar(630
+ &self,631
+ mimetype: impl Into<String>,632
+ bytes: Vec<u8>,633
+ ) -> Result<(), Error> {634
+ self.run(Command::Account(AccountCommand::UploadAvatar {635
+ mimetype: mimetype.into(),636
+ bytes,637
+ }))638
+ .await639
+ }640
+641
+ pub async fn rooms_filtered(&self, filter: &RoomListFilter) -> Vec<RoomSummary> {642
+ let matcher = filter.matcher();643
+ self.rooms()644
+ .await645
+ .into_iter()646
+ .filter(|room| matcher.matches(room))647
+ .collect()648
+ }649
+650
+ pub async fn devices(&self) -> Result<Vec<OwnDevice>, Error> {651
+ let current_device_id = self.client.device_id();652
+ Ok(self653
+ .client654
+ .devices()655
+ .await656
+ .map_err(|source| Error::from_http("listing devices failed", source))?657
+ .devices658
+ .into_iter()659
+ .map(|device| OwnDevice {660
+ current: current_device_id == Some(device.device_id.as_ref()),661
+ device_id: device.device_id,662
+ display_name: device.display_name,663
+ last_seen_ip: device.last_seen_ip,664
+ last_seen_timestamp_ms: device.last_seen_ts.map(|timestamp| timestamp.get().into()),665
+ })666
+ .collect())667
+ }668
+669
+ fn room(&self, room_id: &RoomId) -> Result<matrix_sdk::Room, Error> {670
+ self.client671
+ .get_room(room_id)672
+ .ok_or_else(Error::room_unknown)673
+ }674
+675
+ pub async fn room_notification_settings(676
+ &self,677
+ room_id: &RoomId,678
+ ) -> Result<Option<RoomNotificationSettings>, Error> {679
+ let room = self.room(room_id)?;680
+ let custom = room.user_defined_notification_mode().await;681
+ let mode = match custom {682
+ Some(mode) => mode,683
+ None => match room.notification_mode().await {684
+ Some(mode) => mode,685
+ None => return Ok(None),686
+ },687
+ };688
+ Ok(Some(RoomNotificationSettings {689
+ mode: crate::normalise::notification_mode(mode),690
+ custom: custom.is_some(),691
+ }))692
+ }693
+694
+ pub async fn notification_keywords(&self) -> Vec<String> {695
+ self.client696
+ .notification_settings()697
+ .await698
+ .enabled_keywords()699
+ .await700
+ .into_iter()701
+ .collect()702
+ }703
+704
+ pub async fn resolve_push_notification(705
+ &self,706
+ room_id: &RoomId,707
+ event_id: &EventId,708
+ ) -> Result<PushNotificationStatus, Error> {709
+ let notification_failed = |source| {710
+ Error::with_source(711
+ ErrorKind::Other,712
+ "resolving the push notification failed",713
+ source,714
+ )715
+ };716
+ let client = self717
+ .notification_client718
+ .get_or_try_init(|| async {719
+ NotificationClient::new(720
+ self.client.clone(),721
+ NotificationProcessSetup::SingleProcess {722
+ sync_service: self.sync_service.clone(),723
+ },724
+ )725
+ .await726
+ })727
+ .await728
+ .map_err(notification_failed)?;729
+ let status = client730
+ .get_notification(room_id, event_id)731
+ .await732
+ .map_err(notification_failed)?;733
+ Ok(normalise_push_notification(room_id, event_id, status))734
+ }735
+736
+ pub async fn members(737
+ &self,738
+ room_id: &RoomId,739
+ filter: MemberFilter,740
+ ) -> Result<Vec<MemberSummary>, Error> {741
+ let memberships = match filter {742
+ MemberFilter::Active => RoomMemberships::ACTIVE,743
+ MemberFilter::Joined => RoomMemberships::JOIN,744
+ MemberFilter::Invited => RoomMemberships::INVITE,745
+ MemberFilter::Banned => RoomMemberships::BAN,746
+ MemberFilter::Left => RoomMemberships::LEAVE,747
+ // An empty mask is the SDK's unfiltered wildcard: it matches every748
+ // membership, including custom states that `all()` would exclude.749
+ MemberFilter::All => RoomMemberships::empty(),750
+ };751
+ Ok(self752
+ .room(room_id)?753
+ .members(memberships)754
+ .await755
+ .map_err(|source| Error::from_sdk("listing members failed", source))?756
+ .into_iter()757
+ .map(member_summary)758
+ .collect())759
+ }760
+761
+ pub async fn room_power_levels(&self, room_id: &RoomId) -> Result<RoomPowerLevels, Error> {762
+ let levels = self763
+ .room(room_id)?764
+ .power_levels()765
+ .await766
+ .map_err(|source| Error::from_sdk("reading power levels failed", source.into()))?;767
+ Ok(RoomPowerLevels {768
+ ban: levels.ban.into(),769
+ invite: levels.invite.into(),770
+ kick: levels.kick.into(),771
+ redact: levels.redact.into(),772
+ events_default: levels.events_default.into(),773
+ state_default: levels.state_default.into(),774
+ users_default: levels.users_default.into(),775
+ room_notification: levels.notifications.room.into(),776
+ users: levels777
+ .users778
+ .into_iter()779
+ .map(|(user_id, level)| (user_id, level.into()))780
+ .collect(),781
+ events: levels782
+ .events783
+ .into_iter()784
+ .map(|(event_type, level)| (event_type.to_string(), level.into()))785
+ .collect(),786
+ creators: levels787
+ .rules788
+ .privileged_creators789
+ .into_iter()790
+ .flatten()791
+ .collect(),792
+ })793
+ }794
+795
+ pub async fn pending_send_ids(796
+ &self,797
+ room_id: &RoomId,798
+ ) -> Result<Vec<OwnedTransactionId>, Error> {799
+ let (echoes, _) = self800
+ .room(room_id)?801
+ .send_queue()802
+ .subscribe()803
+ .await804
+ .map_err(|source| {805
+ Error::with_source(ErrorKind::Store, "reading the send queue failed", source)806
+ })?;807
+ Ok(echoes808
+ .into_iter()809
+ .filter(|echo| {810
+ matches!(811
+ echo.content,812
+ matrix_sdk::send_queue::LocalEchoContent::Event { .. }813
+ )814
+ })815
+ .map(|echo| echo.transaction_id)816
+ .collect())817
+ }818
+819
+ pub async fn global_account_data(820
+ &self,821
+ event_type: GlobalAccountDataEventType,822
+ ) -> Result<Option<Raw<AnyGlobalAccountDataEventContent>>, Error> {823
+ self.client824
+ .account()825
+ .account_data_raw(event_type)826
+ .await827
+ .map_err(|source| Error::from_sdk("reading account data failed", source))828
+ }829
+830
+ pub async fn room_account_data(831
+ &self,832
+ room_id: &RoomId,833
+ event_type: RoomAccountDataEventType,834
+ ) -> Result<Option<Raw<AnyRoomAccountDataEvent>>, Error> {835
+ self.room(room_id)?836
+ .account_data(event_type)837
+ .await838
+ .map_err(|source| Error::from_sdk("reading room account data failed", source))839
+ }840
+841
+ #[must_use]842
+ pub fn connection_state(&self) -> watch::Receiver<ConnectionState> {843
+ self.connection.clone()844
+ }845
+846
+ #[must_use]847
+ pub fn encryption_state(&self) -> Option<EncryptionState> {848
+ let device_id = self.client.device_id()?.to_owned();849
+ let encryption = self.client.encryption();850
+ Some(encryption_state(851
+ device_id,852
+ encryption.recovery().state(),853
+ encryption.verification_state().get(),854
+ encryption.backups().state(),855
+ ))856
+ }857
+}858
+859
+/// one space's hierarchy, a page at a time. hold on to it while you browse, it remembers where it got to860
+pub struct SpaceHierarchy(SpaceRoomList);861
+862
+impl fmt::Debug for SpaceHierarchy {863
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {864
+ formatter865
+ .debug_struct("SpaceHierarchy")866
+ .finish_non_exhaustive()867
+ }868
+}869
+870
+impl SpaceHierarchy {871
+ /// fetches the next page. a no-op once the end is reached or while a page is in flight872
+ pub async fn paginate(&self) -> Result<(), Error> {873
+ self.0.paginate().await.map_err(|source| {874
+ Error::with_source(875
+ ErrorKind::Server,876
+ "fetching the space hierarchy failed",877
+ source,878
+ )879
+ })880
+ }881
+882
+ /// everything fetched so far, the space itself excluded883
+ pub async fn rooms(&self) -> Vec<SpaceChild> {884
+ self.0.rooms().await.into_iter().map(space_child).collect()885
+ }886
+887
+ #[must_use]888
+ pub fn end_reached(&self) -> bool {889
+ matches!(890
+ self.0.pagination_state(),891
+ SpaceRoomListPaginationState::Idle { end_reached: true }892
+ )893
+ }894
+}895
+896
+fn profile(897
+ user_id: OwnedUserId,898
+ response: &ruma::api::client::profile::get_profile::v3::Response,899
+) -> Result<Profile, Error> {900
+ use ruma::api::client::profile::{AvatarUrl, DisplayName};901
+ let malformed = |source| {902
+ Error::with_source(903
+ ErrorKind::Server,904
+ "the server sent a malformed profile",905
+ source,906
+ )907
+ };908
+ Ok(Profile {909
+ user_id,910
+ display_name: response.get_static::<DisplayName>().map_err(malformed)?,911
+ avatar: response.get_static::<AvatarUrl>().map_err(malformed)?,912
+ })913
+}914
+915
+#[derive(Debug)]916
+pub(crate) struct Request {917
+ pub(crate) command: Command,918
+ pub(crate) reply: Responder,919
+}920
+921
+impl Request {922
+ fn new(command: Command) -> (Self, Pending) {923
+ let (reply, pending) = oneshot::channel();924
+ (925
+ Self {926
+ command,927
+ reply: Responder(reply),928
+ },929
+ Pending(pending),930
+ )931
+ }932
+}933
+934
+/// whoever holds this owes the caller an answer. dropping it answers `Interrupted`935
+#[derive(Debug)]936
+pub(crate) struct Responder(oneshot::Sender<Result<CommandOutput, Error>>);937
+938
+impl Responder {939
+ pub(crate) fn send(self, result: Result<CommandOutput, Error>) {940
+ if let Err(error) = &result {941
+ tracing::debug!(%error, kind = ?error.kind, "command failed");942
+ }943
+ let _ = self.0.send(result);944
+ }945
+946
+ pub(crate) fn done(self, result: Result<(), Error>) {947
+ self.send(result.map(|()| CommandOutput::Done));948
+ }949
+}950
+951
+#[derive(Debug)]952
+pub struct EventStream {953
+ events: mpsc::Receiver<Event>,954
+}955
+956
+impl EventStream {957
+ pub async fn recv(&mut self) -> Option<Event> {958
+ self.events.recv().await959
+ }960
+961
+ pub fn try_recv(&mut self) -> Result<Event, mpsc::error::TryRecvError> {962
+ self.events.try_recv()963
+ }964
+}965
+966
+impl futures_util::Stream for EventStream {967
+ type Item = Event;968
+969
+ fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {970
+ self.events.poll_recv(context)971
+ }972
+}973
+974
+#[derive(Debug)]975
+pub(crate) struct DriverPort {976
+ requests: mpsc::Receiver<Request>,977
+ events: mpsc::Sender<Event>,978
+ connection: watch::Sender<ConnectionState>,979
+}980
+981
+#[derive(Clone, Debug)]982
+pub(crate) struct EventSender(mpsc::Sender<Event>);983
+984
+impl EventSender {985
+ pub(crate) async fn send(&self, event: Event) -> Result<(), SendEventError> {986
+ self.0.send(event).await.map_err(|_| SendEventError)987
+ }988
+989
+ /// resolves once every `EventStream` is gone990
+ pub(crate) async fn closed(&self) {991
+ self.0.closed().await;992
+ }993
+}994
+995
+impl DriverPort {996
+ pub(crate) fn event_sender(&self) -> EventSender {997
+ EventSender(self.events.clone())998
+ }999
+1000
+ pub(crate) fn connection_sender(&self) -> watch::Sender<ConnectionState> {1001
+ self.connection.clone()1002
+ }1003
+1004
+ pub(crate) async fn recv(&mut self) -> Option<Request> {1005
+ self.requests.recv().await1006
+ }1007
+1008
+ pub(crate) fn try_recv(&mut self) -> Option<Request> {1009
+ self.requests.try_recv().ok()1010
+ }1011
+1012
+ pub(crate) fn close(&mut self) {1013
+ self.requests.close();1014
+ }1015
+}1016
+1017
+#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]1018
+#[error("the event receiver has closed")]1019
+pub(crate) struct SendEventError;1020
+1021
+#[must_use]1022
+pub(crate) fn transport_pair(1023
+ client: Client,1024
+ sync_service: Arc<SyncService>,1025
+ media: Arc<MediaService>,1026
+ store_lock: Option<Arc<StoreLock>>,1027
+ command_capacity: usize,1028
+ event_capacity: usize,1029
+) -> (ClientHandle, DriverPort, EventStream) {1030
+ let (request_tx, request_rx) = mpsc::channel(command_capacity);1031
+ let (event_tx, event_rx) = mpsc::channel(event_capacity);1032
+ let (connection_tx, connection_rx) = watch::channel(ConnectionState::Idle);1033
+ (1034
+ ClientHandle {1035
+ requests: request_tx,1036
+ client,1037
+ connection: connection_rx,1038
+ notification_client: Arc::new(OnceCell::new()),1039
+ sync_service,1040
+ media,1041
+ spaces: Arc::new(OnceCell::new()),1042
+ _store_lock: store_lock,1043
+ },1044
+ DriverPort {1045
+ requests: request_rx,1046
+ events: event_tx,1047
+ connection: connection_tx,1048
+ },1049
+ EventStream { events: event_rx },1050
+ )1051
+}1052
+1053
+fn normalise_push_notification(1054
+ room_id: &RoomId,1055
+ event_id: &EventId,1056
+ status: NotificationStatus,1057
+) -> PushNotificationStatus {1058
+ match status {1059
+ NotificationStatus::Event(item) => {1060
+ let event = match &item.raw_event {1061
+ RawNotificationEvent::Timeline(event) => {1062
+ PushNotificationEvent::Timeline(event.clone())1063
+ }1064
+ RawNotificationEvent::Invite(event) => PushNotificationEvent::Invite(event.clone()),1065
+ };1066
+ PushNotificationStatus::Event(Box::new(PushNotification {1067
+ event_id: event_id.to_owned(),1068
+ event,1069
+ sender: PushNotificationSender {1070
+ user_id: item.event.sender().to_owned(),1071
+ display_name: item.sender_display_name,1072
+ avatar_url: item.sender_avatar_url.map(Into::into),1073
+ name_ambiguous: item.is_sender_name_ambiguous,1074
+ },1075
+ room: PushNotificationRoom {1076
+ room_id: room_id.to_owned(),1077
+ name: item.room_computed_display_name,1078
+ avatar_url: item.room_avatar_url.map(Into::into),1079
+ canonical_alias: item1080
+ .room_canonical_alias1081
+ .as_deref()1082
+ .and_then(|alias| alias.parse().ok()),1083
+ topic: item.room_topic,1084
+ encrypted: item.is_room_encrypted,1085
+ direct: item.room_is_dm,1086
+ joined_members_count: item.joined_members_count,1087
+ service_members: item1088
+ .service_members1089
+ .into_iter()1090
+ .filter_map(|user_id| user_id.parse().ok())1091
+ .collect(),1092
+ active_service_members_count: item.active_service_members_count,1093
+ space: item.is_space,1094
+ },1095
+ noisy: item.is_noisy,1096
+ has_mention: item.has_mention,1097
+ thread_id: item.thread_id,1098
+ }))1099
+ }1100
+ NotificationStatus::EventNotFound => PushNotificationStatus::NotFound,1101
+ NotificationStatus::EventFilteredOut => PushNotificationStatus::FilteredOut,1102
+ NotificationStatus::EventRedacted => PushNotificationStatus::Redacted,1103
+ }1104
+}@@ -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,168 @@
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, Error, ErrorKind, UploadDetails};12
+13
+pub(crate) fn prepare_upload(14
+ mut upload: AttachmentUpload,15
+ max_media_bytes: usize,16
+) -> Result<(AttachmentUpload, mime::Mime, AttachmentConfig), Error> {17
+ if upload.bytes.len() > max_media_bytes {18
+ return Err(Error::new(19
+ ErrorKind::MediaTooLarge,20
+ "attachment exceeds the configured byte limit",21
+ ));22
+ }23
+ let mimetype: mime::Mime = upload24
+ .mimetype25
+ .parse()26
+ .map_err(|_| Error::new(ErrorKind::Invalid, "media MIME type is invalid"))?;27
+28
+ let size = ruma::UInt::try_from(upload.bytes.len()).map_err(|_| {29
+ Error::new(30
+ ErrorKind::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(Error::new(71
+ ErrorKind::Invalid,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(Error::new(110
+ ErrorKind::Invalid,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(Error::new(117
+ ErrorKind::Invalid,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(Error::new(130
+ if thumbnail.bytes.len() > max_media_bytes {131
+ ErrorKind::MediaTooLarge132
+ } else {133
+ ErrorKind::Invalid134
+ },135
+ "thumbnail size or dimensions are invalid",136
+ ));137
+ }138
+ let content_type: mime::Mime = thumbnail139
+ .mimetype140
+ .parse()141
+ .map_err(|_| Error::new(ErrorKind::Invalid, "thumbnail MIME type is invalid"))?;142
+ if content_type.type_() != mime::IMAGE {143
+ return Err(Error::new(144
+ ErrorKind::Invalid,145
+ "thumbnail MIME type must be an image",146
+ ));147
+ }148
+ let size = ruma::UInt::try_from(thumbnail.bytes.len()).map_err(|_| {149
+ Error::new(150
+ ErrorKind::MediaTooLarge,151
+ "thumbnail size is not representable",152
+ )153
+ })?;154
+ Ok(Thumbnail {155
+ data: thumbnail.bytes,156
+ content_type,157
+ width: thumbnail.width.into(),158
+ height: thumbnail.height.into(),159
+ size,160
+ })161
+ })162
+ .transpose()?;163
+ let config = AttachmentConfig::new()164
+ .info(info)165
+ .thumbnail(thumbnail)166
+ .caption(upload.caption.clone().map(TextMessageEventContent::plain));167
+ Ok((upload, mimetype, config))168
+}@@ -0,0 +1,29 @@
1
+use std::cell::RefCell;2
+3
+type Handler = Box<dyn Fn(&str)>;4
+5
+thread_local! {6
+ static HANDLER: RefCell<Option<Handler>> = const { RefCell::new(None) };7
+}8
+9
+/// a panic on wasm32 traps the task that raised it and nothing unwinds, so the engine10
+/// catches nothing. the rest of it keeps running in an undefined state and goes silent,11
+/// no `Failed` and no `Stopped`. `handler` runs from the panic hook with the message,12
+/// after whatever hook was there before, and is the host's one chance to react, say by13
+/// terminating the worker and starting a fresh one. a second call replaces the handler14
+pub fn on_panic(handler: impl Fn(&str) + 'static) {15
+ let installed = HANDLER.with(|slot| slot.replace(Some(Box::new(handler))).is_some());16
+ if installed {17
+ return;18
+ }19
+ let previous = std::panic::take_hook();20
+ std::panic::set_hook(Box::new(move |info| {21
+ previous(info);22
+ let message = info.to_string();23
+ HANDLER.with(|slot| {24
+ if let Some(handler) = &*slot.borrow() {25
+ handler(&message);26
+ }27
+ });28
+ }));29
+}@@ -0,0 +1 @@
1
+VITE_MALICE_TARGET=device@@ -0,0 +1,12 @@
1
+<!doctype html>2
+<html lang="en">3
+ <head>4
+ <meta charset="UTF-8" />5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />6
+ <title>Malice — Matrix chat</title>7
+ </head>8
+ <body>9
+ <div id="app"></div>10
+ <script type="module" src="/src/main.ts"></script>11
+ </body>12
+</html>@@ -0,0 +1,1315 @@
1
+{2
+ "name": "malice-web",3
+ "version": "0.1.0",4
+ "lockfileVersion": 3,5
+ "requires": true,6
+ "packages": {7
+ "": {8
+ "name": "malice-web",9
+ "version": "0.1.0",10
+ "dependencies": {11
+ "svelte": "^4.2.19"12
+ },13
+ "devDependencies": {14
+ "@sveltejs/vite-plugin-svelte": "^3.1.2",15
+ "typescript": "~5.6.3",16
+ "vite": "^5.4.11"17
+ }18
+ },19
+ "node_modules/@ampproject/remapping": {20
+ "version": "2.3.0",21
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",22
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",23
+ "license": "Apache-2.0",24
+ "dependencies": {25
+ "@jridgewell/gen-mapping": "^0.3.5",26
+ "@jridgewell/trace-mapping": "^0.3.24"27
+ },28
+ "engines": {29
+ "node": ">=6.0.0"30
+ }31
+ },32
+ "node_modules/@esbuild/aix-ppc64": {33
+ "version": "0.21.5",34
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",35
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",36
+ "cpu": [37
+ "ppc64"38
+ ],39
+ "dev": true,40
+ "license": "MIT",41
+ "optional": true,42
+ "os": [43
+ "aix"44
+ ],45
+ "engines": {46
+ "node": ">=12"47
+ }48
+ },49
+ "node_modules/@esbuild/android-arm": {50
+ "version": "0.21.5",51
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",52
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",53
+ "cpu": [54
+ "arm"55
+ ],56
+ "dev": true,57
+ "license": "MIT",58
+ "optional": true,59
+ "os": [60
+ "android"61
+ ],62
+ "engines": {63
+ "node": ">=12"64
+ }65
+ },66
+ "node_modules/@esbuild/android-arm64": {67
+ "version": "0.21.5",68
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",69
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",70
+ "cpu": [71
+ "arm64"72
+ ],73
+ "dev": true,74
+ "license": "MIT",75
+ "optional": true,76
+ "os": [77
+ "android"78
+ ],79
+ "engines": {80
+ "node": ">=12"81
+ }82
+ },83
+ "node_modules/@esbuild/android-x64": {84
+ "version": "0.21.5",85
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",86
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",87
+ "cpu": [88
+ "x64"89
+ ],90
+ "dev": true,91
+ "license": "MIT",92
+ "optional": true,93
+ "os": [94
+ "android"95
+ ],96
+ "engines": {97
+ "node": ">=12"98
+ }99
+ },100
+ "node_modules/@esbuild/darwin-arm64": {101
+ "version": "0.21.5",102
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",103
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",104
+ "cpu": [105
+ "arm64"106
+ ],107
+ "dev": true,108
+ "license": "MIT",109
+ "optional": true,110
+ "os": [111
+ "darwin"112
+ ],113
+ "engines": {114
+ "node": ">=12"115
+ }116
+ },117
+ "node_modules/@esbuild/darwin-x64": {118
+ "version": "0.21.5",119
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",120
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",121
+ "cpu": [122
+ "x64"123
+ ],124
+ "dev": true,125
+ "license": "MIT",126
+ "optional": true,127
+ "os": [128
+ "darwin"129
+ ],130
+ "engines": {131
+ "node": ">=12"132
+ }133
+ },134
+ "node_modules/@esbuild/freebsd-arm64": {135
+ "version": "0.21.5",136
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",137
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",138
+ "cpu": [139
+ "arm64"140
+ ],141
+ "dev": true,142
+ "license": "MIT",143
+ "optional": true,144
+ "os": [145
+ "freebsd"146
+ ],147
+ "engines": {148
+ "node": ">=12"149
+ }150
+ },151
+ "node_modules/@esbuild/freebsd-x64": {152
+ "version": "0.21.5",153
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",154
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",155
+ "cpu": [156
+ "x64"157
+ ],158
+ "dev": true,159
+ "license": "MIT",160
+ "optional": true,161
+ "os": [162
+ "freebsd"163
+ ],164
+ "engines": {165
+ "node": ">=12"166
+ }167
+ },168
+ "node_modules/@esbuild/linux-arm": {169
+ "version": "0.21.5",170
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",171
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",172
+ "cpu": [173
+ "arm"174
+ ],175
+ "dev": true,176
+ "license": "MIT",177
+ "optional": true,178
+ "os": [179
+ "linux"180
+ ],181
+ "engines": {182
+ "node": ">=12"183
+ }184
+ },185
+ "node_modules/@esbuild/linux-arm64": {186
+ "version": "0.21.5",187
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",188
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",189
+ "cpu": [190
+ "arm64"191
+ ],192
+ "dev": true,193
+ "license": "MIT",194
+ "optional": true,195
+ "os": [196
+ "linux"197
+ ],198
+ "engines": {199
+ "node": ">=12"200
+ }201
+ },202
+ "node_modules/@esbuild/linux-ia32": {203
+ "version": "0.21.5",204
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",205
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",206
+ "cpu": [207
+ "ia32"208
+ ],209
+ "dev": true,210
+ "license": "MIT",211
+ "optional": true,212
+ "os": [213
+ "linux"214
+ ],215
+ "engines": {216
+ "node": ">=12"217
+ }218
+ },219
+ "node_modules/@esbuild/linux-loong64": {220
+ "version": "0.21.5",221
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",222
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",223
+ "cpu": [224
+ "loong64"225
+ ],226
+ "dev": true,227
+ "license": "MIT",228
+ "optional": true,229
+ "os": [230
+ "linux"231
+ ],232
+ "engines": {233
+ "node": ">=12"234
+ }235
+ },236
+ "node_modules/@esbuild/linux-mips64el": {237
+ "version": "0.21.5",238
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",239
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",240
+ "cpu": [241
+ "mips64el"242
+ ],243
+ "dev": true,244
+ "license": "MIT",245
+ "optional": true,246
+ "os": [247
+ "linux"248
+ ],249
+ "engines": {250
+ "node": ">=12"251
+ }252
+ },253
+ "node_modules/@esbuild/linux-ppc64": {254
+ "version": "0.21.5",255
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",256
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",257
+ "cpu": [258
+ "ppc64"259
+ ],260
+ "dev": true,261
+ "license": "MIT",262
+ "optional": true,263
+ "os": [264
+ "linux"265
+ ],266
+ "engines": {267
+ "node": ">=12"268
+ }269
+ },270
+ "node_modules/@esbuild/linux-riscv64": {271
+ "version": "0.21.5",272
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",273
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",274
+ "cpu": [275
+ "riscv64"276
+ ],277
+ "dev": true,278
+ "license": "MIT",279
+ "optional": true,280
+ "os": [281
+ "linux"282
+ ],283
+ "engines": {284
+ "node": ">=12"285
+ }286
+ },287
+ "node_modules/@esbuild/linux-s390x": {288
+ "version": "0.21.5",289
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",290
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",291
+ "cpu": [292
+ "s390x"293
+ ],294
+ "dev": true,295
+ "license": "MIT",296
+ "optional": true,297
+ "os": [298
+ "linux"299
+ ],300
+ "engines": {301
+ "node": ">=12"302
+ }303
+ },304
+ "node_modules/@esbuild/linux-x64": {305
+ "version": "0.21.5",306
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",307
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",308
+ "cpu": [309
+ "x64"310
+ ],311
+ "dev": true,312
+ "license": "MIT",313
+ "optional": true,314
+ "os": [315
+ "linux"316
+ ],317
+ "engines": {318
+ "node": ">=12"319
+ }320
+ },321
+ "node_modules/@esbuild/netbsd-x64": {322
+ "version": "0.21.5",323
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",324
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",325
+ "cpu": [326
+ "x64"327
+ ],328
+ "dev": true,329
+ "license": "MIT",330
+ "optional": true,331
+ "os": [332
+ "netbsd"333
+ ],334
+ "engines": {335
+ "node": ">=12"336
+ }337
+ },338
+ "node_modules/@esbuild/openbsd-x64": {339
+ "version": "0.21.5",340
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",341
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",342
+ "cpu": [343
+ "x64"344
+ ],345
+ "dev": true,346
+ "license": "MIT",347
+ "optional": true,348
+ "os": [349
+ "openbsd"350
+ ],351
+ "engines": {352
+ "node": ">=12"353
+ }354
+ },355
+ "node_modules/@esbuild/sunos-x64": {356
+ "version": "0.21.5",357
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",358
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",359
+ "cpu": [360
+ "x64"361
+ ],362
+ "dev": true,363
+ "license": "MIT",364
+ "optional": true,365
+ "os": [366
+ "sunos"367
+ ],368
+ "engines": {369
+ "node": ">=12"370
+ }371
+ },372
+ "node_modules/@esbuild/win32-arm64": {373
+ "version": "0.21.5",374
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",375
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",376
+ "cpu": [377
+ "arm64"378
+ ],379
+ "dev": true,380
+ "license": "MIT",381
+ "optional": true,382
+ "os": [383
+ "win32"384
+ ],385
+ "engines": {386
+ "node": ">=12"387
+ }388
+ },389
+ "node_modules/@esbuild/win32-ia32": {390
+ "version": "0.21.5",391
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",392
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",393
+ "cpu": [394
+ "ia32"395
+ ],396
+ "dev": true,397
+ "license": "MIT",398
+ "optional": true,399
+ "os": [400
+ "win32"401
+ ],402
+ "engines": {403
+ "node": ">=12"404
+ }405
+ },406
+ "node_modules/@esbuild/win32-x64": {407
+ "version": "0.21.5",408
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",409
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",410
+ "cpu": [411
+ "x64"412
+ ],413
+ "dev": true,414
+ "license": "MIT",415
+ "optional": true,416
+ "os": [417
+ "win32"418
+ ],419
+ "engines": {420
+ "node": ">=12"421
+ }422
+ },423
+ "node_modules/@jridgewell/gen-mapping": {424
+ "version": "0.3.13",425
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",426
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",427
+ "license": "MIT",428
+ "dependencies": {429
+ "@jridgewell/sourcemap-codec": "^1.5.0",430
+ "@jridgewell/trace-mapping": "^0.3.24"431
+ }432
+ },433
+ "node_modules/@jridgewell/resolve-uri": {434
+ "version": "3.1.2",435
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",436
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",437
+ "license": "MIT",438
+ "engines": {439
+ "node": ">=6.0.0"440
+ }441
+ },442
+ "node_modules/@jridgewell/sourcemap-codec": {443
+ "version": "1.6.0",444
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",445
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",446
+ "license": "MIT"447
+ },448
+ "node_modules/@jridgewell/trace-mapping": {449
+ "version": "0.3.31",450
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",451
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",452
+ "license": "MIT",453
+ "dependencies": {454
+ "@jridgewell/resolve-uri": "^3.1.0",455
+ "@jridgewell/sourcemap-codec": "^1.4.14"456
+ }457
+ },458
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {459
+ "version": "1.5.1",460
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",461
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",462
+ "cpu": [463
+ "x64"464
+ ],465
+ "dev": true,466
+ "license": "MIT",467
+ "optional": true,468
+ "os": [469
+ "linux"470
+ ],471
+ "engines": {472
+ "node": "^22.20 || ^24.12 || >=25"473
+ }474
+ },475
+ "node_modules/@rollup/rollup-android-arm-eabi": {476
+ "version": "4.63.4",477
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz",478
+ "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==",479
+ "cpu": [480
+ "arm"481
+ ],482
+ "dev": true,483
+ "license": "MIT",484
+ "optional": true,485
+ "os": [486
+ "android"487
+ ]488
+ },489
+ "node_modules/@rollup/rollup-android-arm64": {490
+ "version": "4.63.4",491
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz",492
+ "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==",493
+ "cpu": [494
+ "arm64"495
+ ],496
+ "dev": true,497
+ "license": "MIT",498
+ "optional": true,499
+ "os": [500
+ "android"501
+ ]502
+ },503
+ "node_modules/@rollup/rollup-darwin-arm64": {504
+ "version": "4.63.4",505
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz",506
+ "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==",507
+ "cpu": [508
+ "arm64"509
+ ],510
+ "dev": true,511
+ "license": "MIT",512
+ "optional": true,513
+ "os": [514
+ "darwin"515
+ ]516
+ },517
+ "node_modules/@rollup/rollup-darwin-x64": {518
+ "version": "4.63.4",519
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz",520
+ "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==",521
+ "cpu": [522
+ "x64"523
+ ],524
+ "dev": true,525
+ "license": "MIT",526
+ "optional": true,527
+ "os": [528
+ "darwin"529
+ ]530
+ },531
+ "node_modules/@rollup/rollup-freebsd-arm64": {532
+ "version": "4.63.4",533
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz",534
+ "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==",535
+ "cpu": [536
+ "arm64"537
+ ],538
+ "dev": true,539
+ "license": "MIT",540
+ "optional": true,541
+ "os": [542
+ "freebsd"543
+ ]544
+ },545
+ "node_modules/@rollup/rollup-freebsd-x64": {546
+ "version": "4.63.4",547
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz",548
+ "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==",549
+ "cpu": [550
+ "x64"551
+ ],552
+ "dev": true,553
+ "license": "MIT",554
+ "optional": true,555
+ "os": [556
+ "freebsd"557
+ ]558
+ },559
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {560
+ "version": "4.63.4",561
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz",562
+ "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==",563
+ "cpu": [564
+ "arm"565
+ ],566
+ "dev": true,567
+ "license": "MIT",568
+ "optional": true,569
+ "os": [570
+ "linux"571
+ ]572
+ },573
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {574
+ "version": "4.63.4",575
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz",576
+ "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==",577
+ "cpu": [578
+ "arm"579
+ ],580
+ "dev": true,581
+ "license": "MIT",582
+ "optional": true,583
+ "os": [584
+ "linux"585
+ ]586
+ },587
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {588
+ "version": "4.63.4",589
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz",590
+ "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==",591
+ "cpu": [592
+ "arm64"593
+ ],594
+ "dev": true,595
+ "license": "MIT",596
+ "optional": true,597
+ "os": [598
+ "linux"599
+ ]600
+ },601
+ "node_modules/@rollup/rollup-linux-arm64-musl": {602
+ "version": "4.63.4",603
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz",604
+ "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==",605
+ "cpu": [606
+ "arm64"607
+ ],608
+ "dev": true,609
+ "license": "MIT",610
+ "optional": true,611
+ "os": [612
+ "linux"613
+ ]614
+ },615
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {616
+ "version": "4.63.4",617
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz",618
+ "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==",619
+ "cpu": [620
+ "loong64"621
+ ],622
+ "dev": true,623
+ "license": "MIT",624
+ "optional": true,625
+ "os": [626
+ "linux"627
+ ]628
+ },629
+ "node_modules/@rollup/rollup-linux-loong64-musl": {630
+ "version": "4.63.4",631
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz",632
+ "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==",633
+ "cpu": [634
+ "loong64"635
+ ],636
+ "dev": true,637
+ "license": "MIT",638
+ "optional": true,639
+ "os": [640
+ "linux"641
+ ]642
+ },643
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {644
+ "version": "4.63.4",645
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz",646
+ "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==",647
+ "cpu": [648
+ "ppc64"649
+ ],650
+ "dev": true,651
+ "license": "MIT",652
+ "optional": true,653
+ "os": [654
+ "linux"655
+ ]656
+ },657
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {658
+ "version": "4.63.4",659
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz",660
+ "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==",661
+ "cpu": [662
+ "ppc64"663
+ ],664
+ "dev": true,665
+ "license": "MIT",666
+ "optional": true,667
+ "os": [668
+ "linux"669
+ ]670
+ },671
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {672
+ "version": "4.63.4",673
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz",674
+ "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==",675
+ "cpu": [676
+ "riscv64"677
+ ],678
+ "dev": true,679
+ "license": "MIT",680
+ "optional": true,681
+ "os": [682
+ "linux"683
+ ]684
+ },685
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {686
+ "version": "4.63.4",687
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz",688
+ "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==",689
+ "cpu": [690
+ "riscv64"691
+ ],692
+ "dev": true,693
+ "license": "MIT",694
+ "optional": true,695
+ "os": [696
+ "linux"697
+ ]698
+ },699
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {700
+ "version": "4.63.4",701
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz",702
+ "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==",703
+ "cpu": [704
+ "s390x"705
+ ],706
+ "dev": true,707
+ "license": "MIT",708
+ "optional": true,709
+ "os": [710
+ "linux"711
+ ]712
+ },713
+ "node_modules/@rollup/rollup-linux-x64-gnu": {714
+ "version": "4.63.4",715
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz",716
+ "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==",717
+ "cpu": [718
+ "x64"719
+ ],720
+ "dev": true,721
+ "license": "MIT",722
+ "optional": true,723
+ "os": [724
+ "linux"725
+ ]726
+ },727
+ "node_modules/@rollup/rollup-linux-x64-musl": {728
+ "version": "4.63.4",729
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz",730
+ "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==",731
+ "cpu": [732
+ "x64"733
+ ],734
+ "dev": true,735
+ "license": "MIT",736
+ "optional": true,737
+ "os": [738
+ "linux"739
+ ]740
+ },741
+ "node_modules/@rollup/rollup-openbsd-x64": {742
+ "version": "4.63.4",743
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz",744
+ "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==",745
+ "cpu": [746
+ "x64"747
+ ],748
+ "dev": true,749
+ "license": "MIT",750
+ "optional": true,751
+ "os": [752
+ "openbsd"753
+ ]754
+ },755
+ "node_modules/@rollup/rollup-openharmony-arm64": {756
+ "version": "4.63.4",757
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz",758
+ "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==",759
+ "cpu": [760
+ "arm64"761
+ ],762
+ "dev": true,763
+ "license": "MIT",764
+ "optional": true,765
+ "os": [766
+ "openharmony"767
+ ]768
+ },769
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {770
+ "version": "4.63.4",771
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz",772
+ "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==",773
+ "cpu": [774
+ "arm64"775
+ ],776
+ "dev": true,777
+ "license": "MIT",778
+ "optional": true,779
+ "os": [780
+ "win32"781
+ ]782
+ },783
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {784
+ "version": "4.63.4",785
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz",786
+ "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==",787
+ "cpu": [788
+ "ia32"789
+ ],790
+ "dev": true,791
+ "license": "MIT",792
+ "optional": true,793
+ "os": [794
+ "win32"795
+ ]796
+ },797
+ "node_modules/@rollup/rollup-win32-x64-gnu": {798
+ "version": "4.63.4",799
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz",800
+ "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==",801
+ "cpu": [802
+ "x64"803
+ ],804
+ "dev": true,805
+ "license": "MIT",806
+ "optional": true,807
+ "os": [808
+ "win32"809
+ ]810
+ },811
+ "node_modules/@rollup/rollup-win32-x64-msvc": {812
+ "version": "4.63.4",813
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz",814
+ "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==",815
+ "cpu": [816
+ "x64"817
+ ],818
+ "dev": true,819
+ "license": "MIT",820
+ "optional": true,821
+ "os": [822
+ "win32"823
+ ]824
+ },825
+ "node_modules/@sveltejs/vite-plugin-svelte": {826
+ "version": "3.1.2",827
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",828
+ "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",829
+ "dev": true,830
+ "license": "MIT",831
+ "dependencies": {832
+ "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",833
+ "debug": "^4.3.4",834
+ "deepmerge": "^4.3.1",835
+ "kleur": "^4.1.5",836
+ "magic-string": "^0.30.10",837
+ "svelte-hmr": "^0.16.0",838
+ "vitefu": "^0.2.5"839
+ },840
+ "engines": {841
+ "node": "^18.0.0 || >=20"842
+ },843
+ "peerDependencies": {844
+ "svelte": "^4.0.0 || ^5.0.0-next.0",845
+ "vite": "^5.0.0"846
+ }847
+ },848
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {849
+ "version": "2.1.0",850
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",851
+ "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",852
+ "dev": true,853
+ "license": "MIT",854
+ "dependencies": {855
+ "debug": "^4.3.4"856
+ },857
+ "engines": {858
+ "node": "^18.0.0 || >=20"859
+ },860
+ "peerDependencies": {861
+ "@sveltejs/vite-plugin-svelte": "^3.0.0",862
+ "svelte": "^4.0.0 || ^5.0.0-next.0",863
+ "vite": "^5.0.0"864
+ }865
+ },866
+ "node_modules/@types/estree": {867
+ "version": "1.0.9",868
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",869
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",870
+ "license": "MIT"871
+ },872
+ "node_modules/acorn": {873
+ "version": "8.18.0",874
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",875
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",876
+ "license": "MIT",877
+ "bin": {878
+ "acorn": "bin/acorn"879
+ },880
+ "engines": {881
+ "node": ">=0.4.0"882
+ }883
+ },884
+ "node_modules/aria-query": {885
+ "version": "5.3.2",886
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",887
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",888
+ "license": "Apache-2.0",889
+ "engines": {890
+ "node": ">= 0.4"891
+ }892
+ },893
+ "node_modules/axobject-query": {894
+ "version": "4.1.0",895
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",896
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",897
+ "license": "Apache-2.0",898
+ "engines": {899
+ "node": ">= 0.4"900
+ }901
+ },902
+ "node_modules/code-red": {903
+ "version": "1.0.4",904
+ "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",905
+ "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",906
+ "license": "MIT",907
+ "dependencies": {908
+ "@jridgewell/sourcemap-codec": "^1.4.15",909
+ "@types/estree": "^1.0.1",910
+ "acorn": "^8.10.0",911
+ "estree-walker": "^3.0.3",912
+ "periscopic": "^3.1.0"913
+ }914
+ },915
+ "node_modules/css-tree": {916
+ "version": "2.3.1",917
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",918
+ "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",919
+ "license": "MIT",920
+ "dependencies": {921
+ "mdn-data": "2.0.30",922
+ "source-map-js": "^1.0.1"923
+ },924
+ "engines": {925
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"926
+ }927
+ },928
+ "node_modules/debug": {929
+ "version": "4.4.3",930
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",931
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",932
+ "dev": true,933
+ "license": "MIT",934
+ "dependencies": {935
+ "ms": "^2.1.3"936
+ },937
+ "engines": {938
+ "node": ">=6.0"939
+ },940
+ "peerDependenciesMeta": {941
+ "supports-color": {942
+ "optional": true943
+ }944
+ }945
+ },946
+ "node_modules/deepmerge": {947
+ "version": "4.3.1",948
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",949
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",950
+ "dev": true,951
+ "license": "MIT",952
+ "engines": {953
+ "node": ">=0.10.0"954
+ }955
+ },956
+ "node_modules/esbuild": {957
+ "version": "0.21.5",958
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",959
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",960
+ "dev": true,961
+ "hasInstallScript": true,962
+ "license": "MIT",963
+ "bin": {964
+ "esbuild": "bin/esbuild"965
+ },966
+ "engines": {967
+ "node": ">=12"968
+ },969
+ "optionalDependencies": {970
+ "@esbuild/aix-ppc64": "0.21.5",971
+ "@esbuild/android-arm": "0.21.5",972
+ "@esbuild/android-arm64": "0.21.5",973
+ "@esbuild/android-x64": "0.21.5",974
+ "@esbuild/darwin-arm64": "0.21.5",975
+ "@esbuild/darwin-x64": "0.21.5",976
+ "@esbuild/freebsd-arm64": "0.21.5",977
+ "@esbuild/freebsd-x64": "0.21.5",978
+ "@esbuild/linux-arm": "0.21.5",979
+ "@esbuild/linux-arm64": "0.21.5",980
+ "@esbuild/linux-ia32": "0.21.5",981
+ "@esbuild/linux-loong64": "0.21.5",982
+ "@esbuild/linux-mips64el": "0.21.5",983
+ "@esbuild/linux-ppc64": "0.21.5",984
+ "@esbuild/linux-riscv64": "0.21.5",985
+ "@esbuild/linux-s390x": "0.21.5",986
+ "@esbuild/linux-x64": "0.21.5",987
+ "@esbuild/netbsd-x64": "0.21.5",988
+ "@esbuild/openbsd-x64": "0.21.5",989
+ "@esbuild/sunos-x64": "0.21.5",990
+ "@esbuild/win32-arm64": "0.21.5",991
+ "@esbuild/win32-ia32": "0.21.5",992
+ "@esbuild/win32-x64": "0.21.5"993
+ }994
+ },995
+ "node_modules/estree-walker": {996
+ "version": "3.0.3",997
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",998
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",999
+ "license": "MIT",1000
+ "dependencies": {1001
+ "@types/estree": "^1.0.0"1002
+ }1003
+ },1004
+ "node_modules/fsevents": {1005
+ "version": "2.3.3",1006
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",1007
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",1008
+ "dev": true,1009
+ "hasInstallScript": true,1010
+ "license": "MIT",1011
+ "optional": true,1012
+ "os": [1013
+ "darwin"1014
+ ],1015
+ "engines": {1016
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"1017
+ }1018
+ },1019
+ "node_modules/is-reference": {1020
+ "version": "3.0.3",1021
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",1022
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",1023
+ "license": "MIT",1024
+ "dependencies": {1025
+ "@types/estree": "^1.0.6"1026
+ }1027
+ },1028
+ "node_modules/kleur": {1029
+ "version": "4.1.5",1030
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",1031
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",1032
+ "dev": true,1033
+ "license": "MIT",1034
+ "engines": {1035
+ "node": ">=6"1036
+ }1037
+ },1038
+ "node_modules/locate-character": {1039
+ "version": "3.0.0",1040
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",1041
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",1042
+ "license": "MIT"1043
+ },1044
+ "node_modules/magic-string": {1045
+ "version": "0.30.21",1046
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",1047
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",1048
+ "license": "MIT",1049
+ "dependencies": {1050
+ "@jridgewell/sourcemap-codec": "^1.5.5"1051
+ }1052
+ },1053
+ "node_modules/mdn-data": {1054
+ "version": "2.0.30",1055
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",1056
+ "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",1057
+ "license": "CC0-1.0"1058
+ },1059
+ "node_modules/ms": {1060
+ "version": "2.1.3",1061
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",1062
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",1063
+ "dev": true,1064
+ "license": "MIT"1065
+ },1066
+ "node_modules/nanoid": {1067
+ "version": "3.3.19",1068
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",1069
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",1070
+ "dev": true,1071
+ "funding": [1072
+ {1073
+ "type": "github",1074
+ "url": "https://github.com/sponsors/ai"1075
+ }1076
+ ],1077
+ "license": "MIT",1078
+ "bin": {1079
+ "nanoid": "bin/nanoid.cjs"1080
+ },1081
+ "engines": {1082
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"1083
+ }1084
+ },1085
+ "node_modules/periscopic": {1086
+ "version": "3.1.0",1087
+ "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",1088
+ "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",1089
+ "license": "MIT",1090
+ "dependencies": {1091
+ "@types/estree": "^1.0.0",1092
+ "estree-walker": "^3.0.0",1093
+ "is-reference": "^3.0.0"1094
+ }1095
+ },1096
+ "node_modules/picocolors": {1097
+ "version": "1.1.1",1098
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",1099
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",1100
+ "dev": true,1101
+ "license": "ISC"1102
+ },1103
+ "node_modules/postcss": {1104
+ "version": "8.5.28",1105
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",1106
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",1107
+ "dev": true,1108
+ "funding": [1109
+ {1110
+ "type": "opencollective",1111
+ "url": "https://opencollective.com/postcss/"1112
+ },1113
+ {1114
+ "type": "tidelift",1115
+ "url": "https://tidelift.com/funding/github/npm/postcss"1116
+ },1117
+ {1118
+ "type": "github",1119
+ "url": "https://github.com/sponsors/ai"1120
+ }1121
+ ],1122
+ "license": "MIT",1123
+ "dependencies": {1124
+ "nanoid": "^3.3.18",1125
+ "picocolors": "^1.1.1",1126
+ "source-map-js": "^1.2.1"1127
+ },1128
+ "engines": {1129
+ "node": "^10 || ^12 || >=14"1130
+ }1131
+ },1132
+ "node_modules/rollup": {1133
+ "version": "4.63.4",1134
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",1135
+ "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==",1136
+ "dev": true,1137
+ "license": "MIT",1138
+ "dependencies": {1139
+ "@types/estree": "1.0.9"1140
+ },1141
+ "bin": {1142
+ "rollup": "dist/bin/rollup"1143
+ },1144
+ "engines": {1145
+ "node": ">=18.0.0",1146
+ "npm": ">=8.0.0"1147
+ },1148
+ "optionalDependencies": {1149
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",1150
+ "@rollup/rollup-android-arm-eabi": "4.63.4",1151
+ "@rollup/rollup-android-arm64": "4.63.4",1152
+ "@rollup/rollup-darwin-arm64": "4.63.4",1153
+ "@rollup/rollup-darwin-x64": "4.63.4",1154
+ "@rollup/rollup-freebsd-arm64": "4.63.4",1155
+ "@rollup/rollup-freebsd-x64": "4.63.4",1156
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.4",1157
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.4",1158
+ "@rollup/rollup-linux-arm64-gnu": "4.63.4",1159
+ "@rollup/rollup-linux-arm64-musl": "4.63.4",1160
+ "@rollup/rollup-linux-loong64-gnu": "4.63.4",1161
+ "@rollup/rollup-linux-loong64-musl": "4.63.4",1162
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.4",1163
+ "@rollup/rollup-linux-ppc64-musl": "4.63.4",1164
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.4",1165
+ "@rollup/rollup-linux-riscv64-musl": "4.63.4",1166
+ "@rollup/rollup-linux-s390x-gnu": "4.63.4",1167
+ "@rollup/rollup-linux-x64-gnu": "4.63.4",1168
+ "@rollup/rollup-linux-x64-musl": "4.63.4",1169
+ "@rollup/rollup-openbsd-x64": "4.63.4",1170
+ "@rollup/rollup-openharmony-arm64": "4.63.4",1171
+ "@rollup/rollup-win32-arm64-msvc": "4.63.4",1172
+ "@rollup/rollup-win32-ia32-msvc": "4.63.4",1173
+ "@rollup/rollup-win32-x64-gnu": "4.63.4",1174
+ "@rollup/rollup-win32-x64-msvc": "4.63.4",1175
+ "fsevents": "~2.3.2"1176
+ }1177
+ },1178
+ "node_modules/source-map-js": {1179
+ "version": "1.2.1",1180
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",1181
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",1182
+ "license": "BSD-3-Clause",1183
+ "engines": {1184
+ "node": ">=0.10.0"1185
+ }1186
+ },1187
+ "node_modules/svelte": {1188
+ "version": "4.2.20",1189
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",1190
+ "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",1191
+ "license": "MIT",1192
+ "dependencies": {1193
+ "@ampproject/remapping": "^2.2.1",1194
+ "@jridgewell/sourcemap-codec": "^1.4.15",1195
+ "@jridgewell/trace-mapping": "^0.3.18",1196
+ "@types/estree": "^1.0.1",1197
+ "acorn": "^8.9.0",1198
+ "aria-query": "^5.3.0",1199
+ "axobject-query": "^4.0.0",1200
+ "code-red": "^1.0.3",1201
+ "css-tree": "^2.3.1",1202
+ "estree-walker": "^3.0.3",1203
+ "is-reference": "^3.0.1",1204
+ "locate-character": "^3.0.0",1205
+ "magic-string": "^0.30.4",1206
+ "periscopic": "^3.1.0"1207
+ },1208
+ "engines": {1209
+ "node": ">=16"1210
+ }1211
+ },1212
+ "node_modules/svelte-hmr": {1213
+ "version": "0.16.0",1214
+ "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",1215
+ "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",1216
+ "dev": true,1217
+ "license": "ISC",1218
+ "engines": {1219
+ "node": "^12.20 || ^14.13.1 || >= 16"1220
+ },1221
+ "peerDependencies": {1222
+ "svelte": "^3.19.0 || ^4.0.0"1223
+ }1224
+ },1225
+ "node_modules/typescript": {1226
+ "version": "5.6.3",1227
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",1228
+ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",1229
+ "dev": true,1230
+ "license": "Apache-2.0",1231
+ "bin": {1232
+ "tsc": "bin/tsc",1233
+ "tsserver": "bin/tsserver"1234
+ },1235
+ "engines": {1236
+ "node": ">=14.17"1237
+ }1238
+ },1239
+ "node_modules/vite": {1240
+ "version": "5.4.21",1241
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",1242
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",1243
+ "dev": true,1244
+ "license": "MIT",1245
+ "dependencies": {1246
+ "esbuild": "^0.21.3",1247
+ "postcss": "^8.4.43",1248
+ "rollup": "^4.20.0"1249
+ },1250
+ "bin": {1251
+ "vite": "bin/vite.js"1252
+ },1253
+ "engines": {1254
+ "node": "^18.0.0 || >=20.0.0"1255
+ },1256
+ "funding": {1257
+ "url": "https://github.com/vitejs/vite?sponsor=1"1258
+ },1259
+ "optionalDependencies": {1260
+ "fsevents": "~2.3.3"1261
+ },1262
+ "peerDependencies": {1263
+ "@types/node": "^18.0.0 || >=20.0.0",1264
+ "less": "*",1265
+ "lightningcss": "^1.21.0",1266
+ "sass": "*",1267
+ "sass-embedded": "*",1268
+ "stylus": "*",1269
+ "sugarss": "*",1270
+ "terser": "^5.4.0"1271
+ },1272
+ "peerDependenciesMeta": {1273
+ "@types/node": {1274
+ "optional": true1275
+ },1276
+ "less": {1277
+ "optional": true1278
+ },1279
+ "lightningcss": {1280
+ "optional": true1281
+ },1282
+ "sass": {1283
+ "optional": true1284
+ },1285
+ "sass-embedded": {1286
+ "optional": true1287
+ },1288
+ "stylus": {1289
+ "optional": true1290
+ },1291
+ "sugarss": {1292
+ "optional": true1293
+ },1294
+ "terser": {1295
+ "optional": true1296
+ }1297
+ }1298
+ },1299
+ "node_modules/vitefu": {1300
+ "version": "0.2.5",1301
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",1302
+ "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",1303
+ "dev": true,1304
+ "license": "MIT",1305
+ "peerDependencies": {1306
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0"1307
+ },1308
+ "peerDependenciesMeta": {1309
+ "vite": {1310
+ "optional": true1311
+ }1312
+ }1313
+ }1314
+ }1315
+}@@ -0,0 +1,21 @@
1
+{2
+ "name": "malice-web",3
+ "private": true,4
+ "version": "0.1.0",5
+ "type": "module",6
+ "scripts": {7
+ "dev": "vite --host 127.0.0.1 --port 5173",8
+ "build": "vite build",9
+ "build:device": "vite build --mode device",10
+ "preview": "vite preview --host 127.0.0.1 --port 4173",11
+ "test": "rm -rf .test-dist && tsc -p tsconfig.test.json && node --test .test-dist/test/media.test.js"12
+ },13
+ "dependencies": {14
+ "svelte": "^4.2.19"15
+ },16
+ "devDependencies": {17
+ "@sveltejs/vite-plugin-svelte": "^3.1.2",18
+ "typescript": "~5.6.3",19
+ "vite": "^5.4.11"20
+ }21
+}@@ -0,0 +1,1262 @@
1
+<script lang="ts">2
+ import { onMount, tick } from 'svelte';3
+ import LoginPanel from './lib/LoginPanel.svelte';4
+ import MessageRow from './lib/MessageRow.svelte';5
+ import MxcAvatar from './lib/MxcAvatar.svelte';6
+ import SecurityPanel from './lib/SecurityPanel.svelte';7
+ import {8
+ closeRoom,9
+ createRoom,10
+ getEncryption,11
+ getMembers,12
+ getProfile,13
+ getRooms,14
+ getSession,15
+ getSpaceChildren,16
+ joinRoom,17
+ leaveRoom,18
+ logout,19
+ markRead,20
+ openRoom,21
+ paginateRoom,22
+ reactToMessage,23
+ uploadAttachment24
+ } from './lib/api';25
+ import {26
+ banners,27
+ blocked,28
+ connectEvents,29
+ connection,30
+ disconnectEvents,31
+ dismissBanner,32
+ encryption,33
+ membersByRoom,34
+ membersLoading,35
+ pushBanner,36
+ resetClientState,37
+ rooms,38
+ selectedRoomId,39
+ sessionUser,40
+ softLogout,41
+ timelines,42
+ verification43
+ } from './lib/store';44
+ import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';45
+ import type { PendingMessage } from './lib/pending';46
+ import { notifyTyping, stopTyping } from './lib/typing';47
+ import { formatDay } from './lib/format';48
+ import { parseEncryptionState, parseOwnProfile, parseVerificationState } from './lib/types';49
+ import type { MemberSummary, OwnProfile, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';50
+51
+ let roomsLoading = false;52
+ let booted = false;53
+ let search = '';54
+ let sidebarOpen = false;55
+ let membersOpen = false;56
+ let dialog: 'join' | 'create' | 'security' | null = null;57
+ let joinTarget = '';58
+ let joinBusy = false;59
+ let createName = '';60
+ let createTopic = '';61
+ let createBusy = false;62
+ let leaveBusy = false;63
+ let paginateBusy = false;64
+ let reacting = false;65
+ let composer = '';66
+ let drafts: Record<string, string> = {};67
+ let stickToBottom = true;68
+ let timelineEl: HTMLElement | null = null;69
+ let composerEl: HTMLTextAreaElement | null = null;70
+ let fileInput: HTMLInputElement | null = null;71
+ let uploadBusy = false;72
+ let uploadName: string | null = null;73
+ let openedRoom: string | null = null;74
+ let openReady: Record<string, boolean> = {};75
+ let openFailed: Record<string, string> = {};76
+ let membersFor: string | null = null;77
+ let lastReadKey = '';78
+ let readTimer: number | null = null;79
+ let profile: OwnProfile | null = null;80
+ let profileMxc: string | null = null;81
+ let selectedSpaceId: string | null = null;82
+ let spaceChildIds: string[] | null = null;83
+ let spaceChildrenLoading = false;84
+ let spaceChildrenError: string | null = null;85
+ let avatarByUser: Record<string, string | null> = {};86
+87
+ $: selectedRoom = $rooms.find((r) => r.room_id === $selectedRoomId) ?? null;88
+ $: spaceRooms = $rooms.filter((r) => r.space);89
+ $: invites = $rooms.filter((r) => r.membership === 'Invited');90
+ $: query = search.trim().toLowerCase();91
+ $: selectedSpace = selectedSpaceId ? ($rooms.find((r) => r.room_id === selectedSpaceId) ?? null) : null;92
+ // While a space's children load (or fail), show no channels rather than the93
+ // full list — listing everything would falsely imply it belongs to the space.94
+ $: baseRooms = selectedSpaceId ? (spaceChildIds ? $rooms.filter((r) => spaceChildIds.includes(r.room_id)) : []) : $rooms;95
+ $: visibleRooms = query96
+ ? baseRooms.filter(97
+ (r) =>98
+ r.name.toLowerCase().includes(query) || r.room_id.toLowerCase().includes(query)99
+ )100
+ : baseRooms;101
+ $: directRooms = visibleRooms.filter((r) => r.direct && !r.space && r.membership !== 'Invited');102
+ $: groupRooms = visibleRooms.filter(103
+ (r) => !r.direct && !r.space && r.membership !== 'Invited'104
+ );105
+ $: spaceList = visibleRooms.filter((r) => r.space && r.membership !== 'Invited');106
+ $: channelRooms = baseRooms.filter((r) => !r.space && r.membership !== 'Invited');107
+ $: recoveryBanner = $encryption && ($encryption.recovery === 'Disabled' || $encryption.recovery === 'Incomplete') ? $encryption : null;108
+ $: verificationBanner = $verification && $verification.kind !== 'Done' ? $verification : null;109
+ $: items = $selectedRoomId ? ($timelines[$selectedRoomId] ?? []) : [];110
+ $: members = $selectedRoomId ? ($membersByRoom[$selectedRoomId] ?? []) : [];111
+ $: hasStart = items.some((i) => i.kind === 'start');112
+ $: grouped = buildGroups(items);113
+ $: pendingItems = $selectedRoomId ? ($pendingByRoom[$selectedRoomId] ?? []) : [];114
+ $: lastRealOwn = lastSenderIsOwn(items);115
+116
+ function lastSenderIsOwn(list: TimelineItem[]): boolean {117
+ for (let i = list.length - 1; i >= 0; i -= 1) {118
+ const it = list[i];119
+ if (it.kind === 'event') return it.event.sender.user_id === $sessionUser;120
+ }121
+ return false;122
+ }123
+124
+ function pendingShowSender(index: number): boolean {125
+ return index === 0 ? !lastRealOwn : false;126
+ }127
+128
+ function pendingToEvent(p: PendingMessage): TimelineEvent {129
+ const state: SendState = p.status === 'failed' ? { kind: 'Failed', will_retry: false } : { kind: 'Sending' };130
+ return {131
+ item_id: `pending-${p.id}`,132
+ event_id: null,133
+ transaction_id: p.id,134
+ sender: {135
+ user_id: $sessionUser ?? '',136
+ display_name: profile?.display_name ?? null,137
+ avatar: null,138
+ name_ambiguous: false139
+ },140
+ timestamp_ms: p.createdAt,141
+ own: true,142
+ send_state: state,143
+ content: {144
+ kind: 'Message',145
+ message: { kind: 'Text', body: p.body, geo_uri: null, formatted_body: null, edited: false, media: null }146
+ },147
+ reply_to: null,148
+ thread_root: null,149
+ thread_summary: null,150
+ reactions: [],151
+ read_by: [],152
+ editable: false153
+ };154
+ }155
+156
+ interface Group {157
+ key: string;158
+ item: TimelineItem;159
+ showSender: boolean;160
+ }161
+162
+ function buildGroups(list: TimelineItem[]): Group[] {163
+ const out: Group[] = [];164
+ let prevSender: string | null = null;165
+ let prevTs = 0;166
+ list.forEach((item, index) => {167
+ let showSender = true;168
+ if (item.kind === 'event') {169
+ const e = item.event;170
+ showSender =171
+ prevSender !== e.sender.user_id || Math.abs(e.timestamp_ms - prevTs) > 5 * 60 * 1000;172
+ prevSender = e.sender.user_id;173
+ prevTs = e.timestamp_ms;174
+ out.push({ key: e.item_id || e.event_id || `m-${index}`, item, showSender });175
+ } else {176
+ prevSender = null;177
+ out.push({ key: `${item.kind}-${index}`, item, showSender });178
+ }179
+ });180
+ return out;181
+ }182
+183
+ function roomUnread(r: RoomSummary): number {184
+ return r.unread_messages + r.unread_mentions;185
+ }186
+187
+ onMount(() => {188
+ void boot();189
+ return () => {190
+ disconnectEvents();191
+ stopTyping();192
+ if (readTimer !== null) window.clearTimeout(readTimer);193
+ };194
+ });195
+196
+ async function boot(): Promise<void> {197
+ try {198
+ const session = await getSession();199
+ blocked.set(session.blocked ?? false);200
+ sessionUser.set(session.user_id);201
+ if (session.user_id) {202
+ softLogout.set(null);203
+ await refreshRooms();204
+ connectEvents();205
+ void loadProfile();206
+ void loadEncryption();207
+ } else {208
+ softLogout.set(session.soft_logout ?? null);209
+ }210
+ } catch (e) {211
+ pushBanner(e instanceof Error ? `Session check failed: ${e.message}` : 'Session check failed');212
+ sessionUser.set(null);213
+ } finally {214
+ booted = true;215
+ }216
+ }217
+218
+ async function refreshRooms(): Promise<void> {219
+ roomsLoading = true;220
+ try {221
+ const res = await getRooms();222
+ rooms.set(res.rooms);223
+ pruneSpaceFilter();224
+ } catch (e) {225
+ pushBanner(e instanceof Error ? e.message : 'Could not load rooms');226
+ } finally {227
+ roomsLoading = false;228
+ }229
+ }230
+231
+ async function loadProfile(): Promise<void> {232
+ try {233
+ const raw = await getProfile();234
+ profile = parseOwnProfile(raw);235
+ profileMxc = profile.avatar_url;236
+ } catch {237
+ profile = null;238
+ }239
+ }240
+241
+ async function loadEncryption(): Promise<void> {242
+ try {243
+ const res = await getEncryption();244
+ const state = parseEncryptionState(res.state);245
+ if (state) encryption.set(state);246
+ const verificationState = parseVerificationState(res.verification);247
+ if (verificationState) verification.set(verificationState);248
+ } catch {249
+ // Encryption status also arrives over SSE; a failed first fetch is not fatal.250
+ }251
+ }252
+253
+ function afterLogin(userId: string): void {254
+ sessionUser.set(userId);255
+ softLogout.set(null);256
+ blocked.set(false);257
+ void refreshRooms();258
+ connectEvents();259
+ void loadProfile();260
+ void loadEncryption();261
+ }262
+263
+ async function doLogout(): Promise<void> {264
+ stopTyping();265
+ try {266
+ await logout();267
+ } catch (e) {268
+ pushBanner(e instanceof Error ? e.message : 'Logout failed');269
+ return;270
+ }271
+ disconnectEvents();272
+ resetClientState();273
+ softLogout.set(null);274
+ blocked.set(false);275
+ sessionUser.set(null);276
+ composer = '';277
+ drafts = {};278
+ dialog = null;279
+ membersOpen = false;280
+ sidebarOpen = false;281
+ profile = null;282
+ profileMxc = null;283
+ selectedSpaceId = null;284
+ spaceChildIds = null;285
+ spaceChildrenError = null;286
+ openReady = {};287
+ openFailed = {};288
+ avatarByUser = {};289
+ resetPending();290
+ }291
+292
+ function pruneSpaceFilter(): void {293
+ if (!selectedSpaceId) return;294
+ if (!$rooms.some((r) => r.room_id === selectedSpaceId)) {295
+ selectedSpaceId = null;296
+ spaceChildIds = null;297
+ spaceChildrenError = null;298
+ }299
+ }300
+301
+ async function selectHome(): Promise<void> {302
+ selectedSpaceId = null;303
+ spaceChildIds = null;304
+ spaceChildrenError = null;305
+ sidebarOpen = true;306
+ }307
+308
+ async function selectSpace(spaceId: string): Promise<void> {309
+ if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;310
+ stopTyping();311
+ const prev = openedRoom;312
+ if (prev) {313
+ openedRoom = null;314
+ closeRoom(prev).catch(() => undefined);315
+ }316
+ selectedSpaceId = spaceId;317
+ spaceChildIds = null;318
+ spaceChildrenError = null;319
+ spaceChildrenLoading = true;320
+ sidebarOpen = true;321
+ // A space is navigation, not a message timeline.322
+ selectedRoomId.set(null);323
+ try {324
+ const res = await getSpaceChildren(spaceId);325
+ if (selectedSpaceId === spaceId) spaceChildIds = res.room_ids ?? [];326
+ } catch (e) {327
+ if (selectedSpaceId === spaceId) {328
+ spaceChildrenError = e instanceof Error ? e.message : 'Could not load rooms in this space';329
+ spaceChildIds = [];330
+ }331
+ } finally {332
+ if (selectedSpaceId === spaceId) spaceChildrenLoading = false;333
+ }334
+ }335
+336
+ async function selectRoom(roomId: string): Promise<void> {337
+ if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;338
+ if (roomId === $selectedRoomId && openReady[roomId]) {339
+ sidebarOpen = false;340
+ return;341
+ }342
+ stopTyping();343
+ const prev = openedRoom;344
+ openedRoom = roomId;345
+ selectedRoomId.set(roomId);346
+ composer = drafts[roomId] ?? '';347
+ stickToBottom = true;348
+ lastReadKey = '';349
+ sidebarOpen = false;350
+ openReady = { ...openReady, [roomId]: false };351
+ if (openFailed[roomId]) {352
+ const nextFailed = { ...openFailed };353
+ delete nextFailed[roomId];354
+ openFailed = nextFailed;355
+ }356
+ try {357
+ await openRoom(roomId);358
+ openReady = { ...openReady, [roomId]: true };359
+ } catch (e) {360
+ const message = e instanceof Error ? e.message : `Could not open room ${roomId}`;361
+ openFailed = { ...openFailed, [roomId]: message };362
+ pushBanner(message);363
+ }364
+ if (membersOpen) void loadMembers(roomId);365
+ await tick();366
+ scrollToBottom();367
+ queueReadReceipt();368
+ composerEl?.focus();369
+ }370
+371
+ function scrollToBottom(): void {372
+ if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;373
+ }374
+375
+ function onTimelineScroll(): void {376
+ if (!timelineEl) return;377
+ const gap = timelineEl.scrollHeight - timelineEl.scrollTop - timelineEl.clientHeight;378
+ stickToBottom = gap < 80;379
+ }380
+381
+ $: void trackTimeline($selectedRoomId, items);382
+ async function trackTimeline(roomId: string | null, list: TimelineItem[]): Promise<void> {383
+ if (!roomId || list.length === 0) return;384
+ if (roomId !== $selectedRoomId) return;385
+ await tick();386
+ if (stickToBottom) scrollToBottom();387
+ queueReadReceipt();388
+ }389
+390
+ function queueReadReceipt(): void {391
+ if (readTimer !== null) window.clearTimeout(readTimer);392
+ readTimer = window.setTimeout(() => {393
+ readTimer = null;394
+ void sendReadReceipt();395
+ }, 800);396
+ }397
+398
+ async function sendReadReceipt(): Promise<void> {399
+ const roomId = $selectedRoomId;400
+ if (!roomId) return;401
+ const list = $timelines[roomId] ?? [];402
+ for (let i = list.length - 1; i >= 0; i -= 1) {403
+ const entry = list[i];404
+ if (entry.kind === 'event' && entry.event.event_id) {405
+ const key = `${roomId}:${entry.event.event_id}`;406
+ if (key === lastReadKey) return;407
+ lastReadKey = key;408
+ try {409
+ await markRead(roomId, entry.event.event_id);410
+ } catch {411
+ return;412
+ }413
+ return;414
+ }415
+ }416
+ }417
+418
+ function send(): void {419
+ const roomId = $selectedRoomId;420
+ const body = composer.trim();421
+ if (!roomId || !body) return;422
+ if (!openReady[roomId]) {423
+ pushBanner('Still opening this room — sending is disabled until the room is ready.');424
+ return;425
+ }426
+ composer = '';427
+ if (roomId in drafts) delete drafts[roomId];428
+ stickToBottom = true;429
+ queueSend(roomId, body);430
+ composerEl?.focus();431
+ }432
+433
+ let typedText = '';434
+ let typedRoom: string | null = null;435
+ $: trackTypingInput(composer, $selectedRoomId, openReady);436
+ function trackTypingInput(text: string, roomId: string | null, ready: Record<string, boolean>): void {437
+ const edited = roomId === typedRoom && text !== typedText;438
+ typedText = text;439
+ typedRoom = roomId;440
+ if (!roomId || !ready[roomId] || !text.trim()) {441
+ stopTyping();442
+ } else if (edited) {443
+ notifyTyping(roomId);444
+ }445
+ }446
+447
+ $: void trackPending($selectedRoomId, pendingItems);448
+ async function trackPending(roomId: string | null, list: PendingMessage[]): Promise<void> {449
+ if (!roomId || list.length === 0) return;450
+ if (roomId !== $selectedRoomId) return;451
+ await tick();452
+ if (stickToBottom) scrollToBottom();453
+ }454
+455
+ function onComposerKey(ev: KeyboardEvent): void {456
+ if (ev.key === 'Enter' && !ev.shiftKey) {457
+ ev.preventDefault();458
+ send();459
+ }460
+ }461
+462
+ async function upload(): Promise<void> {463
+ const roomId = $selectedRoomId;464
+ if (!roomId || uploadBusy || !openReady[roomId]) return;465
+ fileInput?.click();466
+ }467
+468
+ async function onFileChosen(): Promise<void> {469
+ const roomId = $selectedRoomId;470
+ const files = fileInput?.files;471
+ if (!roomId || !files || files.length === 0) return;472
+ const file = files[0];473
+ if (file.size === 0 || file.size > 20 * 1024 * 1024) {474
+ pushBanner('File must be between 1 byte and 20 MiB.');475
+ if (fileInput) fileInput.value = '';476
+ return;477
+ }478
+ uploadBusy = true;479
+ uploadName = file.name || 'attachment';480
+ try {481
+ await uploadAttachment(roomId, file);482
+ stickToBottom = true;483
+ } catch (e) {484
+ pushBanner(e instanceof Error ? e.message : 'Upload failed');485
+ } finally {486
+ uploadBusy = false;487
+ uploadName = null;488
+ if (fileInput) fileInput.value = '';489
+ }490
+ }491
+ async function react(eventId: string, key: string): Promise<void> {492
+ const roomId = $selectedRoomId;493
+ if (!roomId || !eventId || reacting) return;494
+ reacting = true;495
+ try {496
+ await reactToMessage(roomId, eventId, key);497
+ } catch (e) {498
+ pushBanner(e instanceof Error ? e.message : 'Reaction failed');499
+ } finally {500
+ reacting = false;501
+ }502
+ }503
+504
+ async function paginate(): Promise<void> {505
+ const roomId = $selectedRoomId;506
+ if (!roomId || paginateBusy) return;507
+ paginateBusy = true;508
+ try {509
+ await paginateRoom(roomId);510
+ } catch (e) {511
+ pushBanner(e instanceof Error ? e.message : 'Loading older messages failed');512
+ } finally {513
+ paginateBusy = false;514
+ }515
+ }516
+517
+ async function loadMembers(roomId: string): Promise<void> {518
+ if (membersFor === roomId && $membersLoading) return;519
+ membersFor = roomId;520
+ membersLoading.set(true);521
+ try {522
+ const res = await getMembers(roomId);523
+ membersByRoom.update((all) => ({ ...all, [roomId]: res.members }));524
+ avatarByUser = { ...avatarByUser, ...avatarMap(res.members) };525
+ } catch (e) {526
+ pushBanner(e instanceof Error ? e.message : 'Could not load members');527
+ } finally {528
+ if (membersFor === roomId) membersLoading.set(false);529
+ }530
+ }531
+532
+ function avatarMap(list: MemberSummary[]): Record<string, string | null> {533
+ const next: Record<string, string | null> = {};534
+ for (const member of list) next[member.user_id] = member.avatar;535
+ return next;536
+ }537
+538
+ function senderAvatarFor(userId: string): string | null {539
+ return avatarByUser[userId] ?? null;540
+ }541
+542
+ function toggleMembers(): void {543
+ membersOpen = !membersOpen;544
+ if (membersOpen && $selectedRoomId) void loadMembers($selectedRoomId);545
+ }546
+547
+ async function join(target: string): Promise<void> {548
+ const room = target.trim();549
+ if (!room || joinBusy) return;550
+ joinBusy = true;551
+ try {552
+ const res = await joinRoom(room);553
+ joinTarget = '';554
+ dialog = null;555
+ await refreshRooms();556
+ await selectRoom(res.room_id);557
+ } catch (e) {558
+ pushBanner(e instanceof Error ? e.message : 'Join failed');559
+ } finally {560
+ joinBusy = false;561
+ }562
+ }563
+564
+ async function create(): Promise<void> {565
+ const name = createName.trim();566
+ if (!name || createBusy) return;567
+ createBusy = true;568
+ try {569
+ const res = await createRoom(name, createTopic.trim() || undefined);570
+ createName = '';571
+ createTopic = '';572
+ dialog = null;573
+ await refreshRooms();574
+ await selectRoom(res.room_id);575
+ } catch (e) {576
+ pushBanner(e instanceof Error ? e.message : 'Create room failed');577
+ } finally {578
+ createBusy = false;579
+ }580
+ }581
+582
+ async function leave(): Promise<void> {583
+ const roomId = $selectedRoomId;584
+ if (!roomId || leaveBusy) return;585
+ leaveBusy = true;586
+ stopTyping();587
+ try {588
+ await leaveRoom(roomId);589
+ const prev = openedRoom;590
+ if (prev === roomId) openedRoom = null;591
+ if ($selectedRoomId === roomId) selectedRoomId.set(null);592
+ if (prev) closeRoom(prev).catch(() => undefined);593
+ await refreshRooms();594
+ } catch (e) {595
+ pushBanner(e instanceof Error ? e.message : 'Leave failed');596
+ } finally {597
+ leaveBusy = false;598
+ }599
+ }600
+601
+ function connectionClass(state: string): string {602
+ if (state === 'online') return 'dot-online';603
+ if (state === 'connecting' || state === 'reconnecting') return 'dot-busy';604
+ return 'dot-off';605
+ }606
+607
+ function onKeydown(ev: KeyboardEvent): void {608
+ if (ev.key === 'Escape') {609
+ if (dialog) dialog = null;610
+ else if (membersOpen && window.innerWidth < 1100) membersOpen = false;611
+ else if (sidebarOpen) sidebarOpen = false;612
+ }613
+ }614
+</script>615
+616
+<svelte:window on:keydown={onKeydown} />617
+618
+{#if !booted}619
+ <div class="splash" role="status" aria-label="Loading Malice">620
+ <div class="splash-mark" aria-hidden="true">M</div>621
+ <p>Loading Malice…</p>622
+ </div>623
+{:else if $blocked}624
+ <div class="splash" role="status" aria-label="Malice is open in another tab">625
+ <div class="splash-mark" aria-hidden="true">M</div>626
+ <p>Malice is open in another tab. Close this tab or use that one instead.</p>627
+ </div>628
+{:else if $sessionUser == null}629
+ <LoginPanel softLogout={$softLogout} on:done={(ev) => afterLogin(ev.detail)} />630
+{:else}631
+ <div class="app">632
+ <nav class="rail" aria-label="Servers and spaces">633
+ <button634
+ type="button"635
+ class="rail-btn"636
+ class:active={selectedSpaceId === null}637
+ title="Home — all channels"638
+ aria-label="Home — all channels"639
+ aria-pressed={selectedSpaceId === null}640
+ on:click={() => void selectHome()}641
+ >642
+ <span aria-hidden="true">⌂</span>643
+ </button>644
+ <div class="rail-sep" aria-hidden="true"></div>645
+ {#each spaceRooms as space (space.room_id)}646
+ <button647
+ type="button"648
+ class="rail-btn rail-space"649
+ class:active={selectedSpaceId === space.room_id}650
+ title={space.name}651
+ aria-label={`Show channels in space ${space.name}`}652
+ aria-pressed={selectedSpaceId === space.room_id}653
+ on:click={() => void selectSpace(space.room_id)}654
+ >655
+ <MxcAvatar mxc={space.avatar} label={space.name} size={44} shape="rounded" />656
+ </button>657
+ {/each}658
+ <button659
+ type="button"660
+ class="rail-btn rail-add"661
+ aria-label="Join or create a room"662
+ on:click={() => {663
+ dialog = 'join';664
+ }}665
+ >666
+ <span aria-hidden="true">+</span>667
+ </button>668
+ </nav>669
+670
+ <aside class="sidebar" class:open={sidebarOpen} aria-label="Rooms">671
+ <div class="side-search">672
+ <label class="visually-hidden" for="room-search">Search rooms</label>673
+ <input674
+ id="room-search"675
+ type="search"676
+ placeholder="Search rooms"677
+ bind:value={search}678
+ autocomplete="off"679
+ />680
+ <button681
+ type="button"682
+ class="icon-btn"683
+ title="Refresh rooms"684
+ aria-label="Refresh rooms"685
+ disabled={roomsLoading}686
+ on:click={() => void refreshRooms()}687
+ >688
+ <span aria-hidden="true">⟳</span>689
+ </button>690
+ </div>691
+ <div class="side-scroll">692
+ {#if selectedSpace}693
+ <div class="space-context">694
+ <MxcAvatar mxc={selectedSpace.avatar} label={selectedSpace.name} size={28} shape="rounded" />695
+ <div class="space-context-text">696
+ <strong>{selectedSpace.name}</strong>697
+ <span>Space — channels below</span>698
+ </div>699
+ </div>700
+ {:else}701
+ <div class="space-context">702
+ <div class="space-context-text">703
+ <strong>Home</strong>704
+ <span>All channels</span>705
+ </div>706
+ </div>707
+ {/if}708
+ {#if roomsLoading && $rooms.length === 0}709
+ <p class="side-note">Loading rooms…</p>710
+ {:else if $rooms.length === 0}711
+ <p class="side-note">No rooms yet. Join one or create your own.</p>712
+ <div class="side-cta">713
+ <button type="button" on:click={() => (dialog = 'join')}>Join a room</button>714
+ <button type="button" on:click={() => (dialog = 'create')}>Create one</button>715
+ </div>716
+ {:else}717
+ {#if selectedSpaceId && spaceChildrenLoading}718
+ <p class="side-note">Loading channels in this space…</p>719
+ {:else if selectedSpaceId && spaceChildrenError}720
+ <p class="side-note" role="alert">{spaceChildrenError}</p>721
+ <div class="side-cta">722
+ <button type="button" on:click={() => selectedSpaceId && void selectSpace(selectedSpaceId)}>Retry</button>723
+ <button type="button" on:click={() => void selectHome()}>Back to Home</button>724
+ </div>725
+ {:else if selectedSpaceId && spaceChildIds && channelRooms.length === 0}726
+ <p class="side-note">No channels found in this space yet.</p>727
+ <div class="side-cta">728
+ <button type="button" on:click={() => void selectHome()}>Back to Home</button>729
+ </div>730
+ {:else}731
+ {#if invites.length > 0 && !selectedSpaceId}732
+ <h2 class="side-h">Invites</h2>733
+ <ul class="room-list">734
+ {#each invites as room (room.room_id)}735
+ <li>736
+ <button737
+ type="button"738
+ class="room-row"739
+ class:active={$selectedRoomId === room.room_id}740
+ on:click={() => void selectRoom(room.room_id)}741
+ >742
+ <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />743
+ <span class="room-name">{room.name}</span>744
+ <span745
+ class="invite-join"746
+ role="button"747
+ tabindex="0"748
+ aria-label={`Join ${room.name}`}749
+ on:click|stopPropagation={() => void join(room.room_id)}750
+ on:keydown|stopPropagation={(ev) => {751
+ if (ev.key === 'Enter' || ev.key === ' ') {752
+ ev.preventDefault();753
+ void join(room.room_id);754
+ }755
+ }}756
+ >757
+ Join758
+ </span>759
+ </button>760
+ </li>761
+ {/each}762
+ </ul>763
+ {/if}764
+765
+ {#if directRooms.length > 0}766
+ <h2 class="side-h">Direct messages</h2>767
+ <ul class="room-list">768
+ {#each directRooms as room (room.room_id)}769
+ <li>770
+ <button771
+ type="button"772
+ class="room-row"773
+ class:active={$selectedRoomId === room.room_id}774
+ aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}775
+ on:click={() => void selectRoom(room.room_id)}776
+ >777
+ <MxcAvatar mxc={room.avatar} label={room.name} size={26} />778
+ <span class="room-name">{room.name}</span>779
+ {#if roomUnread(room) > 0}780
+ <span781
+ class="badge"782
+ class:mention={room.unread_mentions > 0}783
+ aria-label={`${roomUnread(room)} unread`}784
+ >785
+ {roomUnread(room) > 99 ? '99+' : roomUnread(room)}786
+ </span>787
+ {/if}788
+ </button>789
+ </li>790
+ {/each}791
+ </ul>792
+ {/if}793
+794
+ {#if groupRooms.length > 0}795
+ <h2 class="side-h">{selectedSpace ? 'Channels in this space' : 'Rooms'}</h2>796
+ <ul class="room-list">797
+ {#each groupRooms as room (room.room_id)}798
+ <li>799
+ <button800
+ type="button"801
+ class="room-row"802
+ class:active={$selectedRoomId === room.room_id}803
+ aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}804
+ on:click={() => void selectRoom(room.room_id)}805
+ >806
+ <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />807
+ <span class="room-hash" aria-hidden="true">#</span>808
+ <span class="room-name">{room.name}</span>809
+ {#if roomUnread(room) > 0}810
+ <span811
+ class="badge"812
+ class:mention={room.unread_mentions > 0}813
+ aria-label={`${roomUnread(room)} unread`}814
+ >815
+ {roomUnread(room) > 99 ? '99+' : roomUnread(room)}816
+ </span>817
+ {/if}818
+ </button>819
+ </li>820
+ {/each}821
+ </ul>822
+ {/if}823
+824
+ {#if !selectedSpaceId && spaceList.length > 0}825
+ <h2 class="side-h">Spaces</h2>826
+ <ul class="room-list">827
+ {#each spaceList as room (room.room_id)}828
+ <li>829
+ <button830
+ type="button"831
+ class="room-row"832
+ class:active={selectedSpaceId === room.room_id}833
+ aria-label={`Show channels in space ${room.name}`}834
+ on:click={() => void selectSpace(room.room_id)}835
+ >836
+ <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />837
+ <span class="room-hash" aria-hidden="true">◇</span>838
+ <span class="room-name">{room.name}</span>839
+ </button>840
+ </li>841
+ {/each}842
+ </ul>843
+ {/if}844
+845
+ {#if query && visibleRooms.length === 0}846
+ <p class="side-note">No rooms match “{search.trim()}”.</p>847
+ {/if}848
+ {/if}849
+ {/if}850
+ </div>851
+852
+ <div class="side-footer">853
+ <div class="me" title={$sessionUser}>854
+ {#if profileMxc}855
+ <MxcAvatar mxc={profileMxc} label={profile?.display_name ?? $sessionUser} size={30} />856
+ {:else}857
+ <span class="me-avatar" aria-hidden="true">{$sessionUser.slice(1, 2).toUpperCase()}</span>858
+ {/if}859
+ <span class="me-id">{profile?.display_name && profile.display_name !== $sessionUser ? `${profile.display_name} (${$sessionUser})` : $sessionUser}</span>860
+ </div>861
+ <button862
+ type="button"863
+ class="icon-btn"864
+ title="Encryption and verification"865
+ aria-label="Encryption and verification"866
+ on:click={() => (dialog = 'security')}867
+ >868
+ <span aria-hidden="true">🛡</span>869
+ </button>870
+ <button type="button" class="icon-btn" title="Log out" aria-label="Log out" on:click={() => void doLogout()}>871
+ <span aria-hidden="true">⏻</span>872
+ </button>873
+ </div>874
+ </aside>875
+ {#if sidebarOpen}876
+ <button type="button" class="scrim" aria-label="Close room list" on:click={() => (sidebarOpen = false)}></button>877
+ {/if}878
+879
+ <main class="chat">880
+ {#if $banners.length > 0}881
+ <div class="banners" aria-live="polite">882
+ {#each $banners as banner (banner.id)}883
+ <div class="banner" role="alert">884
+ <span>{banner.message}</span>885
+ <button886
+ type="button"887
+ aria-label="Dismiss error"888
+ on:click={() => dismissBanner(banner.id)}889
+ >890
+ ✕891
+ </button>892
+ </div>893
+ {/each}894
+ </div>895
+ {/if}896
+ {#if !selectedRoom}897
+ <div class="empty">898
+ <div class="empty-mark" aria-hidden="true">◈</div>899
+ {#if selectedSpace}900
+ <h1>{selectedSpace.name}</h1>901
+ <p>Select a channel below to start chatting. Spaces group channels — they are not chat rooms.</p>902
+ {:else}903
+ <h1>Welcome to Malice</h1>904
+ <p>Pick a room from the sidebar, or join one to get started.</p>905
+ {/if}906
+ <div class="empty-cta">907
+ <button type="button" class="primary-btn" on:click={() => (dialog = 'join')}>Join a room</button>908
+ <button type="button" class="ghost-btn" on:click={() => (dialog = 'create')}>Create a room</button>909
+ </div>910
+ <p class="conn-line">911
+ <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>912
+ {$connection}913
+ </p>914
+ </div>915
+ {:else}916
+ <header class="chat-head">917
+ <button918
+ type="button"919
+ class="icon-btn only-mobile"920
+ aria-label="Open room list"921
+ on:click={() => (sidebarOpen = true)}922
+ >923
+ <span aria-hidden="true">☰</span>924
+ </button>925
+ <div class="head-title">926
+ <div class="head-room">927
+ <MxcAvatar mxc={selectedRoom.avatar} label={selectedRoom.name} size={36} shape={selectedRoom.space ? 'rounded' : 'circle'} />928
+ <div class="head-room-text">929
+ <h1>{selectedRoom.name}</h1>930
+ <div class="head-meta">931
+ {#if selectedRoom.space}932
+ <span class="pill">space</span>933
+ {/if}934
+ {#if selectedRoom.encrypted}935
+ <span class="pill" title="End-to-end encrypted">🔒 encrypted</span>936
+ {/if}937
+ {#if selectedRoom.direct}938
+ <span class="pill">direct</span>939
+ {/if}940
+ <span class="conn" title={`Sync state: ${$connection}`}>941
+ <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>942
+ {$connection}943
+ </span>944
+ </div>945
+ </div>946
+ </div>947
+ </div>948
+ <div class="head-actions">949
+ <button950
+ type="button"951
+ class="ghost-btn"952
+ aria-label="Encryption and verification"953
+ title="Encryption and verification"954
+ on:click={() => (dialog = 'security')}955
+ >956
+ 🛡 Security957
+ </button>958
+ <button959
+ type="button"960
+ class="ghost-btn"961
+ aria-pressed={membersOpen}962
+ aria-label={membersOpen ? 'Hide members' : 'Show members'}963
+ on:click={toggleMembers}964
+ >965
+ Members966
+ </button>967
+ {#if selectedRoom.membership === 'Joined'}968
+ <button969
+ type="button"970
+ class="ghost-btn danger"971
+ disabled={leaveBusy}972
+ on:click={() => void leave()}973
+ >974
+ {leaveBusy ? 'Leaving…' : 'Leave'}975
+ </button>976
+ {:else}977
+ <button978
+ type="button"979
+ class="primary-btn"980
+ disabled={joinBusy}981
+ on:click={() => void join(selectedRoom.room_id)}982
+ >983
+ {joinBusy ? 'Joining…' : 'Join'}984
+ </button>985
+ {/if}986
+ <button987
+ type="button"988
+ class="icon-btn only-mobile"989
+ aria-label="Toggle members"990
+ on:click={toggleMembers}991
+ >992
+ <span aria-hidden="true">ⓘ</span>993
+ </button>994
+ </div>995
+ </header>996
+997
+998
+ {#if recoveryBanner}999
+ <div class="sec-banner warn" role="status">1000
+ <span>Message history is locked — recovery is {recoveryBanner.recovery}. Enter your recovery key to decrypt.</span>1001
+ <button type="button" on:click={() => (dialog = 'security')}>Recover</button>1002
+ </div>1003
+ {/if}1004
+ {#if verificationBanner}1005
+ <div class="sec-banner" role="status">1006
+ {#if verificationBanner.kind === 'Incoming'}1007
+ <span>A device wants to verify this session. Review the request to accept it.</span>1008
+ <button type="button" on:click={() => (dialog = 'security')}>Review request</button>1009
+ {:else if verificationBanner.kind === 'Comparison'}1010
+ <span>Verification codes are ready — compare emojis and numbers.</span>1011
+ <button type="button" on:click={() => (dialog = 'security')}>Compare codes</button>1012
+ {:else if verificationBanner.kind === 'Waiting'}1013
+ <span>Verification started — approve on your other device, then compare codes here.</span>1014
+ <button type="button" on:click={() => (dialog = 'security')}>Open verification</button>1015
+ {:else if verificationBanner.kind === 'Failed'}1016
+ <span>Verification failed: {verificationBanner.message}</span>1017
+ <button type="button" on:click={() => (dialog = 'security')}>Try again</button>1018
+ {:else if verificationBanner.kind === 'Cancelled'}1019
+ <span>Verification cancelled: {verificationBanner.reason}</span>1020
+ <button type="button" on:click={() => (dialog = 'security')}>Open security</button>1021
+ {:else}1022
+ <span>Verification in progress.</span>1023
+ <button type="button" on:click={() => (dialog = 'security')}>Open security</button>1024
+ {/if}1025
+ </div>1026
+ {/if}1027
+ {#if $connection !== 'online'}1028
+ <p class="conn-banner" role="status">1029
+ Sync is {$connection}. New messages will appear when the connection recovers.1030
+ </p>1031
+ {/if}1032
+ {#if $selectedRoomId && !openReady[$selectedRoomId] && !openFailed[$selectedRoomId]}1033
+ <p class="conn-banner" role="status">Opening {selectedRoom.name}… sending is disabled until the room is ready.</p>1034
+ {/if}1035
+ {#if $selectedRoomId && openFailed[$selectedRoomId]}1036
+ <p class="conn-banner error" role="alert">{openFailed[$selectedRoomId]}</p>1037
+ {/if}1038
+1039
+ <div class="timeline" bind:this={timelineEl} on:scroll={onTimelineScroll} aria-label={`Messages in ${selectedRoom.name}`}>1040
+ <div class="timeline-top">1041
+ {#if hasStart}1042
+ <p class="start-note">This is the beginning of the visible history.</p>1043
+ {:else}1044
+ <button type="button" class="ghost-btn" disabled={paginateBusy} on:click={() => void paginate()}>1045
+ {paginateBusy ? 'Loading…' : 'Load older messages'}1046
+ </button>1047
+ {/if}1048
+ </div>1049
+ {#if grouped.length === 0}1050
+ <p class="side-note timeline-empty">No messages yet. Say hello!</p>1051
+ {:else}1052
+ {#each grouped as g (g.key)}1053
+ {#if g.item.kind === 'day'}1054
+ <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>1055
+ {:else if g.item.kind === 'readMarker'}1056
+ <div class="new-row"><span>New</span></div>1057
+ {:else if g.item.kind === 'start'}1058
+ <p class="start-note">This is the beginning of the visible history.</p>1059
+ {:else if g.item.kind === 'unknown'}1060
+ <p class="unknown-note">Unsupported event ({g.item.label})</p>1061
+ {:else}1062
+ <MessageRow1063
+ itemId={g.key}1064
+ event={g.item.event}1065
+ showSender={g.showSender}1066
+ senderAvatar={senderAvatarFor(g.item.event.sender.user_id)}1067
+ {reacting}1068
+ onReact={(eventId, key) => void react(eventId, key)}1069
+ />1070
+ {/if}1071
+ {/each}1072
+ {/if}1073
+ {#each pendingItems as p, i (p.id)}1074
+ <MessageRow1075
+ itemId={`pending-${p.id}`}1076
+ event={pendingToEvent(p)}1077
+ showSender={pendingShowSender(i)}1078
+ senderAvatar={senderAvatarFor($sessionUser ?? '')}1079
+ {reacting}1080
+ onReact={(eventId, key) => void react(eventId, key)}1081
+ />1082
+ {#if p.status === 'failed'}1083
+ <div class="pending-actions">1084
+ <span class="pending-error" role="alert">{p.error}</span>1085
+ <button type="button" class="ghost-btn" on:click={() => retryPending(p.id)}>Retry</button>1086
+ <button type="button" class="ghost-btn danger" on:click={() => discardPending(p.id)}>Discard</button>1087
+ </div>1088
+ {/if}1089
+ {/each}1090
+ </div>1091
+1092
+ {#if selectedRoom.membership === 'Joined'}1093
+ <form class="composer" on:submit|preventDefault={() => send()}>1094
+ <label class="visually-hidden" for="composer">Message {selectedRoom.name}</label>1095
+ <input1096
+ type="file"1097
+ bind:this={fileInput}1098
+ class="visually-hidden"1099
+ aria-label="Attach a file"1100
+ on:change={() => void onFileChosen()}1101
+ />1102
+ <button1103
+ type="button"1104
+ class="ghost-btn"1105
+ title="Attach an image or file"1106
+ aria-label="Attach an image or file"1107
+ disabled={!($selectedRoomId && openReady[$selectedRoomId]) || uploadBusy}1108
+ on:click={() => void upload()}1109
+ >1110
+ {uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : '📎'}1111
+ </button>1112
+ <textarea1113
+ id="composer"1114
+ bind:this={composerEl}1115
+ bind:value={composer}1116
+ rows="1"1117
+ placeholder={`Message ${selectedRoom.name}`}1118
+ aria-label={`Message ${selectedRoom.name}. Enter to send, Shift Enter for a new line.`}1119
+ on:keydown={onComposerKey}1120
+ disabled={!($selectedRoomId && openReady[$selectedRoomId])}1121
+ ></textarea>1122
+ <button1123
+ type="submit"1124
+ class="primary-btn"1125
+ disabled={!composer.trim() || !($selectedRoomId && openReady[$selectedRoomId])}1126
+ title={!($selectedRoomId && openReady[$selectedRoomId]) ? 'Waiting for the room to open' : 'Send message'}1127
+ >1128
+ Send1129
+ </button>1130
+ </form>1131
+ <p class="composer-note">Sending and failed states show under your message.</p>1132
+ {:else}1133
+ <div class="join-bar">1134
+ <p>You are not a member of this room.</p>1135
+ <button type="button" class="primary-btn" disabled={joinBusy} on:click={() => void join(selectedRoom.room_id)}>1136
+ {joinBusy ? 'Joining…' : `Join ${selectedRoom.name}`}1137
+ </button>1138
+ </div>1139
+ {/if}1140
+ {/if}1141
+ </main>1142
+1143
+ {#if selectedRoom && membersOpen}1144
+ <aside class="members" aria-label={`Members of ${selectedRoom.name}`}>1145
+ <div class="members-head">1146
+ <h2>Members</h2>1147
+ <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>1148
+ <span aria-hidden="true">✕</span>1149
+ </button>1150
+ </div>1151
+ {#if $membersLoading && members.length === 0}1152
+ <p class="side-note">Loading members…</p>1153
+ {:else if members.length === 0}1154
+ <p class="side-note">No members loaded.</p>1155
+ {#if $selectedRoomId}1156
+ <button type="button" class="ghost-btn" on:click={() => $selectedRoomId && void loadMembers($selectedRoomId)}>1157
+ Retry1158
+ </button>1159
+ {/if}1160
+ {:else}1161
+ <ul class="member-list">1162
+ {#each members as member (member.user_id)}1163
+ <li class="member">1164
+ <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} size={30} />1165
+ <span class="member-names">1166
+ <span class="member-name">{member.display_name || member.name}</span>1167
+ <span class="member-id">{member.user_id}</span>1168
+ </span>1169
+ </li>1170
+ {/each}1171
+ </ul>1172
+ {/if}1173
+ </aside>1174
+ {/if}1175
+1176
+ {#if dialog}1177
+ <div class="modal-wrap" role="presentation" on:click|self={() => (dialog = null)}>1178
+ {#if dialog === 'security'}1179
+ <SecurityPanel1180
+ encryptionState={$encryption}1181
+ verificationState={$verification}1182
+ onAction={() => void loadEncryption()}1183
+ on:close={() => (dialog = null)}1184
+ />1185
+ {:else}1186
+ <div class="modal" role="dialog" aria-modal="true" aria-label={dialog === 'join' ? 'Join a room' : 'Create a room'}>1187
+ <div class="modal-tabs" role="tablist" aria-label="Room actions">1188
+ <button1189
+ type="button"1190
+ role="tab"1191
+ aria-selected={dialog === 'join'}1192
+ class:active={dialog === 'join'}1193
+ on:click={() => (dialog = 'join')}1194
+ >1195
+ Join1196
+ </button>1197
+ <button1198
+ type="button"1199
+ role="tab"1200
+ aria-selected={dialog === 'create'}1201
+ class:active={dialog === 'create'}1202
+ on:click={() => (dialog = 'create')}1203
+ >1204
+ Create1205
+ </button>1206
+ <button1207
+ type="button"1208
+ class="icon-btn modal-x"1209
+ aria-label="Close dialog"1210
+ on:click={() => (dialog = null)}1211
+ >1212
+ <span aria-hidden="true">✕</span>1213
+ </button>1214
+ </div>1215
+ {#if dialog === 'join'}1216
+ <form on:submit|preventDefault={() => void join(joinTarget)}>1217
+ <label class="field">1218
+ <span>Room ID or alias</span>1219
+ <input1220
+ type="text"1221
+ placeholder="!room:example.org or #alias:example.org"1222
+ bind:value={joinTarget}1223
+ required1224
+ disabled={joinBusy}1225
+ />1226
+ </label>1227
+ <button type="submit" class="primary-btn wide" disabled={joinBusy || !joinTarget.trim()}>1228
+ {joinBusy ? 'Joining…' : 'Join room'}1229
+ </button>1230
+ </form>1231
+ {:else}1232
+ <form on:submit|preventDefault={() => void create()}>1233
+ <label class="field">1234
+ <span>Room name</span>1235
+ <input1236
+ type="text"1237
+ placeholder="Weekend plans"1238
+ bind:value={createName}1239
+ required1240
+ disabled={createBusy}1241
+ />1242
+ </label>1243
+ <label class="field">1244
+ <span>Topic (optional)</span>1245
+ <input1246
+ type="text"1247
+ placeholder="What should this room be about?"1248
+ bind:value={createTopic}1249
+ disabled={createBusy}1250
+ />1251
+ </label>1252
+ <button type="submit" class="primary-btn wide" disabled={createBusy || !createName.trim()}>1253
+ {createBusy ? 'Creating…' : 'Create room'}1254
+ </button>1255
+ </form>1256
+ {/if}1257
+ </div>1258
+ {/if}1259
+ </div>1260
+ {/if}1261
+ </div>1262
+{/if}@@ -0,0 +1,556 @@
1
+:root {2
+ --bg: #14151c;3
+ --bg-soft: #1b1d27;4
+ --panel: #1e2029;5
+ --panel-2: #232634;6
+ --border: #2e3142;7
+ --text: #e6e8f0;8
+ --muted: #9aa0b4;9
+ --accent: #5865f2;10
+ --danger: #f26d6d;11
+ --warn: #e8b25a;12
+ color-scheme: dark;13
+}14
+15
+* { box-sizing: border-box; }16
+17
+html, body, #app {18
+ margin: 0;19
+ min-height: 100dvh;20
+}21
+22
+body {23
+ background: var(--bg);24
+ color: var(--text);25
+ font: 14px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;26
+}27
+28
+button { font: inherit; }29
+input, textarea { font: inherit; }30
+31
+.visually-hidden {32
+ position: absolute;33
+ width: 1px; height: 1px;34
+ overflow: hidden;35
+ clip: rect(0 0 0 0);36
+ white-space: nowrap;37
+}38
+39
+.splash {40
+ min-height: 100dvh;41
+ display: flex;42
+ flex-direction: column;43
+ gap: 12px;44
+ align-items: center;45
+ justify-content: center;46
+ color: var(--muted);47
+}48
+.splash-mark {49
+ width: 52px; height: 52px;50
+ border-radius: 16px;51
+ display: grid; place-items: center;52
+ font-size: 26px; font-weight: 800; color: #fff;53
+ background: linear-gradient(135deg, var(--accent), #7c5cff);54
+}55
+56
+.app {57
+ display: grid;58
+ grid-template-columns: 68px 300px 1fr;59
+ height: 100dvh;60
+ overflow: hidden;61
+}62
+.app:has(.members) { grid-template-columns: 68px 300px 1fr 260px; }63
+64
+/* ——— Rail ——— */65
+.rail {66
+ background: #101117;67
+ display: flex;68
+ flex-direction: column;69
+ align-items: center;70
+ gap: 8px;71
+ padding: 12px 0;72
+ overflow-y: auto;73
+}74
+.rail-sep { width: 32px; height: 2px; border-radius: 2px; background: var(--border); }75
+.rail-btn {76
+ width: 46px; height: 46px;77
+ border-radius: 50%;78
+ border: none;79
+ background: var(--panel-2);80
+ color: var(--text);81
+ font-size: 19px;82
+ font-weight: 700;83
+ cursor: pointer;84
+ transition: border-radius 0.15s, background 0.15s;85
+ display: grid;86
+ place-items: center;87
+ overflow: hidden;88
+ padding: 0;89
+}90
+.rail-btn:hover { border-radius: 16px; background: var(--accent); color: #fff; }91
+.rail-btn.active { border-radius: 16px; background: var(--accent); color: #fff; }92
+.rail-btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }93
+.rail-avatar { width: 100%; height: 100%; object-fit: cover; display: block; }94
+.rail-add { color: var(--muted); }95
+.rail-add:hover { color: #fff; }96
+.space-context {97
+ display: flex;98
+ align-items: center;99
+ gap: 10px;100
+ padding: 10px 8px 4px;101
+}102
+.space-context-text { display: flex; flex-direction: column; min-width: 0; }103
+.space-context-text strong { font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }104
+.space-context-text span { font-size: 11.5px; color: var(--muted); }105
+.sec-banner {106
+ display: flex;107
+ align-items: center;108
+ gap: 10px;109
+ margin: 10px 16px 0;110
+ padding: 8px 12px;111
+ background: rgba(88, 101, 242, 0.14);112
+ border: 1px solid var(--accent);113
+ color: var(--text);114
+ border-radius: 8px;115
+ font-size: 12.5px;116
+}117
+.sec-banner.warn { background: #33300f; border-color: #5c551f; color: #ecd98a; }118
+.sec-banner button {119
+ margin-left: auto;120
+ flex: none;121
+ background: transparent;122
+ border: 1px solid currentColor;123
+ border-radius: 6px;124
+ color: inherit;125
+ cursor: pointer;126
+ padding: 3px 10px;127
+ font-size: 12.5px;128
+}129
+.composer-note { margin: 0; padding: 0 16px 10px; font-size: 11.5px; color: var(--muted); }130
+.head-room { display: flex; align-items: center; gap: 12px; min-width: 0; }131
+.head-room-text { min-width: 0; flex: 1; }132
+.conn-banner.error { background: #3a1d22; border-color: #6b2a33; color: #ffc9ce; }133
+/* ——— Sidebar ——— */134
+.sidebar {135
+ background: var(--panel);136
+ border-right: 1px solid var(--border);137
+ display: flex;138
+ flex-direction: column;139
+ min-height: 0;140
+}141
+.side-search {142
+ display: flex;143
+ gap: 6px;144
+ padding: 12px;145
+ border-bottom: 1px solid var(--border);146
+}147
+.side-search input {148
+ flex: 1;149
+ min-width: 0;150
+ background: var(--bg-soft);151
+ border: 1px solid var(--border);152
+ border-radius: 8px;153
+ color: var(--text);154
+ padding: 7px 10px;155
+}156
+.side-search input:focus { outline: 2px solid var(--accent); border-color: var(--accent); }157
+.side-scroll { flex: 1; overflow-y: auto; padding: 8px; min-height: 0; }158
+.side-h {159
+ font-size: 11px;160
+ text-transform: uppercase;161
+ letter-spacing: 0.08em;162
+ color: var(--muted);163
+ margin: 12px 6px 6px;164
+}165
+.side-h:first-child { margin-top: 4px; }166
+.room-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }167
+.room-row {168
+ width: 100%;169
+ display: flex;170
+ align-items: center;171
+ gap: 6px;172
+ background: transparent;173
+ border: none;174
+ border-radius: 8px;175
+ color: var(--text);176
+ padding: 7px 9px;177
+ cursor: pointer;178
+ text-align: left;179
+ font-size: 13.5px;180
+}181
+.room-row:hover { background: var(--panel-2); }182
+.room-row.active { background: rgba(88, 101, 242, 0.2); }183
+.room-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }184
+.room-hash { color: var(--muted); }185
+.room-name {186
+ flex: 1;187
+ min-width: 0;188
+ overflow: hidden;189
+ text-overflow: ellipsis;190
+ white-space: nowrap;191
+}192
+.badge {193
+ flex: none;194
+ min-width: 20px;195
+ height: 20px;196
+ padding: 0 6px;197
+ border-radius: 999px;198
+ background: var(--panel-2);199
+ border: 1px solid var(--border);200
+ color: var(--text);201
+ font-size: 11px;202
+ font-weight: 700;203
+ display: inline-grid;204
+ place-items: center;205
+}206
+.badge.mention { background: var(--accent); border-color: var(--accent); color: #fff; }207
+.invite-join {208
+ flex: none;209
+ background: var(--accent);210
+ color: #fff;211
+ border-radius: 6px;212
+ padding: 2px 10px;213
+ font-size: 12px;214
+ font-weight: 600;215
+ cursor: pointer;216
+}217
+.side-note { color: var(--muted); font-size: 13px; padding: 8px 6px; }218
+.side-cta { display: flex; gap: 8px; padding: 0 6px; }219
+.side-cta button {220
+ flex: 1;221
+ background: var(--panel-2);222
+ border: 1px solid var(--border);223
+ color: var(--text);224
+ border-radius: 8px;225
+ padding: 8px;226
+ cursor: pointer;227
+}228
+.side-cta button:hover { border-color: var(--accent); }229
+.side-footer {230
+ display: flex;231
+ align-items: center;232
+ gap: 8px;233
+ padding: 10px 12px;234
+ border-top: 1px solid var(--border);235
+ background: var(--bg-soft);236
+}237
+.me { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }238
+.me-avatar {239
+ flex: none;240
+ width: 30px; height: 30px;241
+ border-radius: 50%;242
+ background: var(--accent);243
+ color: #fff;244
+ font-weight: 700;245
+ font-size: 13px;246
+ display: inline-grid;247
+ place-items: center;248
+}249
+.me-id {250
+ min-width: 0;251
+ overflow: hidden;252
+ text-overflow: ellipsis;253
+ white-space: nowrap;254
+ font-size: 12.5px;255
+ color: var(--muted);256
+}257
+.icon-btn {258
+ flex: none;259
+ background: transparent;260
+ border: 1px solid transparent;261
+ border-radius: 8px;262
+ color: var(--muted);263
+ font-size: 16px;264
+ padding: 5px 9px;265
+ cursor: pointer;266
+}267
+.icon-btn:hover:not(:disabled) { background: var(--panel-2); color: var(--text); }268
+.icon-btn:disabled { opacity: 0.4; cursor: default; }269
+.scrim { display: none; }270
+271
+/* ——— Chat ——— */272
+.chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--bg); }273
+.empty {274
+ margin: auto;275
+ text-align: center;276
+ padding: 24px;277
+ max-width: 420px;278
+}279
+.empty-mark { font-size: 44px; color: var(--accent); }280
+.empty h1 { font-size: 22px; margin: 8px 0 4px; }281
+.empty p { color: var(--muted); }282
+.empty-cta { display: flex; gap: 10px; justify-content: center; margin: 16px 0; }283
+.conn-line { display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 13px; }284
+285
+.chat-head {286
+ display: flex;287
+ align-items: center;288
+ gap: 10px;289
+ padding: 10px 16px;290
+ border-bottom: 1px solid var(--border);291
+ background: var(--panel);292
+ min-height: 62px;293
+}294
+.head-title { flex: 1; min-width: 0; }295
+.head-title h1 {296
+ margin: 0;297
+ font-size: 15.5px;298
+ overflow: hidden;299
+ text-overflow: ellipsis;300
+ white-space: nowrap;301
+}302
+.head-meta { display: flex; align-items: center; gap: 8px; margin-top: 2px; }303
+.pill {304
+ font-size: 11px;305
+ border: 1px solid var(--border);306
+ border-radius: 999px;307
+ padding: 0 8px;308
+ color: var(--muted);309
+}310
+.conn { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }311
+.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; }312
+.dot-online { background: #3fb97f; }313
+.dot-busy { background: var(--warn); }314
+.dot-off { background: var(--danger); }315
+.head-actions { display: flex; align-items: center; gap: 8px; }316
+317
+.primary-btn {318
+ background: var(--accent);319
+ border: none;320
+ color: #fff;321
+ font-weight: 600;322
+ border-radius: 8px;323
+ padding: 8px 16px;324
+ cursor: pointer;325
+ font-size: 13.5px;326
+}327
+.primary-btn:hover:not(:disabled) { filter: brightness(1.1); }328
+.primary-btn:disabled { opacity: 0.55; cursor: default; }329
+.primary-btn.wide { width: 100%; margin-top: 12px; }330
+.ghost-btn {331
+ background: var(--panel-2);332
+ border: 1px solid var(--border);333
+ color: var(--text);334
+ border-radius: 8px;335
+ padding: 7px 14px;336
+ cursor: pointer;337
+ font-size: 13px;338
+}339
+.ghost-btn:hover:not(:disabled) { border-color: var(--accent); }340
+.ghost-btn:disabled { opacity: 0.55; cursor: wait; }341
+.ghost-btn.danger { color: var(--danger); }342
+.ghost-btn.danger:hover:not(:disabled) { border-color: var(--danger); }343
+344
+.banners { display: flex; flex-direction: column; gap: 6px; padding: 10px 16px 0; }345
+.banner {346
+ display: flex;347
+ align-items: center;348
+ gap: 10px;349
+ background: #3a1d22;350
+ border: 1px solid #6b2a33;351
+ color: #ffc9ce;352
+ border-radius: 8px;353
+ padding: 8px 12px;354
+ font-size: 13px;355
+}356
+.banner span { flex: 1; }357
+.banner button {358
+ background: transparent;359
+ border: none;360
+ color: inherit;361
+ cursor: pointer;362
+ font-size: 13px;363
+ padding: 2px 6px;364
+}365
+.conn-banner {366
+ margin: 10px 16px 0;367
+ padding: 7px 12px;368
+ background: #33300f;369
+ border: 1px solid #5c551f;370
+ color: #ecd98a;371
+ border-radius: 8px;372
+ font-size: 12.5px;373
+}374
+375
+.timeline {376
+ flex: 1;377
+ overflow-y: auto;378
+ padding: 12px 20px 16px;379
+ min-height: 0;380
+}381
+.timeline-top { display: flex; justify-content: center; padding: 4px 0 12px; }382
+.start-note { text-align: center; color: var(--muted); font-size: 12.5px; }383
+.timeline-empty { text-align: center; padding: 32px 0; }384
+.day-row {385
+ display: flex;386
+ align-items: center;387
+ gap: 12px;388
+ margin: 14px 0 8px;389
+ color: var(--muted);390
+ font-size: 12px;391
+}392
+.day-row::before, .day-row::after {393
+ content: '';394
+ flex: 1;395
+ height: 1px;396
+ background: var(--border);397
+}398
+.new-row {399
+ display: flex;400
+ align-items: center;401
+ gap: 12px;402
+ margin: 8px 0;403
+ color: var(--danger);404
+ font-size: 12px;405
+ font-weight: 600;406
+}407
+.new-row::before, .new-row::after { content: ''; flex: 1; height: 1px; background: #6b2a33; }408
+.unknown-note { color: var(--muted); font-size: 12.5px; font-style: italic; }409
+.pending-actions {410
+ display: flex;411
+ align-items: center;412
+ gap: 8px;413
+ margin: -4px 0 6px 44px;414
+}415
+.pending-error { color: var(--danger); font-size: 12px; flex: 1; min-width: 0; overflow-wrap: anywhere; }416
+417
+.composer {418
+ display: flex;419
+ gap: 10px;420
+ align-items: flex-end;421
+ padding: 12px 16px 16px;422
+ border-top: 1px solid var(--border);423
+ background: var(--panel);424
+}425
+.composer textarea {426
+ flex: 1;427
+ resize: none;428
+ background: var(--bg-soft);429
+ border: 1px solid var(--border);430
+ border-radius: 10px;431
+ color: var(--text);432
+ padding: 10px 12px;433
+ min-height: 40px;434
+ max-height: 160px;435
+ line-height: 1.4;436
+}437
+.composer textarea:focus { outline: 2px solid var(--accent); border-color: var(--accent); }438
+.join-bar {439
+ display: flex;440
+ align-items: center;441
+ gap: 12px;442
+ padding: 12px 16px 16px;443
+ border-top: 1px solid var(--border);444
+ background: var(--panel);445
+}446
+.join-bar p { margin: 0; color: var(--muted); font-size: 13px; flex: 1; }447
+448
+/* ——— Members ——— */449
+.members {450
+ background: var(--panel);451
+ border-left: 1px solid var(--border);452
+ display: flex;453
+ flex-direction: column;454
+ min-height: 0;455
+ overflow: hidden;456
+}457
+.members-head {458
+ display: flex;459
+ align-items: center;460
+ justify-content: space-between;461
+ padding: 12px 12px 8px;462
+}463
+.members-head h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0; }464
+.member-list { list-style: none; margin: 0; padding: 0 8px 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }465
+.member { display: flex; align-items: center; gap: 9px; padding: 6px 8px; border-radius: 8px; }466
+.member:hover { background: var(--panel-2); }467
+.member-names { min-width: 0; display: flex; flex-direction: column; }468
+.member-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }469
+.member-id { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }470
+471
+/* ——— Modal ——— */472
+.modal-wrap {473
+ position: fixed;474
+ inset: 0;475
+ background: rgba(0, 0, 0, 0.6);476
+ display: grid;477
+ place-items: center;478
+ padding: 20px;479
+ z-index: 50;480
+}481
+.modal {482
+ width: min(420px, 100%);483
+ background: var(--panel);484
+ border: 1px solid var(--border);485
+ border-radius: 14px;486
+ padding: 18px;487
+}488
+.modal-tabs { display: flex; gap: 4px; margin-bottom: 14px; }489
+.modal-tabs button[role='tab'] {490
+ background: transparent;491
+ border: none;492
+ border-bottom: 2px solid transparent;493
+ color: var(--muted);494
+ font-weight: 600;495
+ padding: 8px 14px;496
+ cursor: pointer;497
+ font-size: 14px;498
+}499
+.modal-tabs button[role='tab'].active { color: var(--text); border-bottom-color: var(--accent); }500
+.modal-x { margin-left: auto; }501
+.modal .field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--muted); margin-bottom: 12px; }502
+.modal .field input {503
+ background: var(--bg-soft);504
+ border: 1px solid var(--border);505
+ border-radius: 8px;506
+ color: var(--text);507
+ padding: 9px 11px;508
+ font-size: 14px;509
+}510
+.modal .field input:focus { outline: 2px solid var(--accent); border-color: var(--accent); }511
+512
+.only-mobile { display: none; }513
+514
+@media (max-width: 1100px) {515
+ .app, .app:has(.members) { grid-template-columns: 60px 1fr; }516
+ .sidebar {517
+ position: fixed;518
+ top: 0; bottom: 0; left: 60px;519
+ width: min(320px, 85vw);520
+ z-index: 40;521
+ transform: translateX(calc(-100% - 60px));522
+ transition: transform 0.18s ease;523
+ box-shadow: 8px 0 32px rgba(0, 0, 0, 0.5);524
+ }525
+ .sidebar.open { transform: none; }526
+ .scrim {527
+ display: block;528
+ position: fixed;529
+ inset: 0;530
+ left: 60px;531
+ background: rgba(0, 0, 0, 0.55);532
+ border: none;533
+ z-index: 30;534
+ cursor: pointer;535
+ }536
+ .members {537
+ position: fixed;538
+ top: 0; bottom: 0; right: 0;539
+ width: min(300px, 85vw);540
+ z-index: 40;541
+ box-shadow: -8px 0 32px rgba(0, 0, 0, 0.5);542
+ }543
+ .only-mobile { display: inline-block; }544
+ .head-actions .ghost-btn { display: none; }545
+ .timeline { padding: 10px 12px 14px; }546
+ .chat-head { padding: 8px 12px; }547
+ .composer { padding: 10px 12px 12px; }548
+}549
+550
+@media (max-width: 640px) {551
+ .app, .app:has(.members) { grid-template-columns: 52px 1fr; }552
+ .sidebar { left: 52px; transform: translateX(calc(-100% - 52px)); }553
+ .scrim { left: 52px; }554
+ .rail-btn { width: 40px; height: 40px; font-size: 16px; }555
+ .empty-cta { flex-direction: column; }556
+}@@ -0,0 +1,196 @@
1
+<script lang="ts">2
+ import { createEventDispatcher } from 'svelte';3
+ import { login } from './api';4
+ import type { SoftLogout } from './api';5
+ import { pushBanner } from './store';6
+7
+ export let softLogout: SoftLogout | null = null;8
+9
+ const dispatch = createEventDispatcher<{ done: string }>();10
+11
+ let homeserver = '';12
+ let username = '';13
+ let password = '';14
+ let busy = false;15
+ let error: string | null = null;16
+ let overridden = false;17
+18
+ $: locked = !!softLogout && !overridden;19
+ $: if (softLogout && !overridden) {20
+ homeserver = softLogout.homeserver;21
+ username = softLogout.user_id;22
+ }23
+24
+ function useDifferentAccount(): void {25
+ overridden = true;26
+ homeserver = '';27
+ username = '';28
+ password = '';29
+ }30
+31
+ async function submit(): Promise<void> {32
+ if (busy) return;33
+ error = null;34
+ const hs = homeserver.trim();35
+ const user = username.trim();36
+ if (!hs || !user || !password) {37
+ error = 'Homeserver, username and password are all required.';38
+ return;39
+ }40
+ busy = true;41
+ try {42
+ const res = await login(hs, user, password);43
+ dispatch('done', res.user_id);44
+ } catch (e) {45
+ const message = e instanceof Error ? e.message : 'Login failed';46
+ error = message;47
+ pushBanner(`Login failed: ${message}`);48
+ } finally {49
+ busy = false;50
+ }51
+ }52
+</script>53
+54
+<div class="login-wrap">55
+ <form class="login-card" on:submit|preventDefault={submit} aria-label="Log in to Matrix">56
+ <div class="login-brand" aria-hidden="true">M</div>57
+ <h1>Malice</h1>58
+ <p class="login-sub">Hosted Matrix chat. Sign in with your homeserver account.</p>59
+ {#if error}60
+ <p class="login-error" role="alert">{error}</p>61
+ {/if}62
+ {#if locked}63
+ <div class="login-notice" role="status">64
+ <p>This device was signed out by the server. Sign in again to keep its encrypted history.</p>65
+ <button type="button" class="login-link" on:click={useDifferentAccount}>Use a different account</button>66
+ </div>67
+ {/if}68
+ <label class="field">69
+ <span>Homeserver (network address — not a space)</span>70
+ <input71
+ type="url"72
+ inputmode="url"73
+ autocomplete="url"74
+ placeholder="https://matrix.example.org"75
+ bind:value={homeserver}76
+ required77
+ disabled={busy || locked}78
+ />79
+ </label>80
+ <label class="field">81
+ <span>Username</span>82
+ <input83
+ type="text"84
+ autocomplete="username"85
+ placeholder="alice"86
+ bind:value={username}87
+ required88
+ disabled={busy || locked}89
+ />90
+ </label>91
+ <label class="field">92
+ <span>Password</span>93
+ <input94
+ type="password"95
+ autocomplete="current-password"96
+ placeholder="••••••••"97
+ bind:value={password}98
+ required99
+ disabled={busy}100
+ />101
+ </label>102
+ <button type="submit" class="primary" disabled={busy}>103
+ {busy ? 'Signing in…' : 'Sign in'}104
+ </button>105
+ <p class="login-note">Your password is sent to this server for Matrix sign-in. Access tokens stay server-side.</p>106
+ </form>107
+</div>108
+109
+<style>110
+ .login-wrap {111
+ min-height: 100dvh;112
+ display: flex;113
+ align-items: center;114
+ justify-content: center;115
+ padding: 24px;116
+ background: radial-gradient(1200px 600px at 70% -10%, #1c2440 0%, var(--bg) 55%);117
+ }118
+ .login-card {119
+ width: min(400px, 100%);120
+ background: var(--panel);121
+ border: 1px solid var(--border);122
+ border-radius: 16px;123
+ padding: 28px 26px;124
+ display: flex;125
+ flex-direction: column;126
+ gap: 12px;127
+ }128
+ .login-brand {129
+ width: 44px;130
+ height: 44px;131
+ border-radius: 14px;132
+ display: grid;133
+ place-items: center;134
+ font-weight: 800;135
+ font-size: 22px;136
+ color: #fff;137
+ background: linear-gradient(135deg, var(--accent), #7c5cff);138
+ }139
+ h1 { font-size: 24px; margin: 2px 0 0; }140
+ .login-sub { color: var(--muted); font-size: 13.5px; margin: 0 0 6px; }141
+ .login-error {142
+ background: #3a1d22;143
+ border: 1px solid #6b2a33;144
+ color: #ffb4bc;145
+ border-radius: 8px;146
+ padding: 8px 10px;147
+ font-size: 13px;148
+ margin: 0;149
+ }150
+ .login-notice {151
+ background: var(--bg-soft);152
+ border: 1px solid var(--border);153
+ border-radius: 8px;154
+ padding: 10px 12px;155
+ font-size: 12.5px;156
+ color: var(--muted);157
+ display: flex;158
+ flex-direction: column;159
+ gap: 6px;160
+ }161
+ .login-notice p { margin: 0; }162
+ .login-link {163
+ align-self: flex-start;164
+ background: none;165
+ border: none;166
+ color: var(--accent);167
+ font-size: 12.5px;168
+ padding: 0;169
+ cursor: pointer;170
+ }171
+ .login-link:hover { text-decoration: underline; }172
+ .field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--muted); }173
+ .field input {174
+ background: var(--bg-soft);175
+ border: 1px solid var(--border);176
+ border-radius: 8px;177
+ color: var(--text);178
+ padding: 9px 11px;179
+ font-size: 14px;180
+ }181
+ .field input:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: var(--accent); }182
+ .primary {183
+ margin-top: 4px;184
+ background: var(--accent);185
+ border: none;186
+ color: #fff;187
+ font-weight: 600;188
+ border-radius: 9px;189
+ padding: 10px;190
+ font-size: 14.5px;191
+ cursor: pointer;192
+ }193
+ .primary:disabled { opacity: 0.6; cursor: wait; }194
+ .primary:not(:disabled):hover { filter: brightness(1.1); }195
+ .login-note { color: var(--muted); font-size: 12px; margin: 4px 0 0; line-height: 1.5; }196
+</style>@@ -0,0 +1,126 @@
1
+<script lang="ts">2
+ import type { MediaAttachment } from './types';3
+ import { messageMediaUrl } from './api';4
+5
+ export let attachment: MediaAttachment;6
+7
+ const PREVIEWABLE = new Set(['Image', 'Sticker']);8
+9
+ // Encrypted media often has no fetchable thumbnail, so request the bounded10
+ // full bytes directly instead of flashing a broken thumbnail first.11
+ $: fullUrl = messageMediaUrl(attachment.id, 'Full');12
+ $: thumbUrl = messageMediaUrl(attachment.id, 'Thumbnail');13
+ $: previewable = PREVIEWABLE.has(attachment.kind);14
+15
+ let fullFailed = false;16
+17
+ $: if (attachment.id) {18
+ fullFailed = false;19
+ }20
+21
+ function sizeLabel(): string {22
+ if (typeof attachment.size_bytes !== 'number') return '';23
+ const bytes = attachment.size_bytes;24
+ if (bytes < 1024) return `${bytes} B`;25
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;26
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;27
+ }28
+29
+ function durationLabel(): string {30
+ if (typeof attachment.duration_ms !== 'number') return '';31
+ const totalSeconds = Math.round(attachment.duration_ms / 1000);32
+ const minutes = Math.floor(totalSeconds / 60);33
+ const seconds = totalSeconds % 60;34
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;35
+ }36
+</script>37
+38
+{#if previewable && !fullFailed}39
+ <figure class="media-figure">40
+ <a href={fullUrl} target="_blank" rel="noopener noreferrer" title={attachment.filename}>41
+ <img42
+ class="media-image"43
+ src={fullUrl}44
+ alt={attachment.caption || attachment.filename}45
+ loading="lazy"46
+ on:error={() => (fullFailed = true)}47
+ />48
+ </a>49
+ <figcaption class="media-caption">50
+ {#if attachment.caption && attachment.caption !== attachment.filename}51
+ <span>{attachment.caption}</span>52
+ {:else}53
+ <span>{attachment.filename}</span>54
+ {/if}55
+ {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}56
+ </figcaption>57
+ </figure>58
+{:else if previewable && fullFailed}59
+ <div class="media-file">60
+ <span class="file-icon" aria-hidden="true">🖼</span>61
+ <span>{attachment.caption || attachment.filename}</span>62
+ <span class="media-size">preview unavailable</span>63
+ </div>64
+{:else if attachment.kind === 'Video'}65
+ <div class="media-block">66
+ <!-- svelte-ignore a11y-media-has-caption -->67
+ <video class="media-video" controls preload="metadata" poster={attachment.has_thumbnail ? thumbUrl : undefined}>68
+ <source src={fullUrl} type={attachment.mimetype ?? undefined} />69
+ Your browser cannot play this video.70
+ </video>71
+ <div class="media-caption">72
+ <a href={fullUrl} target="_blank" rel="noopener noreferrer">{attachment.caption || attachment.filename}</a>73
+ {#if durationLabel()}<span class="media-size"> · {durationLabel()}</span>{/if}74
+ {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}75
+ </div>76
+ </div>77
+{:else if attachment.kind === 'Audio'}78
+ <div class="media-block">79
+ <!-- svelte-ignore a11y-media-has-caption -->80
+ <audio class="media-audio" controls preload="metadata" src={fullUrl}></audio>81
+ <div class="media-caption">82
+ <span>{attachment.caption || attachment.filename}</span>83
+ {#if durationLabel()}<span class="media-size"> · {durationLabel()}</span>{/if}84
+ {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}85
+ </div>86
+ </div>87
+{:else}88
+ <div class="media-file">89
+ <span class="file-icon" aria-hidden="true">📎</span>90
+ <a href={fullUrl} target="_blank" rel="noopener noreferrer" download={attachment.filename}>91
+ {attachment.caption || attachment.filename}92
+ </a>93
+ {#if sizeLabel()}<span class="media-size">{sizeLabel()}</span>{/if}94
+ </div>95
+{/if}96
+97
+<style>98
+ .media-figure { margin: 6px 0 2px; max-width: 420px; }99
+ .media-image {100
+ display: block;101
+ max-width: 100%;102
+ max-height: 320px;103
+ border-radius: 8px;104
+ border: 1px solid var(--border);105
+ background: var(--bg-soft);106
+ }107
+ .media-video { display: block; max-width: 100%; max-height: 320px; border-radius: 8px; background: #000; }108
+ .media-audio { display: block; width: 100%; max-width: 420px; margin-top: 6px; }109
+ .media-block { margin: 6px 0 2px; max-width: 420px; }110
+ .media-caption { font-size: 12px; color: var(--muted); margin-top: 4px; display: flex; gap: 4px; flex-wrap: wrap; }111
+ .media-caption a { color: var(--text); }112
+ .media-size { opacity: 0.85; }113
+ .media-file {114
+ display: inline-flex;115
+ align-items: center;116
+ gap: 8px;117
+ margin: 6px 0 2px;118
+ padding: 6px 12px;119
+ border: 1px solid var(--border);120
+ border-radius: 8px;121
+ background: var(--bg-soft);122
+ max-width: 100%;123
+ }124
+ .media-file a { color: var(--text); overflow-wrap: anywhere; }125
+ .file-icon { font-size: 15px; }126
+</style>@@ -0,0 +1,306 @@
1
+<script lang="ts">2
+ import MediaAttachmentView from './MediaAttachmentView.svelte';3
+ import MxcAvatar from './MxcAvatar.svelte';4
+ import type { SendState, TimelineContent, TimelineEvent } from './types';5
+ import { senderLabel } from './types';6
+ import { formatClock, formatDay, initialsOf, senderHue } from './format';7
+ import { findUrls } from './urls';8
+9
+ export let itemId: string;10
+ export let event: TimelineEvent;11
+ export let showSender: boolean;12
+ export let reacting: boolean;13
+ export let onReact: (eventId: string, key: string) => void;14
+ export let senderAvatar: string | null = null;15
+16
+ const QUICK_KEYS = ['👍', '❤️', '😂', '🎉', '😮', '😢'];17
+18
+ const MEMBERSHIP_LABELS: Record<string, string> = {19
+ Joined: 'joined the room',20
+ Left: 'left the room',21
+ Banned: 'was banned',22
+ Unbanned: 'was unbanned',23
+ Kicked: 'was kicked',24
+ KickedAndBanned: 'was kicked and banned',25
+ Invited: 'was invited',26
+ InvitationAccepted: 'accepted the invite',27
+ InvitationRejected: 'rejected the invite',28
+ InvitationRevoked: 'had the invite revoked',29
+ Knocked: 'requested to join',30
+ KnockAccepted: 'was let in',31
+ KnockRetracted: 'withdrew the request to join',32
+ KnockDenied: 'had the request to join denied'33
+ };34
+35
+ $: name = senderLabel(event.sender);36
+ $: displayName = event.sender.name_ambiguous ? `${name} (${event.sender.user_id})` : name;37
+ $: interactive = event.content.kind === 'Message' || event.content.kind === 'Sticker' || event.content.kind === 'Poll';38
+ $: previews = event.content.kind === 'Message' ? findUrls(event.content.message.body) : [];39
+40
+ function sendLabel(state: SendState): string {41
+ if (state.kind === 'Failed') return state.will_retry ? 'failed — will retry' : 'failed — not delivered';42
+ if (state.kind === 'Sending') return 'sending…';43
+ if (event.own && state.kind === 'Sent' && event.event_id) return '✓ sent';44
+ return '';45
+ }46
+47
+ function actorLabel(userId: string, name: string | null): string {48
+ return name ?? userId;49
+ }50
+51
+ function replyPreview(content: TimelineContent): string {52
+ if (content.kind === 'Message') return content.message.body;53
+ if (content.kind === 'Sticker') return content.body || 'Sticker';54
+ if (content.kind === 'Poll') return `Poll: ${content.question}`;55
+ if (content.kind === 'Redacted') return 'Message deleted';56
+ if (content.kind === 'UnableToDecrypt') return 'Unable to decrypt';57
+ return systemLine(content);58
+ }59
+60
+ function systemLine(content: TimelineContent): string {61
+ switch (content.kind) {62
+ case 'Redacted':63
+ return 'Message deleted';64
+ case 'UnableToDecrypt':65
+ return `Message from ${displayName} could not be decrypted`;66
+ case 'Membership': {67
+ const who = actorLabel(content.user_id, content.display_name);68
+ return `${who} ${MEMBERSHIP_LABELS[content.change] ?? 'updated their membership'}`;69
+ }70
+ case 'Profile': {71
+ const who = actorLabel(content.user_id, null);72
+ const parts: string[] = [];73
+ if (content.display_name) {74
+ parts.push(75
+ content.display_name.new76
+ ? `changed their display name to "${content.display_name.new}"`77
+ : 'removed their display name'78
+ );79
+ }80
+ if (content.avatar) parts.push('changed their avatar');81
+ return `${who} ${parts.length > 0 ? parts.join(' and ') : 'updated their profile'}`;82
+ }83
+ case 'RoomName':84
+ return content.name85
+ ? `${displayName} changed the room name to "${content.name}"`86
+ : `${displayName} removed the room name`;87
+ case 'RoomTopic':88
+ return content.topic89
+ ? `${displayName} changed the topic to "${content.topic}"`90
+ : `${displayName} removed the topic`;91
+ case 'RoomAvatar':92
+ return content.avatar ? `${displayName} changed the room avatar` : `${displayName} removed the room avatar`;93
+ case 'EncryptionEnabled':94
+ return `${displayName} enabled encryption in this room`;95
+ case 'RoomCreated':96
+ return `${displayName} created the room`;97
+ case 'RoomUpgraded':98
+ return `${displayName} upgraded the room to a new version`;99
+ case 'OtherState':100
+ return `${displayName} sent a state event (${content.event_type})`;101
+ case 'Other':102
+ return `${displayName} sent an event (${content.event_type})`;103
+ default:104
+ return displayName;105
+ }106
+ }107
+</script>108
+109
+<div class="msg" class:own={event.own} class:system={!interactive} data-item={itemId}>110
+ {#if showSender}111
+ {#if senderAvatar}112
+ <MxcAvatar mxc={senderAvatar} label={name} size={34} />113
+ {:else}114
+ <div115
+ class="avatar"116
+ aria-hidden="true"117
+ style={`background:hsl(${senderHue(event.sender.user_id)} 35% 32%)`}118
+ >119
+ {initialsOf(name)}120
+ </div>121
+ {/if}122
+ {:else}123
+ <div class="avatar-spacer" aria-hidden="true">124
+ <span class="hover-time">{formatClock(event.timestamp_ms)}</span>125
+ </div>126
+ {/if}127
+ <div class="msg-body">128
+ {#if showSender}129
+ <div class="msg-head">130
+ <span class="sender" style={`color:hsl(${senderHue(event.sender.user_id)} 70% 72%)`}>131
+ {displayName}132
+ </span>133
+ <time class="time" datetime={new Date(event.timestamp_ms).toISOString()}>134
+ {formatDay(event.timestamp_ms)} {formatClock(event.timestamp_ms)}135
+ </time>136
+ {#if event.content.kind === 'Message' && event.content.message.edited}137
+ <span class="edited" title="Edited">(edited)</span>138
+ {/if}139
+ </div>140
+ {/if}141
+ {#if event.reply_to}142
+ <div class="reply-quote">143
+ {#if event.reply_to.details.kind === 'Ready'}144
+ Replying to {senderLabel(event.reply_to.details.sender)}: {replyPreview(event.reply_to.details.content)}145
+ {:else if event.reply_to.details.kind === 'Pending'}146
+ Loading reply…147
+ {:else}148
+ Reply unavailable149
+ {/if}150
+ </div>151
+ {/if}152
+153
+ {#if event.content.kind === 'Message'}154
+ {@const m = event.content.message}155
+ <p class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>156
+ {#if m.kind === 'Emote'}* {displayName} {/if}{m.body}157
+ </p>158
+ {#if m.media}159
+ <MediaAttachmentView attachment={m.media} />160
+ {/if}161
+ {#if previews.length > 0}162
+ <div class="link-previews">163
+ {#each previews as preview (preview.href)}164
+ <a class="link-card" href={preview.href} target="_blank" rel="noopener noreferrer">165
+ <span class="link-host">{preview.hostname}</span>166
+ {#if preview.path}<span class="link-path">{preview.path}</span>{/if}167
+ <span class="link-url">{preview.display}</span>168
+ </a>169
+ {/each}170
+ </div>171
+ {/if}172
+ {:else if event.content.kind === 'Sticker'}173
+ {#if event.content.body}<p class="text">{event.content.body}</p>{/if}174
+ {#if event.content.media}175
+ <MediaAttachmentView attachment={event.content.media} />176
+ {/if}177
+ {:else if event.content.kind === 'Poll'}178
+ <p class="text">📊 {event.content.question}</p>179
+ {:else}180
+ <p class="system-line">{systemLine(event.content)}</p>181
+ {/if}182
+183
+ {#if interactive && event.reactions.length > 0}184
+ <div class="reactions" aria-label="Reactions">185
+ {#each event.reactions as r (r.key)}186
+ <button187
+ type="button"188
+ class="reaction"189
+ class:mine={r.own}190
+ title={r.own ? 'You reacted — activate to remove' : `${r.count} reaction${r.count === 1 ? '' : 's'}`}191
+ disabled={!event.event_id || reacting}192
+ on:click={() => event.event_id && onReact(event.event_id, r.key)}193
+ >194
+ <span aria-hidden="true">{r.key}</span>195
+ <span class="count">{r.count}</span>196
+ </button>197
+ {/each}198
+ </div>199
+ {/if}200
+ {#if interactive}201
+ <div class="msg-foot">202
+ {#if sendLabel(event.send_state)}203
+ <span class="sendstate" class:failed={event.send_state.kind === 'Failed'}>{sendLabel(event.send_state)}</span>204
+ {/if}205
+ {#if event.read_by.length > 0}206
+ <span class="readby" title={event.read_by.join(', ')}>✓✓ {event.read_by.length}</span>207
+ {/if}208
+ {#if event.event_id}209
+ <span class="quick-react" role="group" aria-label={`React to message from ${displayName}`}>210
+ {#each QUICK_KEYS as key (key)}211
+ <button212
+ type="button"213
+ title={`React ${key}`}214
+ aria-label={`React ${key} to message from ${displayName}`}215
+ disabled={reacting}216
+ on:click={() => onReact(event.event_id ?? '', key)}217
+ >218
+ {key}219
+ </button>220
+ {/each}221
+ </span>222
+ {/if}223
+ </div>224
+ {/if}225
+ </div>226
+</div>227
+228
+<style>229
+ .msg { display: flex; gap: 10px; padding: 3px 0; }230
+ .msg:hover { background: rgba(255, 255, 255, 0.018); }231
+ .msg:hover .hover-time { opacity: 1; }232
+ .msg-foot { display: flex; align-items: center; gap: 8px; min-height: 18px; }233
+ .sendstate { font-size: 11px; color: var(--muted); }234
+ .sendstate.failed { color: var(--danger); }235
+ .link-previews { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; max-width: 420px; }236
+ .link-card {237
+ display: flex; flex-direction: column; gap: 1px;238
+ border: 1px solid var(--border); border-radius: 8px;239
+ background: var(--bg-soft); padding: 8px 10px;240
+ text-decoration: none; color: var(--text);241
+ }242
+ .link-card:hover { border-color: var(--accent); }243
+ .link-host { font-size: 12.5px; font-weight: 650; }244
+ .link-path { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }245
+ .link-url { font-size: 11.5px; color: var(--accent); overflow-wrap: anywhere; }246
+ .avatar {247
+ flex: none;248
+ width: 34px; height: 34px;249
+ border-radius: 50%;250
+ display: grid; place-items: center;251
+ font-size: 12px; font-weight: 700; color: #fff;252
+ }253
+ .avatar-spacer { flex: none; width: 34px; padding-top: 2px; text-align: center; }254
+ .hover-time { opacity: 0; font-size: 10.5px; color: var(--muted); }255
+ .msg-body { min-width: 0; flex: 1; }256
+ .msg-head { display: flex; align-items: baseline; gap: 8px; }257
+ .sender { font-weight: 650; font-size: 13.5px; }258
+ .time, .edited { color: var(--muted); font-size: 11.5px; }259
+ .reply-quote {260
+ border-left: 2px solid var(--accent);261
+ padding: 2px 8px;262
+ margin: 2px 0 4px;263
+ color: var(--muted);264
+ font-size: 12.5px;265
+ background: var(--bg-soft);266
+ border-radius: 0 6px 6px 0;267
+ }268
+ .text {269
+ margin: 1px 0 2px;270
+ font-size: 14px;271
+ line-height: 1.5;272
+ white-space: pre-wrap;273
+ overflow-wrap: anywhere;274
+ }275
+ .text.emote { font-style: italic; color: var(--muted); }276
+ .text.notice { color: var(--muted); }277
+ .system-line { color: var(--muted); font-size: 12.5px; font-style: italic; margin: 1px 0; }278
+ .msg.system { opacity: 0.85; }279
+ .reactions { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }280
+ .reaction {281
+ display: inline-flex; align-items: center; gap: 4px;282
+ background: var(--bg-soft);283
+ border: 1px solid var(--border);284
+ border-radius: 999px;285
+ padding: 1px 8px;286
+ font-size: 13px;287
+ color: var(--text);288
+ cursor: pointer;289
+ }290
+ .reaction.mine { border-color: var(--accent); background: rgba(88, 101, 242, 0.16); }291
+ .reaction:disabled { cursor: default; opacity: 0.85; }292
+ .msg-foot { display: flex; align-items: center; gap: 8px; min-height: 18px; }293
+ .readby { font-size: 11px; color: var(--muted); }294
+ .msg:hover .quick-react, .msg:focus-within .quick-react { display: inline-flex; }295
+ .quick-react button {296
+ background: transparent;297
+ border: 1px solid transparent;298
+ border-radius: 6px;299
+ cursor: pointer;300
+ font-size: 13px;301
+ padding: 0 3px;302
+ opacity: 0.75;303
+ }304
+ .quick-react button:hover:not(:disabled) { opacity: 1; border-color: var(--border); background: var(--bg-soft); }305
+ .quick-react button:disabled { opacity: 0.4; cursor: default; }306
+</style>@@ -0,0 +1,56 @@
1
+<script lang="ts">2
+ import { mediaUrl } from './api';3
+ import { initialsOf } from './format';4
+5
+ export let mxc: string | null;6
+ export let label: string;7
+ export let size = 34;8
+ /** 'circle' for people, 'rounded' for rooms/spaces */9
+ export let shape: 'circle' | 'rounded' = 'circle';10
+11
+ $: src = mediaUrl(mxc);12
+13
+ let failed = false;14
+ $: if (mxc) failed = false;15
+ $: px = `${size}px`;16
+ $: fontPx = `${Math.max(10, Math.round(size * 0.36))}px`;17
+</script>18
+19
+{#if src && !failed}20
+ <img21
+ {src}22
+ alt=""23
+ aria-hidden="true"24
+ width={size}25
+ height={size}26
+ loading="lazy"27
+ class="mxc-avatar"28
+ class:circle={shape === 'circle'}29
+ style={`width:${px};height:${px};`}30
+ on:error={() => (failed = true)}31
+ />32
+{:else}33
+ <span class="mxc-fallback" class:circle={shape === 'circle'} aria-hidden="true" style={`width:${px};height:${px};font-size:${fontPx};`}>34
+ {initialsOf(label || '#')}35
+ </span>36
+{/if}37
+38
+<style>39
+ .mxc-avatar {40
+ flex: none;41
+ object-fit: cover;42
+ border-radius: 8px;43
+ background: var(--bg-soft);44
+ }45
+ .mxc-avatar.circle { border-radius: 50%; }46
+ .mxc-fallback {47
+ flex: none;48
+ display: inline-grid;49
+ place-items: center;50
+ border-radius: 8px;51
+ background: var(--panel-2);52
+ color: var(--text);53
+ font-weight: 700;54
+ }55
+ .mxc-fallback.circle { border-radius: 50%; }56
+</style>@@ -0,0 +1,223 @@
1
+<script lang="ts">2
+ import { createEventDispatcher } from 'svelte';3
+ import {4
+ recoverEncryption,5
+ startVerification,6
+ verifyEncryption,7
+ type VerifyAction8
+ } from './api';9
+ import { pushBanner } from './store';10
+ import type { EncryptionState, VerificationState } from './types';11
+12
+ const dispatch = createEventDispatcher<{ close: void }>();13
+14
+ export let encryptionState: EncryptionState | null;15
+ export let verificationState: VerificationState | null;16
+ export let onAction: () => void = () => undefined;17
+18
+ let recoveryKey = '';19
+ let busy: VerifyAction | 'recover' | 'start' | null = null;20
+21
+ $: recoveryNeeded = encryptionState !== null && encryptionState.recovery !== 'Enabled';22
+23
+ function verificationTitle(state: VerificationState): string {24
+ if (state.kind === 'Waiting') return 'Verification started — waiting for the other device.';25
+ if (state.kind === 'Incoming') return 'Another device wants to verify this session.';26
+ if (state.kind === 'Comparison') return 'Compare the emojis and numbers with the other device.';27
+ if (state.kind === 'Confirmed') return 'Codes confirmed — finishing verification…';28
+ return 'Verification';29
+ }30
+31
+ async function runAction(action: VerifyAction, options?: { silentDone?: boolean }): Promise<void> {32
+ if (busy) return;33
+ busy = action;34
+ try {35
+ await verifyEncryption(action);36
+ onAction();37
+ if (!options?.silentDone && (action === 'Confirm' || action === 'Accept')) {38
+ pushBanner('Confirmation sent — waiting for the verification result.');39
+ }40
+ } catch (e) {41
+ pushBanner(e instanceof Error ? e.message : 'Verification action failed');42
+ } finally {43
+ busy = null;44
+ }45
+ }46
+47
+ async function recover(): Promise<void> {48
+ const key = recoveryKey.trim();49
+ if (!key || busy) return;50
+ busy = 'recover';51
+ try {52
+ await recoverEncryption(key);53
+ onAction();54
+ pushBanner('Recovery key sent — waiting for the server to confirm recovery.');55
+ recoveryKey = '';56
+ } catch (e) {57
+ pushBanner(e instanceof Error ? e.message : 'Recovery failed');58
+ } finally {59
+ busy = null;60
+ }61
+ }62
+63
+ async function start(): Promise<void> {64
+ if (busy) return;65
+ busy = 'start';66
+ try {67
+ await startVerification();68
+ onAction();69
+ } catch (e) {70
+ pushBanner(e instanceof Error ? e.message : 'Could not start verification');71
+ } finally {72
+ busy = null;73
+ }74
+ }75
+</script>76
+77
+<div class="modal" role="dialog" aria-modal="true" aria-label="Encryption and verification">78
+ <div class="modal-tabs" role="tablist" aria-label="Security">79
+ <button type="button" role="tab" aria-selected="true" class="active">Security</button>80
+ <button type="button" class="icon-btn modal-x" aria-label="Close security dialog" on:click={() => dispatch('close')}>81
+ <span aria-hidden="true">✕</span>82
+ </button>83
+ </div>84
+85
+ <section aria-label="Device status">86
+ <h3 class="sec-h">This device</h3>87
+ {#if encryptionState}88
+ <p class="sec-line">89
+ Device <code>{encryptionState.device_id}</code>90
+ {#if encryptionState.device_verified}91
+ <span class="pill ok">verified</span>92
+ {:else}93
+ <span class="pill warn">not verified</span>94
+ {/if}95
+ </p>96
+ <p class="sec-line muted">97
+ Recovery: {encryptionState.recovery} · Key backup: {encryptionState.key_backup}98
+ </p>99
+ {:else}100
+ <p class="sec-line muted">Encryption status not loaded yet — it arrives with the next sync.</p>101
+ {/if}102
+ </section>103
+104
+ <section aria-label="Recovery">105
+ <h3 class="sec-h">Recovery</h3>106
+ {#if recoveryNeeded}107
+ <p class="sec-line muted">Enter your recovery key to unlock message history on this device.</p>108
+ {:else}109
+ <p class="sec-line muted">Recovery looks enabled. Re-entering the key is only needed after a reset.</p>110
+ {/if}111
+ <form on:submit|preventDefault={recover}>112
+ <label class="field">113
+ <span>Recovery key</span>114
+ <input115
+ type="password"116
+ autocomplete="off"117
+ spellcheck="false"118
+ placeholder="EsT4 …"119
+ bind:value={recoveryKey}120
+ disabled={busy !== null}121
+ />122
+ </label>123
+ <button type="submit" class="primary-btn wide" disabled={busy !== null || !recoveryKey.trim()}>124
+ {busy === 'recover' ? 'Sending…' : 'Recover with key'}125
+ </button>126
+ </form>127
+ <p class="sec-note">Success is confirmed when the status above changes.</p>128
+ </section>129
+130
+ <section aria-label="Verification">131
+ <h3 class="sec-h">Verification</h3>132
+ {#if verificationState}133
+ <p class="sec-line"><strong>{verificationTitle(verificationState)}</strong></p>134
+ {#if verificationState.kind === 'Incoming'}135
+ <p class="sec-line muted">136
+ {verificationState.user_id ?? 'Another session'} wants to verify this device.137
+ </p>138
+ {#if verificationState.device_id}139
+ <p class="sec-line muted">Device: <code>{verificationState.device_id}</code></p>140
+ {/if}141
+ <div class="row">142
+ <button type="button" class="primary-btn" disabled={busy !== null} on:click={() => void runAction('Accept')}>143
+ {busy === 'Accept' ? 'Accepting…' : 'Accept request'}144
+ </button>145
+ <button type="button" class="ghost-btn danger" disabled={busy !== null} on:click={() => void runAction('Cancel')}>146
+ {busy === 'Cancel' ? 'Cancelling…' : 'Cancel'}147
+ </button>148
+ </div>149
+ {:else if verificationState.kind === 'Waiting'}150
+ <p class="sec-line muted">Approve the prompt on the other device, then compare codes here.</p>151
+ <div class="row">152
+ <button type="button" class="ghost-btn danger" disabled={busy !== null} on:click={() => void runAction('Cancel')}>153
+ {busy === 'Cancel' ? 'Cancelling…' : 'Cancel verification'}154
+ </button>155
+ </div>156
+ {:else if verificationState.kind === 'Comparison'}157
+ <ol class="emoji-grid" aria-label="Verification emojis">158
+ {#each verificationState.emojis as item (item.name + item.emoji)}159
+ <li><span class="emoji" aria-hidden="true">{item.emoji}</span><span class="emoji-name">{item.name}</span></li>160
+ {/each}161
+ </ol>162
+ <p class="sec-line decimals" aria-label="Verification numbers">163
+ {verificationState.decimals[0]} – {verificationState.decimals[1]} – {verificationState.decimals[2]}164
+ </p>165
+ <div class="row">166
+ <button type="button" class="primary-btn" disabled={busy !== null} on:click={() => void runAction('Confirm')}>167
+ {busy === 'Confirm' ? 'Confirming…' : 'They match'}168
+ </button>169
+ <button type="button" class="ghost-btn danger" disabled={busy !== null} on:click={() => void runAction('Reject')}>170
+ {busy === 'Reject' ? 'Rejecting…' : "They don't match"}171
+ </button>172
+ </div>173
+ {:else if verificationState.kind === 'Confirmed'}174
+ <p class="sec-line muted">Confirmed — waiting for the other side to finish.</p>175
+ {:else if verificationState.kind === 'Done'}176
+ <p class="sec-line"><span class="pill ok">verified</span></p>177
+ <div class="row">178
+ <button type="button" class="ghost-btn" on:click={start} disabled={busy !== null}>Verify again</button>179
+ </div>180
+ {:else if verificationState.kind === 'Cancelled'}181
+ <p class="sec-line muted">Cancelled: {verificationState.reason}</p>182
+ <div class="row">183
+ <button type="button" class="ghost-btn" on:click={start} disabled={busy !== null}>Start again</button>184
+ </div>185
+ {:else if verificationState.kind === 'Failed'}186
+ <p class="sec-line muted">Failed: {verificationState.message}</p>187
+ <div class="row">188
+ <button type="button" class="ghost-btn" on:click={start} disabled={busy !== null}>Try again</button>189
+ </div>190
+ {/if}191
+ {:else}192
+ <p class="sec-line muted">No active verification. Start one to prove this device to your other sessions.</p>193
+ <div class="row">194
+ <button type="button" class="primary-btn" on:click={start} disabled={busy !== null}>195
+ {busy === 'start' ? 'Starting…' : 'Start verification'}196
+ </button>197
+ </div>198
+ {/if}199
+ </section>200
+</div>201
+202
+<style>203
+ .sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 16px 0 6px; }204
+ .sec-h:first-child { margin-top: 0; }205
+ .sec-line { font-size: 13px; margin: 0 0 8px; overflow-wrap: anywhere; }206
+ .sec-line.muted, .sec-note { color: var(--muted); font-size: 12.5px; }207
+ .sec-note { margin: 8px 0 0; }208
+ .pill.ok { background: rgba(63, 185, 127, 0.18); border-color: #3fb97f; color: #9fe3c3; }209
+ .pill.warn { background: rgba(232, 178, 90, 0.15); border-color: var(--warn); color: var(--warn); }210
+ code { font-size: 12px; background: var(--bg-soft); padding: 1px 6px; border-radius: 6px; }211
+ .row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }212
+ .emoji-grid {213
+ list-style: none; margin: 8px 0; padding: 0;214
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 6px;215
+ }216
+ .emoji-grid li {217
+ display: flex; flex-direction: column; align-items: center; gap: 2px;218
+ background: var(--bg-soft); border: 1px solid var(--border); border-radius: 8px; padding: 8px 4px;219
+ }220
+ .emoji { font-size: 24px; }221
+ .emoji-name { font-size: 11px; color: var(--muted); text-align: center; }222
+ .decimals { font-size: 17px; font-weight: 700; letter-spacing: 0.06em; }223
+</style>@@ -0,0 +1,97 @@
1
+// Backend-agnostic API surface. Every call forwards to whichever transport2
+// this build was compiled for, hosted (fetch + SSE) or on-device (worker).3
+import type { MemberSummary, RoomSummary } from './types';4
+import { HttpTransport } from './transport-http';5
+import type { SessionInfo, Transport, VerifyAction } from './transport';6
+import { createDeviceTransport } from 'malice:device-transport';7
+8
+export { ApiError } from './transport';9
+export type { SessionInfo, SoftLogout, VerifyAction } from './transport';10
+11
+function createTransport(): Transport {12
+ if (import.meta.env.VITE_MALICE_TARGET === 'device') return createDeviceTransport();13
+ return new HttpTransport();14
+}15
+16
+const transport: Transport = createTransport();17
+18
+export function getSession(): Promise<SessionInfo> {19
+ return transport.getSession();20
+}21
+22
+export function login(homeserver: string, username: string, password: string): Promise<{ user_id: string }> {23
+ return transport.login(homeserver, username, password);24
+}25
+26
+export function logout(): Promise<void> {27
+ return transport.logout();28
+}29
+30
+export function getRooms(): Promise<{ rooms: RoomSummary[] }> {31
+ return transport.getRooms();32
+}33
+34
+export const openRoom = (room_id: string) => transport.openRoom(room_id);35
+export const closeRoom = (room_id: string) => transport.closeRoom(room_id);36
+export const sendMessage = (room_id: string, body: string) => transport.sendMessage(room_id, body);37
+export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);38
+export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);39
+40
+export function joinRoom(room: string): Promise<{ room_id: string }> {41
+ return transport.joinRoom(room);42
+}43
+44
+export function createRoom(name: string, topic?: string): Promise<{ room_id: string }> {45
+ return transport.createRoom(name, topic);46
+}47
+48
+export const leaveRoom = (room_id: string) => transport.leaveRoom(room_id);49
+export const reactToMessage = (room_id: string, event_id: string, key: string) =>50
+ transport.reactToMessage(room_id, event_id, key);51
+export const markRead = (room_id: string, event_id: string) => transport.markRead(room_id, event_id);52
+53
+export function getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {54
+ return transport.getMembers(room_id);55
+}56
+57
+/** Same URL shape in both builds: the hosted server and the device service worker both answer it. */58
+export function mediaUrl(mxc: string | null): string | null {59
+ if (!mxc) return null;60
+ return `/api/media?mxc=${encodeURIComponent(mxc)}`;61
+}62
+63
+export function messageMediaUrl(mediaId: string, size: 'Thumbnail' | 'Full'): string {64
+ return `/api/message-media?media_id=${encodeURIComponent(mediaId)}&size=${size}`;65
+}66
+67
+export function getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }> {68
+ return transport.getProfile();69
+}70
+71
+export function getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }> {72
+ return transport.getSpaceChildren(room_id);73
+}74
+75
+export function getEncryption(): Promise<{ state: unknown; verification: unknown }> {76
+ return transport.getEncryption();77
+}78
+79
+export function recoverEncryption(recovery_key: string): Promise<void> {80
+ return transport.recoverEncryption(recovery_key);81
+}82
+83
+export function startVerification(): Promise<void> {84
+ return transport.startVerification();85
+}86
+87
+export function verifyEncryption(action: VerifyAction): Promise<void> {88
+ return transport.verifyEncryption(action);89
+}90
+91
+export function uploadAttachment(room_id: string, file: File): Promise<void> {92
+ return transport.uploadAttachment(room_id, file);93
+}94
+95
+export function subscribeEvents(onEvent: (data: string) => void, onDisconnect: () => void): () => void {96
+ return transport.subscribeEvents(onEvent, onDisconnect);97
+}@@ -0,0 +1,8 @@
1
+import type { Transport } from './transport';2
+3
+// vite.config.ts aliases 'malice:device-transport' to this file for hosted4
+// builds, so transport-worker.ts (and its SharedWorker/wasm import chain)5
+// never enters the hosted module graph at all, not even as dead code.6
+export function createDeviceTransport(): Transport {7
+ throw new Error('the device transport is unavailable in this build');8
+}@@ -0,0 +1,6 @@
1
+import { WorkerTransport } from './transport-worker';2
+import type { Transport } from './transport';3
+4
+export function createDeviceTransport(): Transport {5
+ return new WorkerTransport();6
+}@@ -0,0 +1,40 @@
1
+export function formatClock(timestampMs: number): string {2
+ if (!timestampMs) return '';3
+ const d = new Date(timestampMs);4
+ const h = d.getHours().toString().padStart(2, '0');5
+ const m = d.getMinutes().toString().padStart(2, '0');6
+ return `${h}:${m}`;7
+}8
+9
+export function formatDay(timestampMs: number): string {10
+ if (!timestampMs) return 'Unknown date';11
+ const d = new Date(timestampMs);12
+ const today = new Date();13
+ const yesterday = new Date(today);14
+ yesterday.setDate(today.getDate() - 1);15
+ const sameDay = (a: Date, b: Date) =>16
+ a.getFullYear() === b.getFullYear() &&17
+ a.getMonth() === b.getMonth() &&18
+ a.getDate() === b.getDate();19
+ if (sameDay(d, today)) return 'Today';20
+ if (sameDay(d, yesterday)) return 'Yesterday';21
+ return d.toLocaleDateString(undefined, {22
+ weekday: 'short',23
+ year: 'numeric',24
+ month: 'short',25
+ day: 'numeric'26
+ });27
+}28
+29
+export function initialsOf(name: string): string {30
+ const clean = name.replace(/^[@#!]/, '').split(/[:\s._-]+/).filter(Boolean);31
+ const first = clean[0]?.[0] ?? '#';32
+ const second = clean.length > 1 ? (clean[clean.length - 1][0] ?? '') : '';33
+ return (first + second).toUpperCase();34
+}35
+36
+export function senderHue(senderId: string): number {37
+ let h = 0;38
+ for (let i = 0; i < senderId.length; i += 1) h = (h * 31 + senderId.charCodeAt(i)) % 360;39
+ return h;40
+}@@ -0,0 +1,3 @@
1
+export function isRecord(value: unknown): value is Record<string, unknown> {2
+ return typeof value === 'object' && value !== null;3
+}@@ -0,0 +1,4 @@
1
+declare module 'malice:device-transport' {2
+ import type { Transport } from './transport';3
+ export function createDeviceTransport(): Transport;4
+}@@ -0,0 +1,87 @@
1
+// AES-256-CTR range decryption for Matrix attachments (the only cipher Matrix2
+// has, per vendor/macltok's AttachmentEncryption). WebCrypto's AES-CTR handles3
+// a partial final block within one call but not a resumed one, so a block that4
+// isn't a multiple of 16 bytes has to wait for more ciphertext before it can be5
+// decrypted; see RangeDecryptor.push.6
+7
+const BLOCK_SIZE = 16;8
+9
+function decodeBase64(value: string): Uint8Array {10
+ const normalised = value.replace(/-/g, '+').replace(/_/g, '/');11
+ const padded = normalised + '='.repeat((4 - (normalised.length % 4)) % 4);12
+ const binary = atob(padded);13
+ const bytes = new Uint8Array(binary.length);14
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);15
+ return bytes;16
+}17
+18
+export async function importAttachmentKey(k: string): Promise<CryptoKey> {19
+ return crypto.subtle.importKey('raw', decodeBase64(k), { name: 'AES-CTR' }, false, ['decrypt']);20
+}21
+22
+export function decodeAttachmentIv(iv: string): Uint8Array {23
+ const bytes = decodeBase64(iv);24
+ if (bytes.length >= BLOCK_SIZE) return bytes.subarray(0, BLOCK_SIZE);25
+ const padded = new Uint8Array(BLOCK_SIZE);26
+ padded.set(bytes);27
+ return padded;28
+}29
+30
+/** iv + blocks, added as a big-endian 64-bit value into the low 8 bytes only, per the Matrix attachment convention. */31
+export function addBlockOffset(iv: Uint8Array, blocks: number): Uint8Array {32
+ const counter = iv.slice();33
+ const view = new DataView(counter.buffer, counter.byteOffset, counter.byteLength);34
+ const low = view.getBigUint64(8);35
+ view.setBigUint64(8, (low + BigInt(blocks)) & 0xffffffffffffffffn);36
+ return counter;37
+}38
+39
+function concat(a: Uint8Array, b: Uint8Array): Uint8Array {40
+ if (a.length === 0) return b;41
+ if (b.length === 0) return a;42
+ const out = new Uint8Array(a.length + b.length);43
+ out.set(a, 0);44
+ out.set(b, a.length);45
+ return out;46
+}47
+48
+/**49
+ * Decrypts a ciphertext range that starts `startBlock` 16-byte blocks into the50
+ * attachment. Feed it ciphertext in arrival order via `push`, then `finish` once51
+ * the source is exhausted to flush the trailing partial block.52
+ */53
+export class RangeDecryptor {54
+ private buffered = new Uint8Array(0);55
+ private blocksConsumed = 0;56
+57
+ constructor(58
+ private readonly key: CryptoKey,59
+ private readonly iv: Uint8Array,60
+ private readonly startBlock: number61
+ ) {}62
+63
+ async push(chunk: Uint8Array): Promise<Uint8Array> {64
+ const data = concat(this.buffered, chunk);65
+ const wholeLen = data.length - (data.length % BLOCK_SIZE);66
+ if (wholeLen === 0) {67
+ this.buffered = data;68
+ return new Uint8Array(0);69
+ }70
+ this.buffered = data.subarray(wholeLen);71
+ return this.decrypt(data.subarray(0, wholeLen));72
+ }73
+74
+ async finish(): Promise<Uint8Array> {75
+ if (this.buffered.length === 0) return new Uint8Array(0);76
+ const plain = await this.decrypt(this.buffered);77
+ this.buffered = new Uint8Array(0);78
+ return plain;79
+ }80
+81
+ private async decrypt(ciphertext: Uint8Array): Promise<Uint8Array> {82
+ const counter = addBlockOffset(this.iv, this.startBlock + this.blocksConsumed);83
+ this.blocksConsumed += Math.ceil(ciphertext.length / BLOCK_SIZE);84
+ const plain = await crypto.subtle.decrypt({ name: 'AES-CTR', counter, length: 64 }, this.key, ciphertext);85
+ return new Uint8Array(plain);86
+ }87
+}@@ -0,0 +1,51 @@
1
+// Ported from crates/malice-core/src/media.rs's attachment_content_type, so the2
+// service worker can sniff decrypted bytes without a round trip to the wasm host.3
+4
+function startsWith(bytes: Uint8Array, prefix: number[]): boolean {5
+ if (bytes.length < prefix.length) return false;6
+ for (let i = 0; i < prefix.length; i++) {7
+ if (bytes[i] !== prefix[i]) return false;8
+ }9
+ return true;10
+}11
+12
+function ascii(text: string): number[] {13
+ return Array.from(text, (ch) => ch.charCodeAt(0));14
+}15
+16
+function bytesEqual(bytes: Uint8Array, offset: number, text: string): boolean {17
+ const wanted = ascii(text);18
+ if (bytes.length < offset + wanted.length) return false;19
+ for (let i = 0; i < wanted.length; i++) {20
+ if (bytes[offset + i] !== wanted[i]) return false;21
+ }22
+ return true;23
+}24
+25
+export function imageContentType(bytes: Uint8Array): string | null {26
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png';27
+ if (startsWith(bytes, [0xff, 0xd8, 0xff])) return 'image/jpeg';28
+ if (bytesEqual(bytes, 0, 'GIF87a') || bytesEqual(bytes, 0, 'GIF89a')) return 'image/gif';29
+ if (bytes.length >= 12 && bytesEqual(bytes, 0, 'RIFF') && bytesEqual(bytes, 8, 'WEBP')) return 'image/webp';30
+ if (bytes.length >= 12 && bytesEqual(bytes, 4, 'ftyp') && (bytesEqual(bytes, 8, 'avif') || bytesEqual(bytes, 8, 'avis'))) {31
+ return 'image/avif';32
+ }33
+ return null;34
+}35
+36
+export function attachmentContentType(bytes: Uint8Array): string | null {37
+ const image = imageContentType(bytes);38
+ if (image) return image;39
+ if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return 'video/webm';40
+ if (bytes.length >= 12 && bytesEqual(bytes, 0, 'RIFF') && bytesEqual(bytes, 8, 'WAVE')) return 'audio/wav';41
+ if (bytesEqual(bytes, 0, 'OggS')) return 'audio/ogg';42
+ if (bytesEqual(bytes, 0, 'ID3') || startsWith(bytes, [0xff, 0xfb])) return 'audio/mpeg';43
+ if (44
+ bytes.length >= 12 &&45
+ bytesEqual(bytes, 4, 'ftyp') &&46
+ ['isom', 'iso2', 'mp41', 'mp42', 'avc1'].some((brand) => bytesEqual(bytes, 8, brand))47
+ ) {48
+ return 'video/mp4';49
+ }50
+ return null;51
+}@@ -0,0 +1,145 @@
1
+import { get, writable } from 'svelte/store';2
+import { sendMessage } from './api';3
+import { timelines } from './store';4
+import type { TimelineItem } from './types';5
+6
+export interface PendingMessage {7
+ id: string;8
+ body: string;9
+ status: 'sending' | 'failed';10
+ error: string | null;11
+ createdAt: number;12
+}13
+14
+export const pendingByRoom = writable<Record<string, PendingMessage[]>>({});15
+16
+interface PendingRecord extends PendingMessage {17
+ roomId: string;18
+ knownIds: Set<string>;19
+}20
+21
+const records = new Map<string, PendingRecord>();22
+const queues = new Map<string, Promise<void>>();23
+let nextSeq = 1;24
+25
+function nextId(): string {26
+ return `pending-${nextSeq++}`;27
+}28
+29
+function ownIdentities(items: TimelineItem[]): Set<string> {30
+ const out = new Set<string>();31
+ for (const item of items) {32
+ if (item.kind !== 'event' || !item.event.own) continue;33
+ const id = item.event.transaction_id ?? item.event.event_id;34
+ if (id) out.add(id);35
+ }36
+ return out;37
+}38
+39
+function toPublic(record: PendingRecord): PendingMessage {40
+ return { id: record.id, body: record.body, status: record.status, error: record.error, createdAt: record.createdAt };41
+}42
+43
+function roomList(roomId: string): PendingMessage[] {44
+ return get(pendingByRoom)[roomId] ?? [];45
+}46
+47
+function setRoom(roomId: string, list: PendingMessage[]): void {48
+ pendingByRoom.update((all) => ({ ...all, [roomId]: list }));49
+}50
+51
+function addRecord(record: PendingRecord): void {52
+ records.set(record.id, record);53
+ setRoom(record.roomId, [...roomList(record.roomId), toPublic(record)]);54
+}55
+56
+function updateRecord(id: string, patch: Partial<Pick<PendingRecord, 'status' | 'error'>>): void {57
+ const record = records.get(id);58
+ if (!record) return;59
+ Object.assign(record, patch);60
+ setRoom(record.roomId, roomList(record.roomId).map((p) => (p.id === id ? toPublic(record) : p)));61
+}62
+63
+function removeRecord(id: string): void {64
+ const record = records.get(id);65
+ if (!record) return;66
+ records.delete(id);67
+ setRoom(record.roomId, roomList(record.roomId).filter((p) => p.id !== id));68
+}69
+70
+async function attemptSend(record: PendingRecord): Promise<void> {71
+ if (!records.has(record.id)) return;72
+ updateRecord(record.id, { status: 'sending', error: null });73
+ try {74
+ await sendMessage(record.roomId, record.body);75
+ } catch (e) {76
+ updateRecord(record.id, { status: 'failed', error: e instanceof Error ? e.message : 'Message could not be sent' });77
+ }78
+}79
+80
+function enqueue(record: PendingRecord): void {81
+ const previous = queues.get(record.roomId) ?? Promise.resolve();82
+ const next = previous.then(() => attemptSend(record));83
+ queues.set(record.roomId, next.catch(() => undefined));84
+}85
+86
+export function queueSend(roomId: string, body: string): void {87
+ const record: PendingRecord = {88
+ id: nextId(),89
+ roomId,90
+ body,91
+ status: 'sending',92
+ error: null,93
+ createdAt: Date.now(),94
+ knownIds: ownIdentities(get(timelines)[roomId] ?? [])95
+ };96
+ addRecord(record);97
+ enqueue(record);98
+}99
+100
+export function retryPending(id: string): void {101
+ const record = records.get(id);102
+ if (!record) return;103
+ record.knownIds = ownIdentities(get(timelines)[record.roomId] ?? []);104
+ enqueue(record);105
+}106
+107
+export function discardPending(id: string): void {108
+ removeRecord(id);109
+}110
+111
+export function resetPending(): void {112
+ records.clear();113
+ queues.clear();114
+ pendingByRoom.set({});115
+}116
+117
+function reconcileRoom(roomId: string, items: TimelineItem[]): void {118
+ const list = roomList(roomId);119
+ if (list.length === 0) return;120
+ const ownItems = items.filter((item) => item.kind === 'event' && item.event.own) as Extract<121
+ TimelineItem,122
+ { kind: 'event' }123
+ >[];124
+ const claimed = new Set<string>();125
+ for (const pending of list) {126
+ const record = records.get(pending.id);127
+ if (!record) continue;128
+ const match = ownItems.find((item) => {129
+ if (claimed.has(item.event.item_id)) return false;130
+ const id = item.event.transaction_id ?? item.event.event_id;131
+ if (id && record.knownIds.has(id)) return false;132
+ return item.event.content.kind === 'Message' && item.event.content.message.body === record.body;133
+ });134
+ if (match) {135
+ claimed.add(match.event.item_id);136
+ removeRecord(record.id);137
+ }138
+ }139
+}140
+141
+timelines.subscribe((all) => {142
+ for (const roomId of Object.keys(get(pendingByRoom))) {143
+ reconcileRoom(roomId, all[roomId] ?? []);144
+ }145
+});@@ -0,0 +1,177 @@
1
+import { writable } from 'svelte/store';2
+import { getSession, subscribeEvents } from './api';3
+import type { SoftLogout } from './api';4
+import { isRecord } from './guards';5
+import {6
+ parseEncryptionState,7
+ parseRoomList,8
+ parseTimelineList,9
+ parseVerificationState10
+} from './types';11
+import type {12
+ EncryptionState,13
+ MemberSummary,14
+ RoomSummary,15
+ TimelineItem,16
+ VerificationState17
+} from './types';18
+19
+export const sessionUser = writable<string | null | undefined>(undefined);20
+export const softLogout = writable<SoftLogout | null>(null);21
+/** Device build only: this tab lost the store lock to another tab that has malice open. */22
+export const blocked = writable<boolean>(false);23
+export const rooms = writable<RoomSummary[]>([]);24
+export const timelines = writable<Record<string, TimelineItem[]>>({});25
+export const connection = writable<string>('connecting');26
+export const selectedRoomId = writable<string | null>(null);27
+export const membersByRoom = writable<Record<string, MemberSummary[]>>({});28
+export const membersLoading = writable<boolean>(false);29
+export const encryption = writable<EncryptionState | null>(null);30
+export const verification = writable<VerificationState | null>(null);31
+32
+export interface Banner {33
+ id: number;34
+ message: string;35
+}36
+37
+export const banners = writable<Banner[]>([]);38
+let nextBannerId = 1;39
+40
+export function pushBanner(message: string): void {41
+ const id = nextBannerId++;42
+ banners.update((list) => [...list.slice(-4), { id, message }]);43
+}44
+45
+export function dismissBanner(id: number): void {46
+ banners.update((list) => list.filter((b) => b.id !== id));47
+}48
+49
+export function resetClientState(): void {50
+ rooms.set([]);51
+ timelines.set({});52
+ connection.set('connecting');53
+ selectedRoomId.set(null);54
+ membersByRoom.set({});55
+ membersLoading.set(false);56
+ encryption.set(null);57
+ verification.set(null);58
+ banners.set([]);59
+}60
+61
+function applyTimelinePayload(roomId: string, payload: Record<string, unknown>): void {62
+ if (!Array.isArray(payload['items'])) return;63
+ const items = parseTimelineList(payload['items']);64
+ timelines.update((all) => ({ ...all, [roomId]: items }));65
+}66
+67
+function setRooms(raw: unknown): void {68
+ rooms.set(parseRoomList(raw));69
+}70
+71
+function handlePayload(text: string): void {72
+ let data: unknown;73
+ try {74
+ data = JSON.parse(text);75
+ } catch {76
+ return;77
+ }78
+ if (!isRecord(data)) return;79
+ const kind = data['type'];80
+ if (kind === 'snapshot') {81
+ setRooms(data['rooms']);82
+ const rawTimelines = data['timelines'];83
+ if (isRecord(rawTimelines)) {84
+ const next: Record<string, TimelineItem[]> = {};85
+ for (const [roomId, list] of Object.entries(rawTimelines)) {86
+ next[roomId] = parseTimelineList(list);87
+ }88
+ timelines.set(next);89
+ }90
+ if (typeof data['connection'] === 'string') connection.set(data['connection']);91
+ const snapshotEncryption = parseEncryptionState(data['encryption']);92
+ if (snapshotEncryption) encryption.set(snapshotEncryption);93
+ const snapshotVerification = parseVerificationState(data['verification']);94
+ if (snapshotVerification) verification.set(snapshotVerification);95
+ return;96
+ }97
+ if (kind === 'rooms') {98
+ setRooms(data['rooms']);99
+ return;100
+ }101
+ if (kind === 'timeline') {102
+ const roomId = data['room_id'];103
+ if (typeof roomId === 'string') applyTimelinePayload(roomId, data);104
+ return;105
+ }106
+ if (kind === 'timeline_closed') {107
+ const roomId = data['room_id'];108
+ if (typeof roomId === 'string') {109
+ timelines.update((all) => {110
+ const next = { ...all };111
+ delete next[roomId];112
+ return next;113
+ });114
+ }115
+ return;116
+ }117
+ if (kind === 'connection') {118
+ if (typeof data['state'] === 'string') connection.set(data['state']);119
+ return;120
+ }121
+ if (kind === 'encryption') {122
+ const nextEncryption = parseEncryptionState(data['state']);123
+ if (nextEncryption) encryption.set(nextEncryption);124
+ return;125
+ }126
+ if (kind === 'verification') {127
+ const nextVerification = parseVerificationState(data['state']);128
+ if (nextVerification) verification.set(nextVerification);129
+ return;130
+ }131
+ if (kind === 'session_ended') {132
+ disconnectEvents();133
+ resetClientState();134
+ void getSession()135
+ .then((session) => {136
+ softLogout.set(session.soft_logout ?? null);137
+ blocked.set(session.blocked ?? false);138
+ sessionUser.set(session.user_id);139
+ })140
+ .catch(() => sessionUser.set(null));141
+ return;142
+ }143
+ if (kind === 'error') {144
+ const message = data['message'];145
+ pushBanner(typeof message === 'string' && message ? message : 'Sync error from server');146
+ }147
+}148
+149
+let unsubscribeEvents: (() => void) | null = null;150
+151
+function handleStreamDisconnect(): void {152
+ connection.set('reconnecting');153
+ void getSession()154
+ .then((session) => {155
+ if (session.user_id === null) {156
+ disconnectEvents();157
+ resetClientState();158
+ softLogout.set(session.soft_logout ?? null);159
+ blocked.set(session.blocked ?? false);160
+ sessionUser.set(null);161
+ }162
+ })163
+ .catch(() => undefined);164
+}165
+166
+/** Open the event stream; safe to call again (replaces any live subscription). */167
+export function connectEvents(): void {168
+ disconnectEvents();169
+ unsubscribeEvents = subscribeEvents(handlePayload, handleStreamDisconnect);170
+}171
+172
+export function disconnectEvents(): void {173
+ if (unsubscribeEvents) {174
+ unsubscribeEvents();175
+ unsubscribeEvents = null;176
+ }177
+}@@ -0,0 +1,250 @@
1
+import type { MemberSummary, RoomSummary } from './types';2
+import { ApiError, type SessionInfo, type Transport, type VerifyAction } from './transport';3
+4
+async function parseFailure(res: Response, fallback: string): Promise<never> {5
+ let message = fallback;6
+ try {7
+ const text = await res.text();8
+ if (text) {9
+ try {10
+ const data = JSON.parse(text) as unknown;11
+ if (typeof data === 'object' && data !== null) {12
+ const m = (data as Record<string, unknown>)['message'] ?? (data as Record<string, unknown>)['error'];13
+ if (typeof m === 'string' && m) message = m;14
+ else message = text.slice(0, 300);15
+ } else if (typeof data === 'string' && data) {16
+ message = data;17
+ } else {18
+ message = text.slice(0, 300);19
+ }20
+ } catch {21
+ message = text.slice(0, 300);22
+ }23
+ }24
+ } catch {25
+ // keep fallback26
+ }27
+ throw new ApiError(res.status, `${res.status}: ${message}`);28
+}29
+30
+async function json<T>(res: Response, fallback: string): Promise<T> {31
+ if (!res.ok) await parseFailure(res, fallback);32
+ if (res.status === 204) return undefined as T;33
+ return (await res.json()) as T;34
+}35
+36
+const ROOM_COMMAND_PATHS = {37
+ open: '/api/rooms/open',38
+ close: '/api/rooms/close',39
+ send: '/api/rooms/send',40
+ paginate: '/api/rooms/paginate',41
+ leave: '/api/rooms/leave',42
+ react: '/api/rooms/react',43
+ read: '/api/rooms/read',44
+ typing: '/api/rooms/typing'45
+} as const;46
+47
+export class HttpTransport implements Transport {48
+ async getSession(): Promise<SessionInfo> {49
+ const res = await fetch('/api/session', { credentials: 'same-origin' });50
+ return json(res, 'Could not load session');51
+ }52
+53
+ async login(homeserver: string, username: string, password: string): Promise<{ user_id: string }> {54
+ const res = await fetch('/api/login', {55
+ method: 'POST',56
+ credentials: 'same-origin',57
+ headers: { 'content-type': 'application/json' },58
+ body: JSON.stringify({ homeserver, username, password })59
+ });60
+ return json(res, 'Login failed');61
+ }62
+63
+ async logout(): Promise<void> {64
+ const res = await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });65
+ if (!res.ok && res.status !== 204) await parseFailure(res, 'Logout failed');66
+ }67
+68
+ async getRooms(): Promise<{ rooms: RoomSummary[] }> {69
+ const res = await fetch('/api/rooms', { credentials: 'same-origin' });70
+ return json(res, 'Could not load rooms');71
+ }72
+73
+ private async roomCommand(74
+ path: (typeof ROOM_COMMAND_PATHS)[keyof typeof ROOM_COMMAND_PATHS],75
+ payload: Record<string, string | boolean>76
+ ): Promise<void> {77
+ const res = await fetch(path, {78
+ method: 'POST',79
+ credentials: 'same-origin',80
+ headers: { 'content-type': 'application/json' },81
+ body: JSON.stringify(payload)82
+ });83
+ return json(res, `Request to ${path} failed`);84
+ }85
+86
+ openRoom(room_id: string): Promise<void> {87
+ return this.roomCommand(ROOM_COMMAND_PATHS.open, { room_id });88
+ }89
+ closeRoom(room_id: string): Promise<void> {90
+ return this.roomCommand(ROOM_COMMAND_PATHS.close, { room_id });91
+ }92
+ sendMessage(room_id: string, body: string): Promise<void> {93
+ return this.roomCommand(ROOM_COMMAND_PATHS.send, { room_id, body });94
+ }95
+ paginateRoom(room_id: string): Promise<void> {96
+ return this.roomCommand(ROOM_COMMAND_PATHS.paginate, { room_id });97
+ }98
+ setTyping(room_id: string, typing: boolean): Promise<void> {99
+ return this.roomCommand(ROOM_COMMAND_PATHS.typing, { room_id, typing });100
+ }101
+ leaveRoom(room_id: string): Promise<void> {102
+ return this.roomCommand(ROOM_COMMAND_PATHS.leave, { room_id });103
+ }104
+ reactToMessage(room_id: string, event_id: string, key: string): Promise<void> {105
+ return this.roomCommand(ROOM_COMMAND_PATHS.react, { room_id, event_id, key });106
+ }107
+ markRead(room_id: string, event_id: string): Promise<void> {108
+ return this.roomCommand(ROOM_COMMAND_PATHS.read, { room_id, event_id });109
+ }110
+111
+ async joinRoom(room: string): Promise<{ room_id: string }> {112
+ const res = await fetch('/api/rooms/join', {113
+ method: 'POST',114
+ credentials: 'same-origin',115
+ headers: { 'content-type': 'application/json' },116
+ body: JSON.stringify({ room })117
+ });118
+ return json(res, 'Join failed');119
+ }120
+121
+ async createRoom(name: string, topic?: string): Promise<{ room_id: string }> {122
+ const res = await fetch('/api/rooms/create', {123
+ method: 'POST',124
+ credentials: 'same-origin',125
+ headers: { 'content-type': 'application/json' },126
+ body: JSON.stringify(topic ? { name, topic } : { name })127
+ });128
+ return json(res, 'Create room failed');129
+ }130
+131
+ async getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {132
+ const res = await fetch(`/api/rooms/members?room_id=${encodeURIComponent(room_id)}`, {133
+ credentials: 'same-origin'134
+ });135
+ return json(res, 'Could not load members');136
+ }137
+138
+ async getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }> {139
+ const res = await fetch('/api/profile', { credentials: 'same-origin' });140
+ return json(res, 'Could not load profile');141
+ }142
+143
+ async getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }> {144
+ const res = await fetch(`/api/spaces/children?room_id=${encodeURIComponent(room_id)}`, {145
+ credentials: 'same-origin'146
+ });147
+ return json(res, 'Could not load rooms in this space');148
+ }149
+150
+ async getEncryption(): Promise<{ state: unknown; verification: unknown }> {151
+ const res = await fetch('/api/encryption', { credentials: 'same-origin' });152
+ return json(res, 'Could not load encryption status');153
+ }154
+155
+ async recoverEncryption(recovery_key: string): Promise<void> {156
+ const res = await fetch('/api/encryption/recover', {157
+ method: 'POST',158
+ credentials: 'same-origin',159
+ headers: { 'content-type': 'application/json' },160
+ body: JSON.stringify({ recovery_key })161
+ });162
+ return json(res, 'Recovery failed');163
+ }164
+165
+ async startVerification(): Promise<void> {166
+ const res = await fetch('/api/encryption/start', {167
+ method: 'POST',168
+ credentials: 'same-origin',169
+ headers: { 'content-type': 'application/json' },170
+ body: JSON.stringify({})171
+ });172
+ return json(res, 'Could not start verification');173
+ }174
+175
+ async verifyEncryption(action: VerifyAction): Promise<void> {176
+ const res = await fetch('/api/encryption/verify', {177
+ method: 'POST',178
+ credentials: 'same-origin',179
+ headers: { 'content-type': 'application/json' },180
+ body: JSON.stringify({ action })181
+ });182
+ return json(res, 'Verification action failed');183
+ }184
+185
+ async uploadAttachment(room_id: string, file: File): Promise<void> {186
+ const form = new FormData();187
+ form.append('room_id', room_id);188
+ form.append('file', file, file.name || 'attachment');189
+ const res = await fetch('/api/rooms/upload', {190
+ method: 'POST',191
+ credentials: 'same-origin',192
+ body: form193
+ });194
+ return json(res, 'Upload failed');195
+ }196
+197
+ /**198
+ * Keeps its own EventSource alive with backoff for as long as the caller199
+ * hasn't unsubscribed; onDisconnect is just a signal, not a request to stop.200
+ */201
+ subscribeEvents(onEvent: (data: string) => void, onDisconnect: () => void): () => void {202
+ let active = true;203
+ let source: EventSource | null = null;204
+ let retryTimer: number | null = null;205
+ let retryMs = 1000;206
+207
+ const scheduleReconnect = (): void => {208
+ if (!active || retryTimer !== null) return;209
+ const delay = retryMs;210
+ retryMs = Math.min(retryMs * 2, 15000);211
+ retryTimer = window.setTimeout(() => {212
+ retryTimer = null;213
+ if (active) open();214
+ }, delay);215
+ };216
+217
+ const open = (): void => {218
+ const next = new EventSource('/api/events');219
+ source = next;220
+ next.onmessage = (ev: MessageEvent) => {221
+ retryMs = 1000;222
+ if (typeof ev.data === 'string') onEvent(ev.data);223
+ };224
+ next.onerror = () => {225
+ if (source !== next) return;226
+ next.close();227
+ source = null;228
+ onDisconnect();229
+ scheduleReconnect();230
+ };231
+ };232
+ open();233
+234
+ return () => {235
+ active = false;236
+ if (retryTimer !== null) {237
+ window.clearTimeout(retryTimer);238
+ retryTimer = null;239
+ }240
+ if (source) {241
+ try {242
+ source.close();243
+ } catch {244
+ // already gone245
+ }246
+ source = null;247
+ }248
+ };249
+ }250
+}@@ -0,0 +1,291 @@
1
+import type { MemberSummary, RoomSummary } from './types';2
+import { ApiError, type SessionInfo, type SoftLogout, type Transport, type VerifyAction } from './transport';3
+import { statusForKind } from './wire-status';4
+5
+function toApiError(error: { kind: string; message: string }): ApiError {6
+ return new ApiError(statusForKind(error.kind), error.message);7
+}8
+9
+const SW_RELOAD_GUARD = 'malice-sw-reload';10
+11
+/** Registers the service worker and recovers from a shift-reload leaving the page uncontrolled. */12
+function registerServiceWorker(): void {13
+ if (!('serviceWorker' in navigator)) return;14
+ void navigator.serviceWorker15
+ .register('/sw.js', { type: 'module' })16
+ .then(() => navigator.serviceWorker.ready)17
+ .then(() => {18
+ if (navigator.serviceWorker.controller) {19
+ sessionStorage.removeItem(SW_RELOAD_GUARD);20
+ return;21
+ }22
+ if (sessionStorage.getItem(SW_RELOAD_GUARD)) return;23
+ sessionStorage.setItem(SW_RELOAD_GUARD, '1');24
+ window.location.reload();25
+ })26
+ .catch(() => undefined);27
+}28
+29
+type Pending = { resolve: (value: unknown) => void; reject: (error: unknown) => void };30
+31
+/**32
+ * Owns the SharedWorker (or, where unavailable, a dedicated Worker) and the33
+ * request/response and event-fanout protocol described in the on-device34
+ * design doc. A single instance is shared by every WorkerTransport, since35
+ * the UI only ever drives one account at a time.36
+ */37
+class WorkerClient {38
+ private port: MessagePort | Worker | null = null;39
+ private nextId = 1;40
+ private pending = new Map<number, Pending>();41
+ private account: string | null = null;42
+ private listeners = new Set<(account: string, event: string) => void>();43
+ private fatalHandlers = new Set<() => void>();44
+ private subscribed = new Set<string>();45
+46
+ constructor() {47
+ registerServiceWorker();48
+ window.addEventListener('pagehide', () => {49
+ if (this.port) this.post({ kind: 'bye' });50
+ });51
+ }52
+53
+ get accountId(): string | null {54
+ return this.account;55
+ }56
+57
+ private connect(): void {58
+ if (typeof SharedWorker !== 'undefined') {59
+ const worker = new SharedWorker(new URL('../worker/worker.ts', import.meta.url), {60
+ type: 'module',61
+ name: 'malice'62
+ });63
+ worker.port.onmessage = (ev: MessageEvent) => this.handleMessage(ev.data);64
+ worker.port.start();65
+ this.port = worker.port;66
+ } else {67
+ const worker = new Worker(new URL('../worker/worker.ts', import.meta.url), { type: 'module' });68
+ worker.onmessage = (ev: MessageEvent) => this.handleMessage(ev.data);69
+ this.port = worker;70
+ }71
+ for (const account of this.subscribed) this.post({ kind: 'subscribe', account });72
+ }73
+74
+ private post(message: Record<string, unknown>, transfer?: Transferable[]): void {75
+ if (!this.port) this.connect();76
+ if (!this.port) return;77
+ if (transfer && transfer.length > 0) this.port.postMessage(message, transfer);78
+ else this.port.postMessage(message);79
+ }80
+81
+ private rpc<T>(message: Record<string, unknown>, transfer?: Transferable[]): Promise<T> {82
+ const id = this.nextId++;83
+ return new Promise<T>((resolve, reject) => {84
+ this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject });85
+ this.post({ ...message, id }, transfer);86
+ });87
+ }88
+89
+ private handleMessage(data: unknown): void {90
+ if (typeof data !== 'object' || data === null) return;91
+ const msg = data as Record<string, unknown>;92
+ if (msg['kind'] === 'reply') {93
+ const id = msg['id'];94
+ if (typeof id !== 'number') return;95
+ const pending = this.pending.get(id);96
+ if (!pending) return;97
+ this.pending.delete(id);98
+ if (msg['error']) pending.reject(toApiError(msg['error'] as { kind: string; message: string }));99
+ else pending.resolve(msg['ok']);100
+ return;101
+ }102
+ if (msg['kind'] === 'event') {103
+ const account = msg['account'];104
+ const event = msg['event'];105
+ if (typeof account === 'string' && typeof event === 'string') {106
+ for (const listener of this.listeners) listener(account, event);107
+ }108
+ return;109
+ }110
+ if (msg['kind'] === 'fatal') this.handleFatal();111
+ }112
+113
+ /** The worker crashed and closed every port; every tab respawns it after a beat. */114
+ private handleFatal(): void {115
+ this.port = null;116
+ for (const pending of this.pending.values()) {117
+ pending.reject(new ApiError(502, 'the worker stopped; reconnecting'));118
+ }119
+ this.pending.clear();120
+ for (const handler of this.fatalHandlers) handler();121
+ window.setTimeout(() => this.connect(), 1000);122
+ }123
+124
+ async getSession(): Promise<SessionInfo> {125
+ const reply = await this.rpc<{126
+ user_id: string | null;127
+ soft_logout?: SoftLogout;128
+ account?: string;129
+ blocked?: true;130
+ }>({ kind: 'session' });131
+ if (reply.blocked) this.account = null;132
+ else if (reply.account) this.account = reply.account;133
+ return { user_id: reply.user_id, soft_logout: reply.soft_logout, blocked: reply.blocked };134
+ }135
+136
+ async login(homeserver: string, username: string, password: string): Promise<{ user_id: string }> {137
+ const reply = await this.rpc<{ user_id: string; account: string }>({138
+ kind: 'login',139
+ homeserver,140
+ username,141
+ password142
+ });143
+ this.account = reply.account;144
+ return { user_id: reply.user_id };145
+ }146
+147
+ async logout(): Promise<void> {148
+ const account = this.account;149
+ if (!account) return;150
+ await this.rpc<Record<string, never>>({ kind: 'logout', account });151
+ this.subscribed.delete(account);152
+ this.account = null;153
+ }154
+155
+ async call<T>(op: string, fields: Record<string, unknown> = {}, bytes?: ArrayBuffer): Promise<T> {156
+ if (!this.account) throw new ApiError(401, 'not signed in');157
+ const reply = await this.rpc<{ json?: T }>(158
+ { kind: 'call', account: this.account, request: { op, ...fields }, bytes },159
+ bytes ? [bytes] : undefined160
+ );161
+ return reply.json as T;162
+ }163
+164
+ addListener(listener: (account: string, event: string) => void): void {165
+ this.listeners.add(listener);166
+ }167
+168
+ removeListener(listener: (account: string, event: string) => void): void {169
+ this.listeners.delete(listener);170
+ }171
+172
+ onFatal(handler: () => void): () => void {173
+ this.fatalHandlers.add(handler);174
+ return () => this.fatalHandlers.delete(handler);175
+ }176
+177
+ subscribe(account: string): void {178
+ this.subscribed.add(account);179
+ this.post({ kind: 'subscribe', account });180
+ }181
+182
+ unsubscribe(account: string): void {183
+ this.subscribed.delete(account);184
+ this.post({ kind: 'unsubscribe', account });185
+ }186
+}187
+188
+let sharedClient: WorkerClient | null = null;189
+function getClient(): WorkerClient {190
+ if (!sharedClient) sharedClient = new WorkerClient();191
+ return sharedClient;192
+}193
+194
+export class WorkerTransport implements Transport {195
+ private client = getClient();196
+197
+ getSession(): Promise<SessionInfo> {198
+ return this.client.getSession();199
+ }200
+201
+ login(homeserver: string, username: string, password: string): Promise<{ user_id: string }> {202
+ return this.client.login(homeserver, username, password);203
+ }204
+205
+ logout(): Promise<void> {206
+ return this.client.logout();207
+ }208
+209
+ getRooms(): Promise<{ rooms: RoomSummary[] }> {210
+ return this.client.call('rooms');211
+ }212
+ openRoom(room_id: string): Promise<void> {213
+ return this.client.call('open', { room_id });214
+ }215
+ closeRoom(room_id: string): Promise<void> {216
+ return this.client.call('close', { room_id });217
+ }218
+ sendMessage(room_id: string, body: string): Promise<void> {219
+ return this.client.call('send', { room_id, body });220
+ }221
+ paginateRoom(room_id: string): Promise<void> {222
+ return this.client.call('paginate', { room_id });223
+ }224
+ setTyping(room_id: string, typing: boolean): Promise<void> {225
+ return this.client.call('typing', { room_id, typing });226
+ }227
+ joinRoom(room: string): Promise<{ room_id: string }> {228
+ return this.client.call('join', { room });229
+ }230
+ createRoom(name: string, topic?: string): Promise<{ room_id: string }> {231
+ return this.client.call('create', topic ? { name, topic } : { name });232
+ }233
+ leaveRoom(room_id: string): Promise<void> {234
+ return this.client.call('leave', { room_id });235
+ }236
+ reactToMessage(room_id: string, event_id: string, key: string): Promise<void> {237
+ return this.client.call('react', { room_id, event_id, key });238
+ }239
+ markRead(room_id: string, event_id: string): Promise<void> {240
+ return this.client.call('read', { room_id, event_id });241
+ }242
+ getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {243
+ return this.client.call('members', { room_id });244
+ }245
+ getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }> {246
+ return this.client.call('profile');247
+ }248
+ getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }> {249
+ return this.client.call('space_children', { room_id });250
+ }251
+ getEncryption(): Promise<{ state: unknown; verification: unknown }> {252
+ return this.client.call('encryption');253
+ }254
+ recoverEncryption(recovery_key: string): Promise<void> {255
+ return this.client.call('recover', { recovery_key });256
+ }257
+ startVerification(): Promise<void> {258
+ return this.client.call('start_verification');259
+ }260
+ verifyEncryption(action: VerifyAction): Promise<void> {261
+ return this.client.call('verify', { action });262
+ }263
+264
+ async uploadAttachment(room_id: string, file: File): Promise<void> {265
+ const bytes = await file.arrayBuffer();266
+ await this.client.call(267
+ 'upload',268
+ { room_id, filename: file.name || 'attachment', mimetype: file.type || 'application/octet-stream' },269
+ bytes270
+ );271
+ }272
+273
+ subscribeEvents(onEvent: (data: string) => void, onDisconnect: () => void): () => void {274
+ let stopped = false;275
+ const account = this.client.accountId;276
+ const listener = (eventAccount: string, event: string) => {277
+ if (eventAccount === account) onEvent(event);278
+ };279
+ const removeFatal = this.client.onFatal(() => {280
+ if (!stopped) onDisconnect();281
+ });282
+ this.client.addListener(listener);283
+ if (account) this.client.subscribe(account);284
+ return () => {285
+ stopped = true;286
+ this.client.removeListener(listener);287
+ removeFatal();288
+ if (account) this.client.unsubscribe(account);289
+ };290
+ }291
+}@@ -0,0 +1,61 @@
1
+import type { MemberSummary, RoomSummary } from './types';2
+3
+export class ApiError extends Error {4
+ status: number;5
+ constructor(status: number, message: string) {6
+ super(message);7
+ this.name = 'ApiError';8
+ this.status = status;9
+ }10
+}11
+12
+export interface SoftLogout {13
+ user_id: string;14
+ homeserver: string;15
+}16
+17
+export interface SessionInfo {18
+ user_id: string | null;19
+ soft_logout?: SoftLogout;20
+ /** This tab lost the store lock to another tab; see `blocked` in App state. */21
+ blocked?: boolean;22
+}23
+24
+export type VerifyAction = 'Accept' | 'Confirm' | 'Reject' | 'Cancel';25
+26
+/**27
+ * Everything the app needs from the backend, hosted (fetch + SSE) or on-device28
+ * (SharedWorker running macltok on wasm). Both implementations throw ApiError29
+ * for failures and keep the same method shapes as the old api.ts.30
+ */31
+export interface Transport {32
+ getSession(): Promise<SessionInfo>;33
+ login(homeserver: string, username: string, password: string): Promise<{ user_id: string }>;34
+ logout(): Promise<void>;35
+ getRooms(): Promise<{ rooms: RoomSummary[] }>;36
+ openRoom(room_id: string): Promise<void>;37
+ closeRoom(room_id: string): Promise<void>;38
+ sendMessage(room_id: string, body: string): Promise<void>;39
+ paginateRoom(room_id: string): Promise<void>;40
+ setTyping(room_id: string, typing: boolean): Promise<void>;41
+ joinRoom(room: string): Promise<{ room_id: string }>;42
+ createRoom(name: string, topic?: string): Promise<{ room_id: string }>;43
+ leaveRoom(room_id: string): Promise<void>;44
+ reactToMessage(room_id: string, event_id: string, key: string): Promise<void>;45
+ markRead(room_id: string, event_id: string): Promise<void>;46
+ getMembers(room_id: string): Promise<{ members: MemberSummary[] }>;47
+ getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }>;48
+ getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }>;49
+ getEncryption(): Promise<{ state: unknown; verification: unknown }>;50
+ recoverEncryption(recovery_key: string): Promise<void>;51
+ startVerification(): Promise<void>;52
+ verifyEncryption(action: VerifyAction): Promise<void>;53
+ uploadAttachment(room_id: string, file: File): Promise<void>;54
+ /**55
+ * Wire events as raw JSON text, snapshot first. Returns an unsubscribe56
+ * function. onDisconnect fires whenever the underlying connection drops;57
+ * the transport keeps retrying on its own, callers only need onDisconnect58
+ * to notice a possible session change.59
+ */60
+ subscribeEvents(onEvent: (data: string) => void, onDisconnect: () => void): () => void;61
+}@@ -0,0 +1,649 @@
1
+import { isRecord } from './guards';2
+3
+export interface RoomHero {4
+ user_id: string;5
+ display_name: string | null;6
+ avatar: string | null;7
+}8
+9
+export interface LatestEvent {10
+ sender: string | null;11
+ kind: MessageKind | null;12
+ body: string | null;13
+ timestamp_ms: number | null;14
+ send_state: SendState;15
+}16
+17
+export interface RoomSummary {18
+ room_id: string;19
+ name: string;20
+ membership: string;21
+ unread_messages: number;22
+ unread_mentions: number;23
+ unread_notifications: number;24
+ encrypted: boolean;25
+ space: boolean;26
+ direct: boolean;27
+ favourite: boolean;28
+ low_priority: boolean;29
+ avatar: string | null;30
+ latest_event_timestamp_ms: number | null;31
+ latest_event: LatestEvent | null;32
+ notification_mode: string | null;33
+ notification_mode_custom: boolean;34
+ topic: string | null;35
+ canonical_alias: string | null;36
+ joined_members: number;37
+ invited_members: number;38
+ heroes: RoomHero[];39
+ successor: string | null;40
+ inviter: string | null;41
+ parent_spaces: string[];42
+ children: string[];43
+}44
+45
+export interface MemberSummary {46
+ user_id: string;47
+ name: string;48
+ display_name: string | null;49
+ avatar: string | null;50
+ membership: string | { Custom: string };51
+ own: boolean;52
+ name_ambiguous: boolean;53
+ ignored: boolean;54
+ service: boolean;55
+}56
+57
+export type SendState =58
+ | { kind: 'Sent' }59
+ | { kind: 'Sending' }60
+ | { kind: 'Failed'; will_retry: boolean };61
+62
+function stringField(o: Record<string, unknown>, key: string, fallback = ''): string {63
+ const v = o[key];64
+ return typeof v === 'string' ? v : fallback;65
+}66
+67
+function numberField(o: Record<string, unknown>, key: string, fallback = 0): number {68
+ const v = o[key];69
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback;70
+}71
+72
+function optionalString(o: Record<string, unknown>, key: string): string | null {73
+ const v = o[key];74
+ return typeof v === 'string' ? v : null;75
+}76
+77
+function optionalNumber(o: Record<string, unknown>, key: string): number | null {78
+ const v = o[key];79
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;80
+}81
+82
+function stringList(v: unknown): string[] {83
+ if (!Array.isArray(v)) return [];84
+ const out: string[] = [];85
+ for (const entry of v) if (typeof entry === 'string') out.push(entry);86
+ return out;87
+}88
+89
+export function parseSendState(raw: unknown): SendState {90
+ if (raw === 'Sent') return { kind: 'Sent' };91
+ if (raw === 'Sending') return { kind: 'Sending' };92
+ if (isRecord(raw)) {93
+ if (isRecord(raw['Failed'])) {94
+ return { kind: 'Failed', will_retry: raw['Failed']['will_retry'] === true };95
+ }96
+ const kind = raw['kind'];97
+ if (kind === 'Sent' || kind === 'Sending') return { kind };98
+ if (kind === 'Failed') return { kind: 'Failed', will_retry: raw['will_retry'] === true };99
+ }100
+ return { kind: 'Sent' };101
+}102
+103
+export interface MediaAttachment {104
+ id: string;105
+ kind: string;106
+ filename: string;107
+ caption: string | null;108
+ mimetype: string | null;109
+ size_bytes: number | null;110
+ width: number | null;111
+ height: number | null;112
+ duration_ms: number | null;113
+ blurhash: string | null;114
+ has_thumbnail: boolean;115
+}116
+117
+export function parseMediaAttachment(raw: unknown): MediaAttachment | null {118
+ if (!isRecord(raw)) return null;119
+ const idRaw = raw['id'];120
+ if (typeof idRaw !== 'string' || !idRaw) return null;121
+ const kindRaw = raw['kind'];122
+ return {123
+ id: idRaw,124
+ kind: typeof kindRaw === 'string' ? kindRaw : 'File',125
+ filename: stringField(raw, 'filename', 'attachment'),126
+ caption: optionalString(raw, 'caption'),127
+ mimetype: optionalString(raw, 'mimetype'),128
+ size_bytes: optionalNumber(raw, 'size_bytes'),129
+ width: optionalNumber(raw, 'width'),130
+ height: optionalNumber(raw, 'height'),131
+ duration_ms: optionalNumber(raw, 'duration_ms'),132
+ blurhash: optionalString(raw, 'blurhash'),133
+ has_thumbnail: raw['has_thumbnail'] === true134
+ };135
+}136
+137
+export type RecoveryState = 'Unknown' | 'Disabled' | 'Incomplete' | 'Enabled';138
+139
+export interface EncryptionState {140
+ device_id: string;141
+ device_verified: boolean;142
+ recovery: RecoveryState;143
+ key_backup: string;144
+}145
+146
+function parseRecoveryState(raw: unknown): RecoveryState {147
+ return raw === 'Enabled' || raw === 'Disabled' || raw === 'Incomplete' ? raw : 'Unknown';148
+}149
+150
+export function parseEncryptionState(raw: unknown): EncryptionState | null {151
+ if (!isRecord(raw)) return null;152
+ return {153
+ device_id: stringField(raw, 'device_id'),154
+ device_verified: raw['device_verified'] === true,155
+ recovery: parseRecoveryState(raw['recovery']),156
+ key_backup: typeof raw['key_backup'] === 'string' ? raw['key_backup'] : 'Unknown'157
+ };158
+}159
+160
+export interface SasEmoji {161
+ emoji: string;162
+ name: string;163
+}164
+165
+export type VerificationState =166
+ | { kind: 'Waiting' }167
+ | { kind: 'Incoming'; user_id: string | null; device_id: string | null }168
+ | { kind: 'Comparison'; emojis: SasEmoji[]; decimals: [number, number, number] }169
+ | { kind: 'Confirmed' }170
+ | { kind: 'Done' }171
+ | { kind: 'Cancelled'; reason: string }172
+ | { kind: 'Failed'; message: string };173
+174
+function parseEmojis(raw: unknown): SasEmoji[] {175
+ if (!Array.isArray(raw)) return [];176
+ const out: SasEmoji[] = [];177
+ for (const entry of raw) {178
+ if (179
+ Array.isArray(entry) &&180
+ entry.length >= 2 &&181
+ typeof entry[0] === 'string' &&182
+ typeof entry[1] === 'string'183
+ ) {184
+ out.push({ emoji: entry[0], name: entry[1] });185
+ } else if (isRecord(entry)) {186
+ const emoji = entry['emoji'];187
+ const name = entry['name'];188
+ if (typeof emoji === 'string') {189
+ out.push({ emoji, name: typeof name === 'string' ? name : '' });190
+ }191
+ }192
+ }193
+ return out;194
+}195
+196
+function parseDecimals(raw: unknown): [number, number, number] {197
+ const fallback: [number, number, number] = [0, 0, 0];198
+ if (!Array.isArray(raw)) return fallback;199
+ const nums = raw.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));200
+ if (nums.length < 3) return fallback;201
+ return [nums[0], nums[1], nums[2]];202
+}203
+204
+function verificationFailureMessage(raw: unknown): string {205
+ if (typeof raw === 'string') return raw || 'verification failed';206
+ if (isRecord(raw)) {207
+ const keys = Object.keys(raw);208
+ if (keys.length === 0) return 'verification failed';209
+ const first = raw[keys[0]];210
+ if (typeof first === 'string' && first) return first;211
+ return keys[0];212
+ }213
+ return 'verification failed';214
+}215
+216
+/** Normalize the backend VerificationState (serde externally-tagged enum). */217
+export function parseVerificationState(raw: unknown): VerificationState | null {218
+ if (raw === null || raw === undefined) return null;219
+ if (raw === 'Waiting') return { kind: 'Waiting' };220
+ if (raw === 'Confirmed') return { kind: 'Confirmed' };221
+ if (raw === 'Done') return { kind: 'Done' };222
+ if (typeof raw === 'string') return null;223
+ if (!isRecord(raw)) return null;224
+ if (isRecord(raw['Comparison'])) {225
+ const inner = raw['Comparison'];226
+ return {227
+ kind: 'Comparison',228
+ emojis: parseEmojis(inner['emojis']),229
+ decimals: parseDecimals(inner['decimals'])230
+ };231
+ }232
+ if (isRecord(raw['Incoming'])) {233
+ const incoming = raw['Incoming'];234
+ const userId = incoming['user_id'];235
+ const deviceId = incoming['device_id'];236
+ return {237
+ kind: 'Incoming',238
+ user_id: typeof userId === 'string' ? userId : null,239
+ device_id: typeof deviceId === 'string' ? deviceId : null240
+ };241
+ }242
+ if ('Cancelled' in raw) {243
+ const reason = raw['Cancelled'];244
+ return { kind: 'Cancelled', reason: typeof reason === 'string' ? reason : 'cancelled' };245
+ }246
+ if ('Failed' in raw) return { kind: 'Failed', message: verificationFailureMessage(raw['Failed']) };247
+ return null;248
+}249
+250
+export interface OwnProfile {251
+ display_name: string | null;252
+ avatar_url: string | null;253
+}254
+255
+export function parseOwnProfile(raw: unknown): OwnProfile {256
+ if (!isRecord(raw)) return { display_name: null, avatar_url: null };257
+ const displayName = raw['display_name'];258
+ const avatarUrl = raw['avatar_url'];259
+ return {260
+ display_name: typeof displayName === 'string' ? displayName : null,261
+ avatar_url: typeof avatarUrl === 'string' ? avatarUrl : null262
+ };263
+}264
+265
+export function parseRoomIdList(raw: unknown): string[] {266
+ if (!isRecord(raw) || !Array.isArray(raw['room_ids'])) return [];267
+ return raw['room_ids'].filter((entry): entry is string => typeof entry === 'string');268
+}269
+270
+function parseHeroes(raw: unknown): RoomHero[] {271
+ if (!Array.isArray(raw)) return [];272
+ const out: RoomHero[] = [];273
+ for (const entry of raw) {274
+ if (!isRecord(entry)) continue;275
+ const userId = entry['user_id'];276
+ if (typeof userId !== 'string') continue;277
+ out.push({278
+ user_id: userId,279
+ display_name: optionalString(entry, 'display_name'),280
+ avatar: optionalString(entry, 'avatar')281
+ });282
+ }283
+ return out;284
+}285
+286
+export type MessageKind =287
+ | 'Text'288
+ | 'Emote'289
+ | 'Notice'290
+ | 'ServerNotice'291
+ | 'Image'292
+ | 'Video'293
+ | 'Audio'294
+ | 'File'295
+ | 'Location'296
+ | 'VerificationRequest'297
+ | { Other: string };298
+299
+const PLAIN_MESSAGE_KINDS = new Set([300
+ 'Text',301
+ 'Emote',302
+ 'Notice',303
+ 'ServerNotice',304
+ 'Image',305
+ 'Video',306
+ 'Audio',307
+ 'File',308
+ 'Location',309
+ 'VerificationRequest'310
+]);311
+312
+function parseMessageKind(raw: unknown): MessageKind {313
+ if (typeof raw === 'string' && PLAIN_MESSAGE_KINDS.has(raw)) return raw as MessageKind;314
+ if (isRecord(raw) && typeof raw['Other'] === 'string') return { Other: raw['Other'] };315
+ return 'Text';316
+}317
+318
+function parseOptionalMessageKind(raw: unknown): MessageKind | null {319
+ if (raw === null || raw === undefined) return null;320
+ return parseMessageKind(raw);321
+}322
+323
+function parseLatestEvent(raw: unknown): LatestEvent | null {324
+ if (!isRecord(raw)) return null;325
+ return {326
+ sender: optionalString(raw, 'sender'),327
+ kind: parseOptionalMessageKind(raw['kind']),328
+ body: optionalString(raw, 'body'),329
+ timestamp_ms: optionalNumber(raw, 'timestamp_ms'),330
+ send_state: parseSendState(raw['send_state'])331
+ };332
+}333
+334
+export function parseRoomSummary(raw: unknown): RoomSummary | null {335
+ if (!isRecord(raw)) return null;336
+ const roomId = raw['room_id'];337
+ if (typeof roomId !== 'string') return null;338
+ return {339
+ room_id: roomId,340
+ name: stringField(raw, 'name'),341
+ membership: stringField(raw, 'membership'),342
+ unread_messages: numberField(raw, 'unread_messages'),343
+ unread_mentions: numberField(raw, 'unread_mentions'),344
+ unread_notifications: numberField(raw, 'unread_notifications'),345
+ encrypted: raw['encrypted'] === true,346
+ space: raw['space'] === true,347
+ direct: raw['direct'] === true,348
+ favourite: raw['favourite'] === true,349
+ low_priority: raw['low_priority'] === true,350
+ avatar: optionalString(raw, 'avatar'),351
+ latest_event_timestamp_ms: optionalNumber(raw, 'latest_event_timestamp_ms'),352
+ latest_event: parseLatestEvent(raw['latest_event']),353
+ notification_mode: optionalString(raw, 'notification_mode'),354
+ notification_mode_custom: raw['notification_mode_custom'] === true,355
+ topic: optionalString(raw, 'topic'),356
+ canonical_alias: optionalString(raw, 'canonical_alias'),357
+ joined_members: numberField(raw, 'joined_members'),358
+ invited_members: numberField(raw, 'invited_members'),359
+ heroes: parseHeroes(raw['heroes']),360
+ successor: optionalString(raw, 'successor'),361
+ inviter: optionalString(raw, 'inviter'),362
+ parent_spaces: stringList(raw['parent_spaces']),363
+ children: stringList(raw['children'])364
+ };365
+}366
+367
+export function parseRoomList(raw: unknown): RoomSummary[] {368
+ if (!Array.isArray(raw)) return [];369
+ const out: RoomSummary[] = [];370
+ for (const entry of raw) {371
+ const room = parseRoomSummary(entry);372
+ if (room) out.push(room);373
+ }374
+ return out;375
+}376
+377
+export interface Reaction {378
+ key: string;379
+ count: number;380
+ own: boolean;381
+ senders: string[];382
+}383
+384
+export interface Sender {385
+ user_id: string;386
+ display_name: string | null;387
+ avatar: string | null;388
+ name_ambiguous: boolean;389
+}390
+391
+/** display_name if set, else the user id; callers add the id back on when name_ambiguous. */392
+export function senderLabel(sender: Sender): string {393
+ return sender.display_name ?? sender.user_id;394
+}395
+396
+function parseSender(raw: unknown): Sender {397
+ if (!isRecord(raw)) return { user_id: 'unknown', display_name: null, avatar: null, name_ambiguous: false };398
+ return {399
+ user_id: stringField(raw, 'user_id', 'unknown'),400
+ display_name: optionalString(raw, 'display_name'),401
+ avatar: optionalString(raw, 'avatar'),402
+ name_ambiguous: raw['name_ambiguous'] === true403
+ };404
+}405
+406
+export interface ThreadSummary {407
+ reply_count: number;408
+ latest_event_id: string | null;409
+ latest_event_timestamp_ms: number | null;410
+}411
+412
+function parseThreadSummary(raw: unknown): ThreadSummary | null {413
+ if (!isRecord(raw)) return null;414
+ return {415
+ reply_count: numberField(raw, 'reply_count'),416
+ latest_event_id: optionalString(raw, 'latest_event_id'),417
+ latest_event_timestamp_ms: optionalNumber(raw, 'latest_event_timestamp_ms')418
+ };419
+}420
+421
+export interface MessageBody {422
+ kind: MessageKind;423
+ body: string;424
+ geo_uri: string | null;425
+ formatted_body: string | null;426
+ edited: boolean;427
+ media: MediaAttachment | null;428
+}429
+430
+function parseMessageBody(raw: unknown): MessageBody {431
+ const o = isRecord(raw) ? raw : {};432
+ return {433
+ kind: parseMessageKind(o['kind']),434
+ body: stringField(o, 'body'),435
+ geo_uri: optionalString(o, 'geo_uri'),436
+ formatted_body: optionalString(o, 'formatted_body'),437
+ edited: o['edited'] === true,438
+ media: parseMediaAttachment(o['media'])439
+ };440
+}441
+442
+export interface Change<T> {443
+ old: T;444
+ new: T;445
+}446
+447
+function parseChange(raw: unknown): Change<string | null> | null {448
+ if (!isRecord(raw)) return null;449
+ return {450
+ old: typeof raw['old'] === 'string' ? raw['old'] : null,451
+ new: typeof raw['new'] === 'string' ? raw['new'] : null452
+ };453
+}454
+455
+export type TimelineContent =456
+ | { kind: 'Message'; message: MessageBody }457
+ | { kind: 'Sticker'; body: string; media: MediaAttachment | null }458
+ | { kind: 'Poll'; question: string }459
+ | { kind: 'Redacted' }460
+ | { kind: 'UnableToDecrypt'; cause: string }461
+ | { kind: 'Membership'; user_id: string; display_name: string | null; change: string }462
+ | { kind: 'Profile'; user_id: string; display_name: Change<string | null> | null; avatar: Change<string | null> | null }463
+ | { kind: 'RoomName'; name: string | null }464
+ | { kind: 'RoomTopic'; topic: string | null }465
+ | { kind: 'RoomAvatar'; avatar: string | null }466
+ | { kind: 'EncryptionEnabled' }467
+ | { kind: 'RoomCreated' }468
+ | { kind: 'RoomUpgraded'; successor: string }469
+ | { kind: 'OtherState'; event_type: string; state_key: string }470
+ | { kind: 'Other'; event_type: string };471
+472
+const REDACTED_CONTENT: TimelineContent = { kind: 'Redacted' };473
+const ENCRYPTION_ENABLED_CONTENT: TimelineContent = { kind: 'EncryptionEnabled' };474
+const ROOM_CREATED_CONTENT: TimelineContent = { kind: 'RoomCreated' };475
+476
+export function parseTimelineContent(raw: unknown): TimelineContent {477
+ if (raw === 'Redacted') return REDACTED_CONTENT;478
+ if (raw === 'EncryptionEnabled') return ENCRYPTION_ENABLED_CONTENT;479
+ if (raw === 'RoomCreated') return ROOM_CREATED_CONTENT;480
+ if (!isRecord(raw)) return REDACTED_CONTENT;481
+ if (isRecord(raw['Message'])) return { kind: 'Message', message: parseMessageBody(raw['Message']) };482
+ if (isRecord(raw['Sticker'])) {483
+ const inner = raw['Sticker'];484
+ return { kind: 'Sticker', body: stringField(inner, 'body'), media: parseMediaAttachment(inner['media']) };485
+ }486
+ if (isRecord(raw['Poll'])) {487
+ return { kind: 'Poll', question: stringField(raw['Poll'], 'question') };488
+ }489
+ if (typeof raw['UnableToDecrypt'] === 'string') {490
+ return { kind: 'UnableToDecrypt', cause: raw['UnableToDecrypt'] };491
+ }492
+ if (isRecord(raw['Membership'])) {493
+ const inner = raw['Membership'];494
+ return {495
+ kind: 'Membership',496
+ user_id: stringField(inner, 'user_id', 'unknown'),497
+ display_name: optionalString(inner, 'display_name'),498
+ change: stringField(inner, 'change', 'Other')499
+ };500
+ }501
+ if (isRecord(raw['Profile'])) {502
+ const inner = raw['Profile'];503
+ return {504
+ kind: 'Profile',505
+ user_id: stringField(inner, 'user_id', 'unknown'),506
+ display_name: parseChange(inner['display_name']),507
+ avatar: parseChange(inner['avatar'])508
+ };509
+ }510
+ if ('RoomName' in raw) {511
+ const name = raw['RoomName'];512
+ return { kind: 'RoomName', name: typeof name === 'string' ? name : null };513
+ }514
+ if ('RoomTopic' in raw) {515
+ const topic = raw['RoomTopic'];516
+ return { kind: 'RoomTopic', topic: typeof topic === 'string' ? topic : null };517
+ }518
+ if ('RoomAvatar' in raw) {519
+ const avatar = raw['RoomAvatar'];520
+ return { kind: 'RoomAvatar', avatar: typeof avatar === 'string' ? avatar : null };521
+ }522
+ if (isRecord(raw['RoomUpgraded'])) {523
+ return { kind: 'RoomUpgraded', successor: stringField(raw['RoomUpgraded'], 'successor') };524
+ }525
+ if (isRecord(raw['OtherState'])) {526
+ const inner = raw['OtherState'];527
+ return { kind: 'OtherState', event_type: stringField(inner, 'event_type'), state_key: stringField(inner, 'state_key') };528
+ }529
+ if (isRecord(raw['Other'])) {530
+ return { kind: 'Other', event_type: stringField(raw['Other'], 'event_type') };531
+ }532
+ return REDACTED_CONTENT;533
+}534
+535
+export type ReplyDetails =536
+ | { kind: 'Pending' }537
+ | { kind: 'Ready'; sender: Sender; content: TimelineContent; timestamp_ms: number }538
+ | { kind: 'Unavailable' };539
+540
+export interface ReplyReference {541
+ event_id: string;542
+ details: ReplyDetails;543
+}544
+545
+function parseReplyDetails(raw: unknown): ReplyDetails {546
+ if (raw === 'Pending') return { kind: 'Pending' };547
+ if (raw === 'Unavailable') return { kind: 'Unavailable' };548
+ if (isRecord(raw) && isRecord(raw['Ready'])) {549
+ const inner = raw['Ready'];550
+ return {551
+ kind: 'Ready',552
+ sender: parseSender(inner['sender']),553
+ content: parseTimelineContent(inner['content']),554
+ timestamp_ms: numberField(inner, 'timestamp_ms')555
+ };556
+ }557
+ return { kind: 'Unavailable' };558
+}559
+560
+function parseReplyReference(raw: unknown): ReplyReference | null {561
+ if (!isRecord(raw)) return null;562
+ const eventId = raw['event_id'];563
+ if (typeof eventId !== 'string') return null;564
+ return { event_id: eventId, details: parseReplyDetails(raw['details']) };565
+}566
+567
+export interface TimelineEvent {568
+ item_id: string;569
+ event_id: string | null;570
+ transaction_id: string | null;571
+ sender: Sender;572
+ timestamp_ms: number;573
+ own: boolean;574
+ send_state: SendState;575
+ content: TimelineContent;576
+ reply_to: ReplyReference | null;577
+ thread_root: string | null;578
+ thread_summary: ThreadSummary | null;579
+ reactions: Reaction[];580
+ read_by: string[];581
+ editable: boolean;582
+}583
+584
+function parseReactions(raw: unknown): Reaction[] {585
+ if (!Array.isArray(raw)) return [];586
+ const out: Reaction[] = [];587
+ for (const entry of raw) {588
+ if (!isRecord(entry)) continue;589
+ out.push({590
+ key: stringField(entry, 'key'),591
+ count: numberField(entry, 'count'),592
+ own: entry['own'] === true,593
+ senders: stringList(entry['senders'])594
+ });595
+ }596
+ return out;597
+}598
+599
+function parseTimelineEvent(raw: Record<string, unknown>): TimelineEvent {600
+ return {601
+ item_id: stringField(raw, 'item_id'),602
+ event_id: optionalString(raw, 'event_id'),603
+ transaction_id: optionalString(raw, 'transaction_id'),604
+ sender: parseSender(raw['sender']),605
+ timestamp_ms: numberField(raw, 'timestamp_ms'),606
+ own: raw['own'] === true,607
+ send_state: parseSendState(raw['send_state']),608
+ content: parseTimelineContent(raw['content']),609
+ reply_to: parseReplyReference(raw['reply_to']),610
+ thread_root: optionalString(raw, 'thread_root'),611
+ thread_summary: parseThreadSummary(raw['thread_summary']),612
+ reactions: parseReactions(raw['reactions']),613
+ read_by: stringList(raw['read_by']),614
+ editable: raw['editable'] === true615
+ };616
+}617
+618
+export type TimelineItem =619
+ | { kind: 'event'; event: TimelineEvent }620
+ | { kind: 'day'; timestamp_ms: number }621
+ | { kind: 'readMarker' }622
+ | { kind: 'start' }623
+ | { kind: 'unknown'; label: string };624
+625
+/** Normalize one TimelineItem from external-tag serde form. */626
+export function parseTimelineItem(raw: unknown): TimelineItem | null {627
+ if (raw === 'ReadMarker') return { kind: 'readMarker' };628
+ if (raw === 'TimelineStart') return { kind: 'start' };629
+ if (!isRecord(raw)) return null;630
+ if (isRecord(raw['Event'])) return { kind: 'event', event: parseTimelineEvent(raw['Event']) };631
+ if ('DayDivider' in raw) {632
+ const inner = raw['DayDivider'];633
+ const ts = isRecord(inner) && typeof inner['timestamp_ms'] === 'number' ? inner['timestamp_ms'] : 0;634
+ return { kind: 'day', timestamp_ms: ts };635
+ }636
+ if ('ReadMarker' in raw) return { kind: 'readMarker' };637
+ if ('TimelineStart' in raw) return { kind: 'start' };638
+ return { kind: 'unknown', label: Object.keys(raw).join(', ') || 'item' };639
+}640
+641
+export function parseTimelineList(raw: unknown): TimelineItem[] {642
+ if (!Array.isArray(raw)) return [];643
+ const out: TimelineItem[] = [];644
+ for (const entry of raw) {645
+ const item = parseTimelineItem(entry);646
+ if (item) out.push(item);647
+ }648
+ return out;649
+}@@ -0,0 +1,41 @@
1
+import { setTyping } from './api';2
+3
+const REPEAT_MS = 20000;4
+const IDLE_MS = 25000;5
+6
+let currentRoom: string | null = null;7
+let typingActive = false;8
+let lastSentAt = 0;9
+let idleTimer: number | null = null;10
+11
+function clearIdle(): void {12
+ if (idleTimer !== null) {13
+ window.clearTimeout(idleTimer);14
+ idleTimer = null;15
+ }16
+}17
+18
+function fire(roomId: string, typing: boolean): void {19
+ setTyping(roomId, typing).catch(() => undefined);20
+}21
+22
+export function notifyTyping(roomId: string): void {23
+ if (currentRoom && currentRoom !== roomId) stopTyping();24
+ currentRoom = roomId;25
+ const now = Date.now();26
+ if (!typingActive || now - lastSentAt >= REPEAT_MS) {27
+ typingActive = true;28
+ lastSentAt = now;29
+ fire(roomId, true);30
+ }31
+ clearIdle();32
+ idleTimer = window.setTimeout(() => stopTyping(), IDLE_MS);33
+}34
+35
+export function stopTyping(): void {36
+ clearIdle();37
+ const roomId = currentRoom;38
+ if (typingActive && roomId) fire(roomId, false);39
+ typingActive = false;40
+ lastSentAt = 0;41
+}@@ -0,0 +1,85 @@
1
+export interface UrlPreview {2
+ href: string;3
+ display: string;4
+ hostname: string;5
+ path: string;6
+}7
+8
+const MAX_LINKS = 4;9
+const MAX_URL_LENGTH = 2048;10
+const TRAILING_PUNCT = /[.,;:!?'"~…—–-]+$/u;11
+12
+function countChar(text: string, char: string): number {13
+ let count = 0;14
+ for (const c of text) if (c === char) count += 1;15
+ return count;16
+}17
+18
+function trimUrl(raw: string): string {19
+ let out = raw;20
+ let changed = true;21
+ while (changed && out.length > 0) {22
+ changed = false;23
+ const stripped = out.replace(TRAILING_PUNCT, '');24
+ if (stripped !== out) {25
+ out = stripped;26
+ changed = true;27
+ }28
+ // Strip a trailing closer only when it has no matching opener left in29
+ // the URL itself, so "(https://x/a_(b))" keeps the inner pair and drops30
+ // the outer markdown/paren closer.31
+ const pairs: Array<[string, string]> = [32
+ ['(', ')'],33
+ ['[', ']'],34
+ ['{', '}']35
+ ];36
+ for (const [open, close] of pairs) {37
+ if (out.endsWith(close) && countChar(out, close) > countChar(out, open)) {38
+ out = out.slice(0, -1);39
+ changed = true;40
+ }41
+ }42
+ if (out.endsWith('>')) {43
+ out = out.slice(0, -1);44
+ changed = true;45
+ }46
+ }47
+ return out;48
+}49
+50
+/**51
+ * Find http(s) URLs in message text without fetching anything: rendering is52
+ * purely local (hostname/path/link card) so no third-party request can leak53
+ * reader IPs. Callers MUST NOT auto-fetch link targets.54
+ */55
+export function findUrls(text: string): UrlPreview[] {56
+ if (!text || !text.includes('http')) return [];57
+ const out: UrlPreview[] = [];58
+ const seen = new Set<string>();59
+ const pattern = /https?:\/\/[^\s<>"'`\]]+/gi;60
+ let match: RegExpExecArray | null;61
+ while (out.length < MAX_LINKS && (match = pattern.exec(text)) !== null) {62
+ let candidate = match[0];63
+ if (candidate.length > MAX_URL_LENGTH) candidate = candidate.slice(0, MAX_URL_LENGTH);64
+ const trimmed = trimUrl(candidate);65
+ if (trimmed.length < 8) continue;66
+ let parsed: URL | null = null;67
+ try {68
+ parsed = new URL(trimmed);69
+ } catch {70
+ continue;71
+ }72
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;73
+ const href = parsed.toString();74
+ if (seen.has(href)) continue;75
+ seen.add(href);76
+ const rest = `${parsed.pathname}${parsed.search}${parsed.hash}`;77
+ out.push({78
+ href,79
+ display: trimmed.length > 72 ? `${trimmed.slice(0, 69)}…` : trimmed,80
+ hostname: parsed.hostname,81
+ path: rest && rest !== '/' ? (rest.length > 48 ? `${rest.slice(0, 45)}…` : rest) : ''82
+ });83
+ }84
+ return out;85
+}@@ -0,0 +1,16 @@
1
+export const STATUS_BY_KIND: Record<string, number> = {2
+ bad_request: 400,3
+ not_found: 404,4
+ conflict: 409,5
+ busy: 429,6
+ too_large: 413,7
+ unsupported: 415,8
+ engine_stopped: 502,9
+ upstream: 502,10
+ unauthorized: 401,11
+ store_in_use: 40912
+};13
+14
+export function statusForKind(kind: string): number {15
+ return STATUS_BY_KIND[kind] ?? 502;16
+}@@ -0,0 +1,8 @@
1
+import './app.css';2
+import App from './App.svelte';3
+4
+const target = document.getElementById('app');5
+if (!target) throw new Error('#app element missing');6
+const app = new App({ target });7
+8
+export default app;@@ -0,0 +1,474 @@
1
+import { statusForKind } from './lib/wire-status';2
+import { decodeAttachmentIv, importAttachmentKey, RangeDecryptor } from './lib/media-decrypt';3
+import { attachmentContentType } from './lib/media-sniff';4
+5
+// Prepended by vite.config.ts's precacheManifestPlugin: every emitted chunk,6
+// asset, and the wasm, as root-relative URLs.7
+const sw = self as unknown as ServiceWorkerGlobalScope & { __MALICE_PRECACHE__: string[] };8
+9
+const CACHE_NAME = 'malice-shell-v1';10
+11
+sw.addEventListener('install', (event) => {12
+ event.waitUntil(13
+ (async () => {14
+ const cache = await caches.open(CACHE_NAME);15
+ await cache.addAll(['/', '/index.html', ...sw.__MALICE_PRECACHE__]);16
+ })()17
+ );18
+ void sw.skipWaiting();19
+});20
+21
+sw.addEventListener('activate', (event) => {22
+ event.waitUntil(23
+ (async () => {24
+ const keys = await caches.keys();25
+ await Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)));26
+ await sw.clients.claim();27
+ })()28
+ );29
+});30
+31
+interface MediaReply {32
+ bytes?: Uint8Array;33
+ content_type?: string | null;34
+ error?: { kind: string; message: string };35
+}36
+37
+interface AttachmentEncryptionWire {38
+ key: string;39
+ iv: string;40
+ sha256: string;41
+}42
+43
+interface MediaFileWire {44
+ url: string;45
+ mimetype: string | null;46
+ declared_size: number | null;47
+ encryption: AttachmentEncryptionWire | null;48
+}49
+50
+type MediaLocationWire = { File: MediaFileWire } | { ServerThumbnail: { url: string; fallback: MediaFileWire } } | 'Local';51
+52
+interface LocateReply {53
+ location?: MediaLocationWire;54
+ token?: string | null;55
+ error?: { kind: string; message: string };56
+}57
+58
+const MEDIA_TIMEOUT_MS = 30000;59
+const mediaChannel = new BroadcastChannel('malice-media');60
+const pendingMedia = new Map<string, (reply: MediaReply & LocateReply) => void>();61
+let nextMediaRequestId = 0;62
+63
+mediaChannel.onmessage = (ev: MessageEvent) => {64
+ const msg = ev.data as MediaReply & LocateReply & { id: string };65
+ const resolve = pendingMedia.get(msg.id);66
+ if (!resolve) return;67
+ pendingMedia.delete(msg.id);68
+ resolve(msg);69
+};70
+71
+function requestMedia(kind: 'avatar' | 'media', fields: Record<string, string>): Promise<MediaReply | null> {72
+ const id = `${Date.now()}-${nextMediaRequestId++}`;73
+ return new Promise((resolve) => {74
+ const timer = setTimeout(() => {75
+ pendingMedia.delete(id);76
+ resolve(null);77
+ }, MEDIA_TIMEOUT_MS);78
+ pendingMedia.set(id, (reply) => {79
+ clearTimeout(timer);80
+ resolve(reply);81
+ });82
+ mediaChannel.postMessage({ id, kind, ...fields });83
+ });84
+}85
+86
+interface LocateEntry {87
+ location: MediaLocationWire;88
+ token: string | null;89
+ expiresAt: number;90
+}91
+92
+const LOCATE_TTL_MS = 5 * 60 * 1000;93
+const locateCache = new Map<string, LocateEntry>();94
+const sniffCache = new Map<string, { contentType: string | null; expiresAt: number }>();95
+96
+function requestLocate(mediaId: string, size: string): Promise<{ location: MediaLocationWire; token: string | null } | null> {97
+ const id = `${Date.now()}-${nextMediaRequestId++}`;98
+ return new Promise((resolve) => {99
+ const timer = setTimeout(() => {100
+ pendingMedia.delete(id);101
+ resolve(null);102
+ }, MEDIA_TIMEOUT_MS);103
+ pendingMedia.set(id, (reply) => {104
+ clearTimeout(timer);105
+ resolve(reply.error || !reply.location ? null : { location: reply.location, token: reply.token ?? null });106
+ });107
+ mediaChannel.postMessage({ id, kind: 'locate', media_id: mediaId, size });108
+ });109
+}110
+111
+/** In-memory only: the access token never touches Cache Storage or IndexedDB, and expires with the entry. */112
+async function locate(mediaId: string, size: string, force: boolean): Promise<LocateEntry | null> {113
+ if (!force) {114
+ const cached = locateCache.get(mediaId);115
+ if (cached && cached.expiresAt > Date.now()) return cached;116
+ }117
+ const reply = await requestLocate(mediaId, size);118
+ if (!reply) return null;119
+ const entry: LocateEntry = { ...reply, expiresAt: Date.now() + LOCATE_TTL_MS };120
+ locateCache.set(mediaId, entry);121
+ return entry;122
+}123
+124
+interface RangeSlice {125
+ body: Uint8Array;126
+ status: number;127
+ contentRange?: string;128
+}129
+130
+/** The engine hands back the whole attachment at once, so seeking in <video>/<audio> needs this SW to slice it. */131
+function sliceForRange(bytes: Uint8Array, rangeHeader: string | null): RangeSlice {132
+ const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;133
+ if (!match) return { body: bytes, status: 200 };134
+ const total = bytes.byteLength;135
+ let start = match[1] ? parseInt(match[1], 10) : undefined;136
+ let end = match[2] ? parseInt(match[2], 10) : undefined;137
+ if (start === undefined && end !== undefined) {138
+ start = Math.max(total - end, 0);139
+ end = total - 1;140
+ } else if (end === undefined) {141
+ end = total - 1;142
+ }143
+ if (start === undefined || start > end || start >= total) return { body: bytes, status: 200 };144
+ end = Math.min(end, total - 1);145
+ return { body: bytes.subarray(start, end + 1), status: 206, contentRange: `bytes ${start}-${end}/${total}` };146
+}147
+148
+function mediaResponse(reply: MediaReply | null, rangeHeader: string | null, attachment: boolean): Response {149
+ if (!reply) return new Response(null, { status: 504 });150
+ if (reply.error) return new Response(reply.error.message, { status: statusForKind(reply.error.kind) });151
+ const bytes = reply.bytes ?? new Uint8Array();152
+ const { body, status, contentRange } = sliceForRange(bytes, rangeHeader);153
+ const headers = new Headers({154
+ 'Content-Type': reply.content_type ?? 'application/octet-stream',155
+ 'Cache-Control': 'private, max-age=3600',156
+ 'X-Content-Type-Options': 'nosniff'157
+ });158
+ if (attachment) {159
+ headers.set('Content-Disposition', reply.content_type ? 'inline' : 'attachment');160
+ headers.set('Content-Security-Policy', 'sandbox');161
+ }162
+ if (contentRange) {163
+ headers.set('Content-Range', contentRange);164
+ headers.set('Accept-Ranges', 'bytes');165
+ }166
+ return new Response(body, { status, headers });167
+}168
+169
+interface ByteRange {170
+ start: number;171
+ end: number;172
+}173
+174
+/** Unlike sliceForRange, the total comes from the server's declared_size, not a buffer already in hand. */175
+function parseByteRange(rangeHeader: string | null, total: number): ByteRange | null {176
+ const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;177
+ if (!match) return null;178
+ let start = match[1] ? parseInt(match[1], 10) : undefined;179
+ let end = match[2] ? parseInt(match[2], 10) : undefined;180
+ if (start === undefined && end !== undefined) {181
+ start = Math.max(total - end, 0);182
+ end = total - 1;183
+ } else if (end === undefined) {184
+ start = start ?? 0;185
+ end = total - 1;186
+ }187
+ if (start === undefined || start > end || start >= total) return null;188
+ return { start, end: Math.min(end, total - 1) };189
+}190
+191
+function attachmentHeaders(contentType: string | null): Headers {192
+ return new Headers({193
+ 'Content-Type': contentType ?? 'application/octet-stream',194
+ 'Cache-Control': 'private, max-age=3600',195
+ 'X-Content-Type-Options': 'nosniff',196
+ 'Content-Disposition': contentType ? 'inline' : 'attachment',197
+ 'Content-Security-Policy': 'sandbox'198
+ });199
+}200
+201
+/**202
+ * The Authorization header carries the account's access token, so it's only worth the risk on the203
+ * exact authenticated media endpoints, and only when the URL being fetched is the one `locate` named204
+ * (never a redirect target, should a future caller pass one through).205
+ */206
+function shouldAuthorize(fetchUrl: string, locatedUrl: string): boolean {207
+ try {208
+ const target = new URL(fetchUrl);209
+ const located = new URL(locatedUrl);210
+ if (target.origin !== located.origin) return false;211
+ return target.pathname.startsWith('/_matrix/client/v1/media/') || target.pathname.startsWith('/_matrix/media/');212
+ } catch {213
+ return false;214
+ }215
+}216
+217
+function concatChunks(parts: Uint8Array[]): Uint8Array {218
+ const total = parts.reduce((sum, part) => sum + part.byteLength, 0);219
+ const out = new Uint8Array(total);220
+ let offset = 0;221
+ for (const part of parts) {222
+ out.set(part, offset);223
+ offset += part.byteLength;224
+ }225
+ return out;226
+}227
+228
+async function readUpTo(reader: ReadableStreamDefaultReader<Uint8Array>, limit: number): Promise<Uint8Array> {229
+ const chunks: Uint8Array[] = [];230
+ let total = 0;231
+ while (total < limit) {232
+ const { value, done } = await reader.read();233
+ if (done) break;234
+ chunks.push(value);235
+ total += value.byteLength;236
+ }237
+ reader.cancel().catch(() => undefined);238
+ return concatChunks(chunks).subarray(0, Math.min(limit, total));239
+}240
+241
+/**242
+ * Ciphertext in, plaintext out: skips the bytes the upstream sent but the caller didn't ask for243
+ * (only nonzero when the upstream ignored the Range request), decrypts, drops the sub-16-byte244
+ * alignment prefix from the very first output, and stops once `limit` bytes have been produced.245
+ */246
+async function* decryptedChunks(247
+ reader: ReadableStreamDefaultReader<Uint8Array>,248
+ decryptor: RangeDecryptor | null,249
+ skipBytes: number,250
+ dropPrefix: number,251
+ limit: number252
+): AsyncGenerator<Uint8Array> {253
+ let toSkip = skipBytes;254
+ let toDrop = dropPrefix;255
+ let remaining = limit;256
+ while (remaining > 0) {257
+ const { value, done } = await reader.read();258
+ if (done) break;259
+ let chunk = value;260
+ if (toSkip > 0) {261
+ if (chunk.byteLength <= toSkip) {262
+ toSkip -= chunk.byteLength;263
+ continue;264
+ }265
+ chunk = chunk.subarray(toSkip);266
+ toSkip = 0;267
+ }268
+ const plain = decryptor ? await decryptor.push(chunk) : chunk;269
+ if (plain.byteLength === 0) continue;270
+ const emitted = emit(plain);271
+ if (emitted) yield emitted;272
+ }273
+ if (remaining > 0 && decryptor) {274
+ const emitted = emit(await decryptor.finish());275
+ if (emitted) yield emitted;276
+ }277
+ reader.cancel().catch(() => undefined);278
+279
+ function emit(chunk: Uint8Array): Uint8Array | null {280
+ let out = chunk;281
+ if (toDrop > 0) {282
+ if (out.byteLength <= toDrop) {283
+ toDrop -= out.byteLength;284
+ return null;285
+ }286
+ out = out.subarray(toDrop);287
+ toDrop = 0;288
+ }289
+ if (out.byteLength > remaining) out = out.subarray(0, remaining);290
+ remaining -= out.byteLength;291
+ return out.byteLength > 0 ? out : null;292
+ }293
+}294
+295
+async function headSniff(mediaId: string, file: MediaFileWire, token: string | null, retried: boolean): Promise<string | null> {296
+ const cached = sniffCache.get(mediaId);297
+ if (cached && cached.expiresAt > Date.now()) return cached.contentType;298
+ const headers = new Headers({ Range: 'bytes=0-79' });299
+ if (shouldAuthorize(file.url, file.url) && token) headers.set('Authorization', `Bearer ${token}`);300
+ let upstream: Response;301
+ try {302
+ upstream = await fetch(file.url, { headers });303
+ } catch {304
+ return null;305
+ }306
+ if (upstream.status === 401 && !retried) {307
+ const fresh = await locate(mediaId, 'Full', true);308
+ if (fresh && typeof fresh.location === 'object' && 'File' in fresh.location) {309
+ return headSniff(mediaId, fresh.location.File, fresh.token, true);310
+ }311
+ }312
+ if (!upstream.body || (!upstream.ok && upstream.status !== 206)) return null;313
+ const head = await readUpTo(upstream.body.getReader(), 80);314
+ let contentType: string | null;315
+ if (file.encryption) {316
+ const key = await importAttachmentKey(file.encryption.key);317
+ const iv = decodeAttachmentIv(file.encryption.iv);318
+ const decryptor = new RangeDecryptor(key, iv, 0);319
+ contentType = attachmentContentType(concatChunks([await decryptor.push(head), await decryptor.finish()]).subarray(0, 64));320
+ } else {321
+ contentType = attachmentContentType(head.subarray(0, 64));322
+ }323
+ sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });324
+ return contentType;325
+}326
+327
+function wholeFileFallback(mediaId: string, rangeHeader: string | null): Promise<Response> {328
+ return requestMedia('media', { media_id: mediaId, size: 'Full' }).then((reply) => mediaResponse(reply, rangeHeader, true));329
+}330
+331
+async function streamFullMedia(mediaId: string, rangeHeader: string | null, entry?: LocateEntry, retried = false): Promise<Response> {332
+ const located = entry ?? (await locate(mediaId, 'Full', false));333
+ if (!located) return wholeFileFallback(mediaId, rangeHeader);334
+ if (typeof located.location !== 'object' || !('File' in located.location)) return wholeFileFallback(mediaId, rangeHeader);335
+ const file = located.location.File;336
+ const total = file.declared_size;337
+ if (total == null) return wholeFileFallback(mediaId, rangeHeader);338
+339
+ const requested = parseByteRange(rangeHeader, total);340
+ const start = requested?.start ?? 0;341
+ const end = requested?.end ?? total - 1;342
+ const alignedStart = start - (start % 16);343
+344
+ const headers = new Headers({ Range: `bytes=${alignedStart}-${end}` });345
+ if (shouldAuthorize(file.url, file.url) && located.token) headers.set('Authorization', `Bearer ${located.token}`);346
+347
+ let upstream: Response;348
+ try {349
+ upstream = await fetch(file.url, { headers });350
+ } catch {351
+ return new Response('media upstream unreachable', { status: 502 });352
+ }353
+ if (upstream.status === 401 && !retried) {354
+ const fresh = await locate(mediaId, 'Full', true);355
+ if (fresh) return streamFullMedia(mediaId, rangeHeader, fresh, true);356
+ }357
+ if (!upstream.body || (!upstream.ok && upstream.status !== 206)) {358
+ return new Response('media upstream error', { status: 502 });359
+ }360
+361
+ const decryptor = file.encryption362
+ ? new RangeDecryptor(await importAttachmentKey(file.encryption.key), decodeAttachmentIv(file.encryption.iv), alignedStart / 16)363
+ : null;364
+ const skipBytes = upstream.status === 206 ? 0 : alignedStart;365
+ const dropPrefix = start - alignedStart;366
+ const limit = end - start + 1;367
+368
+ const reader = upstream.body.getReader();369
+ const chunks = decryptedChunks(reader, decryptor, skipBytes, dropPrefix, limit);370
+371
+ const prefetched: Uint8Array[] = [];372
+ let prefetchedLen = 0;373
+ let chunksDone = false;374
+ let contentType: string | null;375
+376
+ if (alignedStart === 0) {377
+ while (prefetchedLen < 64) {378
+ const step = await chunks.next();379
+ if (step.done) {380
+ chunksDone = true;381
+ break;382
+ }383
+ prefetched.push(step.value);384
+ prefetchedLen += step.value.byteLength;385
+ }386
+ contentType = attachmentContentType(concatChunks(prefetched).subarray(0, Math.min(64, prefetchedLen)));387
+ sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });388
+ } else {389
+ contentType = await headSniff(mediaId, file, located.token, retried);390
+ }391
+392
+ const body = new ReadableStream<Uint8Array>({393
+ async pull(controller) {394
+ const next = prefetched.shift();395
+ if (next) {396
+ controller.enqueue(next);397
+ return;398
+ }399
+ if (chunksDone) {400
+ controller.close();401
+ return;402
+ }403
+ const step = await chunks.next();404
+ if (step.done) controller.close();405
+ else controller.enqueue(step.value);406
+ },407
+ cancel() {408
+ reader.cancel().catch(() => undefined);409
+ }410
+ });411
+412
+ const responseHeaders = attachmentHeaders(contentType);413
+ responseHeaders.set('Accept-Ranges', 'bytes');414
+ if (requested) {415
+ responseHeaders.set('Content-Range', `bytes ${start}-${end}/${total}`);416
+ return new Response(body, { status: 206, headers: responseHeaders });417
+ }418
+ return new Response(body, { status: 200, headers: responseHeaders });419
+}420
+421
+async function cacheFirst(request: Request): Promise<Response> {422
+ const cached = await caches.match(request);423
+ if (cached) return cached;424
+ const response = await fetch(request);425
+ if (response.ok) {426
+ const cache = await caches.open(CACHE_NAME);427
+ void cache.put(request, response.clone());428
+ }429
+ return response;430
+}431
+432
+async function networkFirstNavigation(request: Request): Promise<Response> {433
+ try {434
+ return await fetch(request);435
+ } catch {436
+ const cached = await caches.match('/index.html');437
+ if (cached) return cached;438
+ throw new Error('offline and no cached shell');439
+ }440
+}441
+442
+sw.addEventListener('fetch', (event) => {443
+ const url = new URL(event.request.url);444
+ const range = event.request.headers.get('Range');445
+446
+ if (url.pathname === '/api/media') {447
+ const mxc = url.searchParams.get('mxc');448
+ if (mxc) {449
+ event.respondWith(requestMedia('avatar', { mxc }).then((reply) => mediaResponse(reply, range, false)));450
+ return;451
+ }452
+ }453
+ if (url.pathname === '/api/message-media') {454
+ const mediaId = url.searchParams.get('media_id');455
+ const size = url.searchParams.get('size');456
+ if (mediaId && size === 'Full') {457
+ event.respondWith(streamFullMedia(mediaId, range));458
+ return;459
+ }460
+ if (mediaId && size) {461
+ event.respondWith(462
+ requestMedia('media', { media_id: mediaId, size }).then((reply) => mediaResponse(reply, range, true))463
+ );464
+ return;465
+ }466
+ }467
+ if (url.pathname.startsWith('/assets/')) {468
+ event.respondWith(cacheFirst(event.request));469
+ return;470
+ }471
+ if (event.request.mode === 'navigate') {472
+ event.respondWith(networkFirstNavigation(event.request));473
+ }474
+});@@ -0,0 +1 @@
1
+/// <reference types="vite/client" />@@ -0,0 +1,131 @@
1
+// IndexedDB account registry. Store passphrases are 32 random bytes, kept2
+// only encrypted with a non-extractable AES-GCM key that never leaves this3
+// database, so a compromised script here still can't read a persisted4
+// passphrase back out in the clear.5
+const DB_NAME = 'malice-accounts';6
+const DB_VERSION = 1;7
+const ACCOUNTS_STORE = 'accounts';8
+const KEYS_STORE = 'keys';9
+const WRAP_KEY_ID = 'wrap-key';10
+11
+interface AccountRow {12
+ key: string;13
+ user_id: string | null;14
+ homeserver: string | null;15
+ wrapped_passphrase: ArrayBuffer;16
+ iv: ArrayBuffer;17
+}18
+19
+export interface AccountInfo {20
+ key: string;21
+ user_id: string | null;22
+ homeserver: string | null;23
+}24
+25
+export interface NewAccount {26
+ key: string;27
+ passphrase: string;28
+}29
+30
+function openDb(): Promise<IDBDatabase> {31
+ return new Promise((resolve, reject) => {32
+ const req = indexedDB.open(DB_NAME, DB_VERSION);33
+ req.onupgradeneeded = () => {34
+ const db = req.result;35
+ if (!db.objectStoreNames.contains(ACCOUNTS_STORE)) db.createObjectStore(ACCOUNTS_STORE, { keyPath: 'key' });36
+ if (!db.objectStoreNames.contains(KEYS_STORE)) db.createObjectStore(KEYS_STORE);37
+ };38
+ req.onsuccess = () => resolve(req.result);39
+ req.onerror = () => reject(req.error);40
+ });41
+}42
+43
+function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {44
+ return new Promise((resolve, reject) => {45
+ request.onsuccess = () => resolve(request.result);46
+ request.onerror = () => reject(request.error);47
+ });48
+}49
+50
+function runTx<T>(51
+ db: IDBDatabase,52
+ stores: string[],53
+ mode: IDBTransactionMode,54
+ fn: (tx: IDBTransaction) => Promise<T> | T55
+): Promise<T> {56
+ return new Promise((resolve, reject) => {57
+ const tx = db.transaction(stores, mode);58
+ let result: T;59
+ Promise.resolve(fn(tx))60
+ .then((value) => {61
+ result = value;62
+ })63
+ .catch(reject);64
+ tx.oncomplete = () => resolve(result);65
+ tx.onerror = () => reject(tx.error);66
+ tx.onabort = () => reject(tx.error ?? new Error('account registry transaction aborted'));67
+ });68
+}69
+70
+async function getWrapKey(db: IDBDatabase): Promise<CryptoKey> {71
+ const existing = await runTx(db, [KEYS_STORE], 'readonly', (tx) =>72
+ requestToPromise(tx.objectStore(KEYS_STORE).get(WRAP_KEY_ID))73
+ );74
+ if (existing) return existing as CryptoKey;75
+ const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);76
+ await runTx(db, [KEYS_STORE], 'readwrite', (tx) => requestToPromise(tx.objectStore(KEYS_STORE).put(key, WRAP_KEY_ID)));77
+ return key;78
+}79
+80
+function randomHex(byteLength: number): string {81
+ const bytes = crypto.getRandomValues(new Uint8Array(byteLength));82
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');83
+}84
+85
+export async function listAccounts(): Promise<AccountInfo[]> {86
+ const db = await openDb();87
+ const rows = (await runTx(db, [ACCOUNTS_STORE], 'readonly', (tx) =>88
+ requestToPromise(tx.objectStore(ACCOUNTS_STORE).getAll())89
+ )) as AccountRow[];90
+ return rows.map((row) => ({ key: row.key, user_id: row.user_id, homeserver: row.homeserver }));91
+}92
+93
+export async function createAccount(): Promise<NewAccount> {94
+ const db = await openDb();95
+ const wrapKey = await getWrapKey(db);96
+ const key = randomHex(16);97
+ const passphrase = randomHex(32);98
+ const iv = crypto.getRandomValues(new Uint8Array(12));99
+ const wrapped = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapKey, new TextEncoder().encode(passphrase));100
+ const row: AccountRow = { key, user_id: null, homeserver: null, wrapped_passphrase: wrapped, iv: iv.buffer };101
+ await runTx(db, [ACCOUNTS_STORE], 'readwrite', (tx) => requestToPromise(tx.objectStore(ACCOUNTS_STORE).put(row)));102
+ return { key, passphrase };103
+}104
+105
+export async function getPassphrase(accountKey: string): Promise<string> {106
+ const db = await openDb();107
+ const wrapKey = await getWrapKey(db);108
+ const row = (await runTx(db, [ACCOUNTS_STORE], 'readonly', (tx) =>109
+ requestToPromise(tx.objectStore(ACCOUNTS_STORE).get(accountKey))110
+ )) as AccountRow | undefined;111
+ if (!row) throw new Error(`unknown account ${accountKey}`);112
+ const bytes = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: row.iv }, wrapKey, row.wrapped_passphrase);113
+ return new TextDecoder().decode(bytes);114
+}115
+116
+export async function setIdentity(accountKey: string, userId: string, homeserver: string): Promise<void> {117
+ const db = await openDb();118
+ await runTx(db, [ACCOUNTS_STORE], 'readwrite', async (tx) => {119
+ const store = tx.objectStore(ACCOUNTS_STORE);120
+ const row = (await requestToPromise(store.get(accountKey))) as AccountRow | undefined;121
+ if (!row) return;122
+ row.user_id = userId;123
+ row.homeserver = homeserver;124
+ store.put(row);125
+ });126
+}127
+128
+export async function deleteAccount(accountKey: string): Promise<void> {129
+ const db = await openDb();130
+ await runTx(db, [ACCOUNTS_STORE], 'readwrite', (tx) => requestToPromise(tx.objectStore(ACCOUNTS_STORE).delete(accountKey)));131
+}@@ -0,0 +1,314 @@
1
+import init, { Host } from './pkg/malice_worker.js';2
+import { isRecord } from '../lib/guards';3
+import {4
+ createAccount,5
+ deleteAccount,6
+ getPassphrase,7
+ listAccounts,8
+ setIdentity,9
+ type AccountInfo10
+} from './accounts';11
+12
+interface PortLike {13
+ postMessage(message: unknown, transfer?: Transferable[]): void;14
+}15
+16
+interface WireError {17
+ kind: string;18
+ message: string;19
+}20
+21
+// Host.call()'s wasm-bindgen signature is untyped (Promise<any>); this is the22
+// JSON shape the malice-worker contract actually returns.23
+interface CallOk {24
+ json?: unknown;25
+ bytes?: Uint8Array;26
+ content_type?: string | null;27
+}28
+29
+interface Identity {30
+ user_id: string;31
+ homeserver: string;32
+}33
+34
+const ports = new Set<PortLike>();35
+const subscriptions = new Map<string, Set<PortLike>>();36
+const liveAccounts = new Set<string>();37
+const softAccounts = new Map<string, Identity>();38
+const blockedAccounts = new Set<string>();39
+const accountIdentity = new Map<string, Identity>();40
+41
+let host: Host;42
+43
+function postTo(port: PortLike, message: Record<string, unknown>): void {44
+ port.postMessage(message);45
+}46
+47
+function broadcastEvent(account: string, event: string): void {48
+ for (const port of subscriptions.get(account) ?? []) postTo(port, { kind: 'event', account, event });49
+}50
+51
+function toWireError(error: unknown): WireError {52
+ if (isRecord(error) && typeof error['kind'] === 'string') {53
+ return { kind: error['kind'], message: typeof error['message'] === 'string' ? error['message'] : 'unknown error' };54
+ }55
+ return { kind: 'upstream', message: error instanceof Error ? error.message : 'unknown error' };56
+}57
+58
+function usernameMatches(submitted: string, userId: string): boolean {59
+ const trimmed = submitted.trim();60
+ if (trimmed === userId) return true;61
+ const localpart = userId.startsWith('@') ? userId.slice(1).split(':')[0] : userId;62
+ return trimmed === localpart;63
+}64
+65
+function currentAccountKey(): string | null {66
+ for (const key of liveAccounts) return key;67
+ return null;68
+}69
+70
+function onSessionEnded(account: string, retain: unknown): void {71
+ liveAccounts.delete(account);72
+ const identity = accountIdentity.get(account);73
+ if (retain === 'nothing') {74
+ accountIdentity.delete(account);75
+ void deleteAccount(account).catch(() => undefined);76
+ } else if (identity) {77
+ softAccounts.set(account, identity);78
+ }79
+ broadcastEvent(account, JSON.stringify({ type: 'session_ended', retain }));80
+}81
+82
+function onHostEvent(account: string, eventJson: string): void {83
+ let parsed: unknown;84
+ try {85
+ parsed = JSON.parse(eventJson);86
+ } catch {87
+ return;88
+ }89
+ if (isRecord(parsed) && parsed['type'] === 'session_ended') {90
+ onSessionEnded(account, parsed['retain']);91
+ return;92
+ }93
+ broadcastEvent(account, eventJson);94
+}95
+96
+function onHostFatal(message: string): void {97
+ for (const port of ports) postTo(port, { kind: 'fatal', message });98
+ close();99
+}100
+101
+async function restoreAccount(account: AccountInfo): Promise<void> {102
+ try {103
+ const passphrase = await getPassphrase(account.key);104
+ const result = await host.restore(account.key, passphrase);105
+ if (result.status === 'resumed' && result.user_id) {106
+ accountIdentity.set(account.key, { user_id: result.user_id, homeserver: account.homeserver ?? '' });107
+ liveAccounts.add(account.key);108
+ } else if (result.status === 'soft_logged_out' && result.user_id && result.homeserver) {109
+ softAccounts.set(account.key, { user_id: result.user_id, homeserver: result.homeserver });110
+ }111
+ } catch (error) {112
+ const wire = toWireError(error);113
+ if (wire.kind === 'store_in_use') blockedAccounts.add(account.key);114
+ else await deleteAccount(account.key).catch(() => undefined);115
+ }116
+}117
+118
+const ready = (async () => {119
+ await init();120
+ host = new Host(onHostEvent, onHostFatal);121
+ for (const account of await listAccounts()) await restoreAccount(account);122
+})();123
+124
+interface SessionReply {125
+ user_id: string | null;126
+ soft_logout?: Identity;127
+ account?: string;128
+ blocked?: true;129
+}130
+131
+function handleSession(): SessionReply {132
+ const liveKey = currentAccountKey();133
+ if (liveKey) {134
+ const identity = accountIdentity.get(liveKey);135
+ return { user_id: identity?.user_id ?? null, account: liveKey };136
+ }137
+ if (blockedAccounts.size > 0) return { user_id: null, blocked: true };138
+ const soft = softAccounts.values().next();139
+ if (!soft.done) return { user_id: null, soft_logout: soft.value };140
+ return { user_id: null };141
+}142
+143
+async function handleLogin(144
+ homeserver: string,145
+ username: string,146
+ password: string147
+): Promise<{ user_id: string; account: string }> {148
+ let targetKey: string | null = null;149
+ for (const [key, identity] of softAccounts) {150
+ if (usernameMatches(username, identity.user_id)) {151
+ targetKey = key;152
+ break;153
+ }154
+ }155
+ const reused = targetKey !== null;156
+ let passphrase: string;157
+ if (targetKey) {158
+ passphrase = await getPassphrase(targetKey);159
+ } else {160
+ const created = await createAccount();161
+ targetKey = created.key;162
+ passphrase = created.passphrase;163
+ }164
+ try {165
+ const result = await host.login(targetKey, passphrase, homeserver, username, password);166
+ await setIdentity(targetKey, result.user_id, homeserver);167
+ accountIdentity.set(targetKey, { user_id: result.user_id, homeserver });168
+ softAccounts.delete(targetKey);169
+ liveAccounts.add(targetKey);170
+ return { user_id: result.user_id, account: targetKey };171
+ } catch (error) {172
+ if (!reused) await deleteAccount(targetKey).catch(() => undefined);173
+ throw error;174
+ }175
+}176
+177
+async function handleLogout(account: string): Promise<void> {178
+ await host.logout(account);179
+}180
+181
+async function handleCall(account: string, request: unknown, bytes?: ArrayBuffer): Promise<CallOk> {182
+ return host.call(account, JSON.stringify(request), bytes ? new Uint8Array(bytes) : undefined);183
+}184
+185
+function handleSubscribe(port: PortLike, account: string): void {186
+ let set = subscriptions.get(account);187
+ if (!set) {188
+ set = new Set();189
+ subscriptions.set(account, set);190
+ }191
+ set.add(port);192
+ if (liveAccounts.has(account)) {193
+ const snapshot = host.snapshot(account);194
+ if (snapshot) postTo(port, { kind: 'event', account, event: snapshot });195
+ }196
+}197
+198
+function handleUnsubscribe(port: PortLike, account: string): void {199
+ subscriptions.get(account)?.delete(port);200
+}201
+202
+function dropPort(port: PortLike): void {203
+ ports.delete(port);204
+ for (const set of subscriptions.values()) set.delete(port);205
+}206
+207
+async function dispatch(port: PortLike, data: unknown): Promise<void> {208
+ await ready;209
+ if (!isRecord(data)) return;210
+ const kind = data['kind'];211
+ const id = data['id'];212
+ const reply = (ok: unknown) => {213
+ if (typeof id === 'number') postTo(port, { kind: 'reply', id, ok });214
+ };215
+ const fail = (error: unknown) => {216
+ if (typeof id === 'number') postTo(port, { kind: 'reply', id, error: toWireError(error) });217
+ };218
+219
+ switch (kind) {220
+ case 'session':221
+ reply(handleSession());222
+ return;223
+ case 'login': {224
+ const { homeserver, username, password } = data as { homeserver: string; username: string; password: string };225
+ try {226
+ reply(await handleLogin(homeserver, username, password));227
+ } catch (error) {228
+ fail(error);229
+ }230
+ return;231
+ }232
+ case 'logout': {233
+ const { account } = data as { account: string };234
+ try {235
+ await handleLogout(account);236
+ reply({});237
+ } catch (error) {238
+ fail(error);239
+ }240
+ return;241
+ }242
+ case 'call': {243
+ const { account, request, bytes } = data as { account: string; request: unknown; bytes?: ArrayBuffer };244
+ try {245
+ reply(await handleCall(account, request, bytes));246
+ } catch (error) {247
+ fail(error);248
+ }249
+ return;250
+ }251
+ case 'subscribe':252
+ handleSubscribe(port, (data as { account: string }).account);253
+ return;254
+ case 'unsubscribe':255
+ handleUnsubscribe(port, (data as { account: string }).account);256
+ return;257
+ case 'bye':258
+ dropPort(port);259
+ return;260
+ }261
+}262
+263
+type WorkerScope = DedicatedWorkerGlobalScope & Partial<SharedWorkerGlobalScope>;264
+const scope = self as unknown as WorkerScope;265
+266
+if (typeof scope.onconnect !== 'undefined') {267
+ scope.onconnect = (ev: MessageEvent) => {268
+ const port = ev.ports[0];269
+ ports.add(port);270
+ port.onmessage = (msg: MessageEvent) => void dispatch(port, msg.data);271
+ port.start();272
+ };273
+} else {274
+ ports.add(scope);275
+ scope.onmessage = (msg: MessageEvent) => void dispatch(scope, msg.data);276
+}277
+278
+const mediaChannel = new BroadcastChannel('malice-media');279
+mediaChannel.onmessage = (ev: MessageEvent) => void handleMediaRequest(ev.data);280
+281
+async function handleMediaRequest(msg: unknown): Promise<void> {282
+ await ready;283
+ if (!isRecord(msg) || typeof msg['id'] !== 'string') return;284
+ const id = msg['id'];285
+ const account = currentAccountKey();286
+ if (!account) {287
+ mediaChannel.postMessage({ id, error: { kind: 'not_found', message: 'no account signed in' } });288
+ return;289
+ }290
+ if (msg['kind'] === 'locate' && typeof msg['media_id'] === 'string' && typeof msg['size'] === 'string') {291
+ try {292
+ const result = await host.locate(account, msg['media_id'], msg['size']);293
+ mediaChannel.postMessage({ id, location: result.location, token: result.token ?? null });294
+ } catch (error) {295
+ mediaChannel.postMessage({ id, error: toWireError(error) });296
+ }297
+ return;298
+ }299
+ let request: Record<string, unknown>;300
+ if (msg['kind'] === 'avatar' && typeof msg['mxc'] === 'string') {301
+ request = { op: 'avatar', mxc: msg['mxc'] };302
+ } else if (msg['kind'] === 'media' && typeof msg['media_id'] === 'string' && typeof msg['size'] === 'string') {303
+ request = { op: 'media', media_id: msg['media_id'], size: msg['size'] };304
+ } else {305
+ mediaChannel.postMessage({ id, error: { kind: 'bad_request', message: 'invalid media request' } });306
+ return;307
+ }308
+ try {309
+ const result = await host.call(account, JSON.stringify(request));310
+ mediaChannel.postMessage({ id, bytes: result.bytes, content_type: result.content_type ?? null });311
+ } catch (error) {312
+ mediaChannel.postMessage({ id, error: toWireError(error) });313
+ }314
+}@@ -0,0 +1,110 @@
1
+import assert from 'node:assert/strict';2
+import { test } from 'node:test';3
+import { addBlockOffset, importAttachmentKey, RangeDecryptor } from '../src/lib/media-decrypt.js';4
+import { attachmentContentType, imageContentType } from '../src/lib/media-sniff.js';5
+6
+function randomBytes(length: number): Uint8Array {7
+ const bytes = new Uint8Array(length);8
+ crypto.getRandomValues(bytes);9
+ return bytes;10
+}11
+12
+function textBytes(text: string): Uint8Array {13
+ return new TextEncoder().encode(text);14
+}15
+16
+function concatBytes(parts: Uint8Array[]): Uint8Array {17
+ const total = parts.reduce((sum, part) => sum + part.length, 0);18
+ const out = new Uint8Array(total);19
+ let offset = 0;20
+ for (const part of parts) {21
+ out.set(part, offset);22
+ offset += part.length;23
+ }24
+ return out;25
+}26
+27
+function base64url(bytes: Uint8Array): string {28
+ let binary = '';29
+ for (const byte of bytes) binary += String.fromCharCode(byte);30
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');31
+}32
+33
+async function encryptWhole(rawKey: Uint8Array, iv: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array> {34
+ const encryptKey = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-CTR' }, false, ['encrypt']);35
+ const counter = addBlockOffset(iv, 0);36
+ const cipher = await crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, encryptKey, plaintext);37
+ return new Uint8Array(cipher);38
+}39
+40
+async function decryptRange(41
+ key: CryptoKey,42
+ iv: Uint8Array,43
+ ciphertext: Uint8Array,44
+ start: number,45
+ end: number,46
+ chunkSize: number47
+): Promise<Uint8Array> {48
+ const alignedStart = start - (start % 16);49
+ const slice = ciphertext.subarray(alignedStart, end + 1);50
+ const decryptor = new RangeDecryptor(key, iv, alignedStart / 16);51
+ const parts: Uint8Array[] = [];52
+ for (let offset = 0; offset < slice.length; offset += chunkSize) {53
+ const chunk = slice.subarray(offset, Math.min(offset + chunkSize, slice.length));54
+ const plain = await decryptor.push(chunk);55
+ if (plain.length > 0) parts.push(plain);56
+ }57
+ const last = await decryptor.finish();58
+ if (last.length > 0) parts.push(last);59
+ return concatBytes(parts).subarray(start - alignedStart);60
+}61
+62
+async function setup(plaintextLength: number) {63
+ const rawKey = randomBytes(32);64
+ const iv = randomBytes(16);65
+ const key = await importAttachmentKey(base64url(rawKey));66
+ const plaintext = randomBytes(plaintextLength);67
+ const ciphertext = await encryptWhole(rawKey, iv, plaintext);68
+ return { key, iv, plaintext, ciphertext };69
+}70
+71
+test('range decryptor: whole read matches the plaintext', async () => {72
+ const { key, iv, plaintext, ciphertext } = await setup(1000);73
+ const decryptor = new RangeDecryptor(key, iv, 0);74
+ const decrypted = concatBytes([await decryptor.push(ciphertext), await decryptor.finish()]);75
+ assert.deepEqual(decrypted, plaintext);76
+});77
+78
+test('range decryptor: unaligned start and end', async () => {79
+ const { key, iv, plaintext, ciphertext } = await setup(1000);80
+ const start = 37;81
+ const end = 601;82
+ const got = await decryptRange(key, iv, ciphertext, start, end, 512);83
+ assert.deepEqual(got, plaintext.subarray(start, end + 1));84
+});85
+86
+test('range decryptor: chunk sizes that split blocks', async () => {87
+ const { key, iv, plaintext, ciphertext } = await setup(1000);88
+ const start = 100;89
+ const end = 900;90
+ const got = await decryptRange(key, iv, ciphertext, start, end, 7);91
+ assert.deepEqual(got, plaintext.subarray(start, end + 1));92
+});93
+94
+test('range decryptor: range starting past the first block', async () => {95
+ const { key, iv, plaintext, ciphertext } = await setup(2000);96
+ const start = 1040;97
+ const end = 1500;98
+ const got = await decryptRange(key, iv, ciphertext, start, end, 33);99
+ assert.deepEqual(got, plaintext.subarray(start, end + 1));100
+});101
+102
+test('sniff: recognises a few magic numbers', () => {103
+ assert.equal(imageContentType(Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), 'image/png');104
+ assert.equal(imageContentType(Uint8Array.from([0xff, 0xd8, 0xff, 0xe0])), 'image/jpeg');105
+ assert.equal(attachmentContentType(textBytes('OggS')), 'audio/ogg');106
+ assert.equal(attachmentContentType(Uint8Array.from([0x1a, 0x45, 0xdf, 0xa3])), 'video/webm');107
+ const mp4 = concatBytes([new Uint8Array(4), textBytes('ftyp'), textBytes('isom')]);108
+ assert.equal(attachmentContentType(mp4), 'video/mp4');109
+ assert.equal(attachmentContentType(textBytes('not a media file')), null);110
+});@@ -0,0 +1,15 @@
1
+// No @types/node in this project (kept dependency-free), so these are the2
+// minimal ambient declarations node --test and node:assert/strict need to3
+// type-check under tsconfig.test.json's DOM-flavoured lib.4
+declare module 'node:test' {5
+ export function test(name: string, fn: () => void | Promise<void>): void;6
+}7
+8
+declare module 'node:assert/strict' {9
+ interface Assert {10
+ equal(actual: unknown, expected: unknown, message?: string): void;11
+ deepEqual(actual: unknown, expected: unknown, message?: string): void;12
+ }13
+ const assert: Assert;14
+ export default assert;15
+}@@ -0,0 +1,14 @@
1
+{2
+ "compilerOptions": {3
+ "target": "ES2022",4
+ "module": "ESNext",5
+ "moduleResolution": "bundler",6
+ "strict": true,7
+ "skipLibCheck": true,8
+ "isolatedModules": true,9
+ "resolveJsonModule": true,10
+ "types": ["vite/client"]11
+ },12
+ "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"],13
+ "exclude": ["src/worker/**", "src/sw.ts"]14
+}@@ -0,0 +1,11 @@
1
+{2
+ "compilerOptions": {3
+ "target": "ES2022",4
+ "module": "NodeNext",5
+ "moduleResolution": "NodeNext",6
+ "strict": true,7
+ "skipLibCheck": true,8
+ "outDir": ".test-dist"9
+ },10
+ "include": ["src/lib/media-decrypt.ts", "src/lib/media-sniff.ts", "test/media.test.ts", "test/node-shims.d.ts"]11
+}@@ -0,0 +1,15 @@
1
+{2
+ "compilerOptions": {3
+ "target": "ES2022",4
+ "module": "ESNext",5
+ "moduleResolution": "bundler",6
+ "lib": ["ES2022", "WebWorker"],7
+ "strict": true,8
+ "skipLibCheck": true,9
+ "isolatedModules": true,10
+ "resolveJsonModule": true,11
+ "types": ["vite/client"],12
+ "noEmit": true13
+ },14
+ "include": ["src/worker/**/*.ts", "src/sw.ts", "src/lib/wire-status.ts", "src/lib/guards.ts"]15
+}@@ -0,0 +1,70 @@
1
+import { defineConfig } from 'vite';2
+import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte';3
+4
+/**5
+ * The page isn't SW-controlled on first launch, so the worker chunk and the6
+ * wasm can bypass fetch interception entirely and never get cached. Instead7
+ * of relying on runtime fetches, prepend the full list of emitted files8
+ * (chunks, assets, wasm) to sw.js as a global so install() can precache all9
+ * of it directly, no separate request needed.10
+ */11
+function precacheManifestPlugin(): import('vite').Plugin {12
+ return {13
+ name: 'malice-sw-precache-manifest',14
+ generateBundle(_options, bundle) {15
+ const urls = Object.keys(bundle)16
+ .filter((fileName) => fileName !== 'sw.js' && fileName !== '.vite/manifest.json')17
+ .map((fileName) => `/${fileName}`);18
+ const swChunk = bundle['sw.js'];19
+ if (swChunk && swChunk.type === 'chunk') {20
+ swChunk.code = `self.__MALICE_PRECACHE__=${JSON.stringify(urls)};\n${swChunk.code}`;21
+ }22
+ }23
+ };24
+}25
+26
+export default defineConfig(({ mode }) => {27
+ const device = mode === 'device';28
+ return {29
+ plugins: [svelte({ preprocess: vitePreprocess() }), ...(device ? [precacheManifestPlugin()] : [])],30
+ assetsInclude: ['**/*.wasm'],31
+ resolve: {32
+ alias: {33
+ 'malice:device-transport': device ? '/src/lib/device-transport.ts' : '/src/lib/device-transport.stub.ts'34
+ }35
+ },36
+ server: {37
+ host: '127.0.0.1',38
+ port: 5173,39
+ proxy: {40
+ '/api': 'http://localhost:3000'41
+ }42
+ },43
+ preview: {44
+ host: '127.0.0.1',45
+ port: 4173,46
+ proxy: {47
+ '/api': 'http://localhost:3000'48
+ }49
+ },50
+ build: device51
+ ? {52
+ outDir: 'dist-device',53
+ emptyOutDir: true,54
+ manifest: true,55
+ rollupOptions: {56
+ input: {57
+ main: 'index.html',58
+ sw: 'src/sw.ts'59
+ },60
+ output: {61
+ entryFileNames: (chunk) => (chunk.name === 'sw' ? 'sw.js' : 'assets/[name]-[hash].js')62
+ }63
+ }64
+ }65
+ : {66
+ outDir: 'dist',67
+ emptyOutDir: true68
+ }69
+ };70
+});