atagen

macltok

public
Star0
Watch0

Commit 96fdf90aacd6

tarball

atagen <boss@atagen.co>2026-09-24 12:21 UTC

32 files changed+17892-0
toolkit: build compact Matrix client backend
Added file: .gitignore+2 additions
@@ -0,0 +1,2 @@
1+/target
2+
Added file: Cargo.lock+4728 additions
@@ -0,0 +1,4728 @@
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
5+[[package]]
6+name = "accessory"
7+version = "2.1.1"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "cc86a613208ca7d144ca24f6ec49c5cdfa5c05a46f2f7ab5880ff99371f165de"
10+dependencies = [
11+ "macroific 3.0.1",
12+ "proc-macro2",
13+ "quote",
14+ "syn 3.0.5",
15+]
16+
17+[[package]]
18+name = "adler2"
19+version = "2.0.1"
20+source = "registry+https://github.com/rust-lang/crates.io-index"
21+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
22+
23+[[package]]
24+name = "aead"
25+version = "0.5.2"
26+source = "registry+https://github.com/rust-lang/crates.io-index"
27+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
28+dependencies = [
29+ "crypto-common",
30+ "generic-array",
31+]
32+
33+[[package]]
34+name = "aes"
35+version = "0.8.4"
36+source = "registry+https://github.com/rust-lang/crates.io-index"
37+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
38+dependencies = [
39+ "cfg-if",
40+ "cipher",
41+ "cpufeatures 0.2.17",
42+]
43+
44+[[package]]
45+name = "aho-corasick"
46+version = "1.1.5"
47+source = "registry+https://github.com/rust-lang/crates.io-index"
48+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
49+dependencies = [
50+ "memchr",
51+]
52+
53+[[package]]
54+name = "android_system_properties"
55+version = "0.1.6"
56+source = "registry+https://github.com/rust-lang/crates.io-index"
57+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
58+dependencies = [
59+ "libc",
60+]
61+
62+[[package]]
63+name = "anyhow"
64+version = "1.0.104"
65+source = "registry+https://github.com/rust-lang/crates.io-index"
66+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
67+
68+[[package]]
69+name = "anymap2"
70+version = "0.13.0"
71+source = "registry+https://github.com/rust-lang/crates.io-index"
72+checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
73+
74+[[package]]
75+name = "aquamarine"
76+version = "0.6.0"
77+source = "registry+https://github.com/rust-lang/crates.io-index"
78+checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2"
79+dependencies = [
80+ "include_dir",
81+ "itertools 0.10.5",
82+ "proc-macro-error2",
83+ "proc-macro2",
84+ "quote",
85+ "syn 2.0.119",
86+]
87+
88+[[package]]
89+name = "archery"
90+version = "1.2.3"
91+source = "registry+https://github.com/rust-lang/crates.io-index"
92+checksum = "33ca55ee147b1926dbea904f50fe4902494e97bc742205abbbf10c709e43815f"
93+
94+[[package]]
95+name = "arrayvec"
96+version = "0.7.8"
97+source = "registry+https://github.com/rust-lang/crates.io-index"
98+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
99+dependencies = [
100+ "serde",
101+]
102+
103+[[package]]
104+name = "as_variant"
105+version = "1.3.0"
106+source = "registry+https://github.com/rust-lang/crates.io-index"
107+checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a"
108+
109+[[package]]
110+name = "assert-json-diff"
111+version = "2.0.2"
112+source = "registry+https://github.com/rust-lang/crates.io-index"
113+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
114+dependencies = [
115+ "serde",
116+ "serde_json",
117+]
118+
119+[[package]]
120+name = "assert_matches"
121+version = "1.5.0"
122+source = "registry+https://github.com/rust-lang/crates.io-index"
123+checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
124+
125+[[package]]
126+name = "assert_matches2"
127+version = "0.1.2"
128+source = "registry+https://github.com/rust-lang/crates.io-index"
129+checksum = "15832d94c458da98cac0ffa6eca52cc19c2a3c6c951058500a5ae8f01f0fdf56"
130+
131+[[package]]
132+name = "assign"
133+version = "1.1.1"
134+source = "registry+https://github.com/rust-lang/crates.io-index"
135+checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002"
136+
137+[[package]]
138+name = "async-channel"
139+version = "2.5.0"
140+source = "registry+https://github.com/rust-lang/crates.io-index"
141+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
142+dependencies = [
143+ "concurrent-queue",
144+ "event-listener-strategy",
145+ "futures-core",
146+ "pin-project-lite",
147+]
148+
149+[[package]]
150+name = "async-compression"
151+version = "0.4.45"
152+source = "registry+https://github.com/rust-lang/crates.io-index"
153+checksum = "24a8ec73eb862508b7041723c89386365894b4e7d9f6998bf1b8529e5b0ee254"
154+dependencies = [
155+ "compression-codecs",
156+ "compression-core",
157+ "pin-project-lite",
158+ "tokio",
159+]
160+
161+[[package]]
162+name = "async-once-cell"
163+version = "0.5.4"
164+source = "registry+https://github.com/rust-lang/crates.io-index"
165+checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"
166+
167+[[package]]
168+name = "async-rx"
169+version = "0.2.1"
170+source = "registry+https://github.com/rust-lang/crates.io-index"
171+checksum = "a82c1a761cd7b772912fd2500f2c0a850c0c72f5f66543038393ac6cfaa3659a"
172+dependencies = [
173+ "futures-core",
174+ "pin-project-lite",
175+]
176+
177+[[package]]
178+name = "async-stream"
179+version = "0.3.6"
180+source = "registry+https://github.com/rust-lang/crates.io-index"
181+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
182+dependencies = [
183+ "async-stream-impl",
184+ "futures-core",
185+ "pin-project-lite",
186+]
187+
188+[[package]]
189+name = "async-stream-impl"
190+version = "0.3.6"
191+source = "registry+https://github.com/rust-lang/crates.io-index"
192+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
193+dependencies = [
194+ "proc-macro2",
195+ "quote",
196+ "syn 2.0.119",
197+]
198+
199+[[package]]
200+name = "async-trait"
201+version = "0.1.92"
202+source = "registry+https://github.com/rust-lang/crates.io-index"
203+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
204+dependencies = [
205+ "proc-macro2",
206+ "quote",
207+ "syn 3.0.5",
208+]
209+
210+[[package]]
211+name = "async_cell"
212+version = "0.2.3"
213+source = "registry+https://github.com/rust-lang/crates.io-index"
214+checksum = "447ab28afbb345f5408b120702a44e5529ebf90b1796ec76e9528df8e288e6c2"
215+dependencies = [
216+ "loom",
217+]
218+
219+[[package]]
220+name = "atomic-waker"
221+version = "1.1.2"
222+source = "registry+https://github.com/rust-lang/crates.io-index"
223+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
224+
225+[[package]]
226+name = "autocfg"
227+version = "1.5.1"
228+source = "registry+https://github.com/rust-lang/crates.io-index"
229+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
230+
231+[[package]]
232+name = "aws-lc-rs"
233+version = "1.18.1"
234+source = "registry+https://github.com/rust-lang/crates.io-index"
235+checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
236+dependencies = [
237+ "aws-lc-sys",
238+ "zeroize",
239+]
240+
241+[[package]]
242+name = "aws-lc-sys"
243+version = "0.45.0"
244+source = "registry+https://github.com/rust-lang/crates.io-index"
245+checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
246+dependencies = [
247+ "cc",
248+ "cmake",
249+ "dunce",
250+ "fs_extra",
251+ "pkg-config",
252+]
253+
254+[[package]]
255+name = "backon"
256+version = "1.6.0"
257+source = "registry+https://github.com/rust-lang/crates.io-index"
258+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
259+dependencies = [
260+ "fastrand",
261+ "gloo-timers",
262+ "tokio",
263+]
264+
265+[[package]]
266+name = "base64"
267+version = "0.22.1"
268+source = "registry+https://github.com/rust-lang/crates.io-index"
269+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
270+
271+[[package]]
272+name = "base64ct"
273+version = "1.8.3"
274+source = "registry+https://github.com/rust-lang/crates.io-index"
275+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
276+
277+[[package]]
278+name = "bitflags"
279+version = "2.13.1"
280+source = "registry+https://github.com/rust-lang/crates.io-index"
281+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
282+dependencies = [
283+ "serde_core",
284+]
285+
286+[[package]]
287+name = "bitmaps"
288+version = "3.2.1"
289+source = "registry+https://github.com/rust-lang/crates.io-index"
290+checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"
291+
292+[[package]]
293+name = "blake3"
294+version = "1.8.7"
295+source = "registry+https://github.com/rust-lang/crates.io-index"
296+checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
297+dependencies = [
298+ "arrayvec",
299+ "cc",
300+ "cfg-if",
301+ "constant_time_eq",
302+ "cpufeatures 0.3.1",
303+]
304+
305+[[package]]
306+name = "block-buffer"
307+version = "0.10.4"
308+source = "registry+https://github.com/rust-lang/crates.io-index"
309+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
310+dependencies = [
311+ "generic-array",
312+]
313+
314+[[package]]
315+name = "block-padding"
316+version = "0.3.3"
317+source = "registry+https://github.com/rust-lang/crates.io-index"
318+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
319+dependencies = [
320+ "generic-array",
321+]
322+
323+[[package]]
324+name = "bs58"
325+version = "0.5.1"
326+source = "registry+https://github.com/rust-lang/crates.io-index"
327+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
328+dependencies = [
329+ "tinyvec",
330+]
331+
332+[[package]]
333+name = "bumpalo"
334+version = "3.20.3"
335+source = "registry+https://github.com/rust-lang/crates.io-index"
336+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
337+
338+[[package]]
339+name = "byteorder"
340+version = "1.5.0"
341+source = "registry+https://github.com/rust-lang/crates.io-index"
342+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
343+
344+[[package]]
345+name = "bytes"
346+version = "1.12.1"
347+source = "registry+https://github.com/rust-lang/crates.io-index"
348+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
349+
350+[[package]]
351+name = "bytesize"
352+version = "2.7.0"
353+source = "registry+https://github.com/rust-lang/crates.io-index"
354+checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b"
355+
356+[[package]]
357+name = "cast"
358+version = "0.3.0"
359+source = "registry+https://github.com/rust-lang/crates.io-index"
360+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
361+
362+[[package]]
363+name = "cbc"
364+version = "0.1.2"
365+source = "registry+https://github.com/rust-lang/crates.io-index"
366+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
367+dependencies = [
368+ "cipher",
369+]
370+
371+[[package]]
372+name = "cc"
373+version = "1.4.5"
374+source = "registry+https://github.com/rust-lang/crates.io-index"
375+checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
376+dependencies = [
377+ "find-msvc-tools",
378+ "jobserver",
379+ "libc",
380+ "shlex",
381+]
382+
383+[[package]]
384+name = "cfg-if"
385+version = "1.0.4"
386+source = "registry+https://github.com/rust-lang/crates.io-index"
387+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
388+
389+[[package]]
390+name = "cfg_aliases"
391+version = "0.2.2"
392+source = "registry+https://github.com/rust-lang/crates.io-index"
393+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
394+
395+[[package]]
396+name = "chacha20"
397+version = "0.9.1"
398+source = "registry+https://github.com/rust-lang/crates.io-index"
399+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
400+dependencies = [
401+ "cfg-if",
402+ "cipher",
403+ "cpufeatures 0.2.17",
404+]
405+
406+[[package]]
407+name = "chacha20"
408+version = "0.10.2"
409+source = "registry+https://github.com/rust-lang/crates.io-index"
410+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
411+dependencies = [
412+ "cfg-if",
413+ "cpufeatures 0.3.1",
414+ "rand_core 0.10.1",
415+]
416+
417+[[package]]
418+name = "chacha20poly1305"
419+version = "0.10.1"
420+source = "registry+https://github.com/rust-lang/crates.io-index"
421+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
422+dependencies = [
423+ "aead",
424+ "chacha20 0.9.1",
425+ "cipher",
426+ "poly1305",
427+ "zeroize",
428+]
429+
430+[[package]]
431+name = "chrono"
432+version = "0.4.45"
433+source = "registry+https://github.com/rust-lang/crates.io-index"
434+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
435+dependencies = [
436+ "iana-time-zone",
437+ "js-sys",
438+ "num-traits",
439+ "serde",
440+ "wasm-bindgen",
441+ "windows-link",
442+]
443+
444+[[package]]
445+name = "cipher"
446+version = "0.4.4"
447+source = "registry+https://github.com/rust-lang/crates.io-index"
448+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
449+dependencies = [
450+ "crypto-common",
451+ "inout",
452+ "zeroize",
453+]
454+
455+[[package]]
456+name = "cmake"
457+version = "0.1.58"
458+source = "registry+https://github.com/rust-lang/crates.io-index"
459+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
460+dependencies = [
461+ "cc",
462+]
463+
464+[[package]]
465+name = "combine"
466+version = "4.6.8"
467+source = "registry+https://github.com/rust-lang/crates.io-index"
468+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
469+dependencies = [
470+ "bytes",
471+ "memchr",
472+]
473+
474+[[package]]
475+name = "compression-codecs"
476+version = "0.4.40"
477+source = "registry+https://github.com/rust-lang/crates.io-index"
478+checksum = "100590da849306918656ffbb22576bbdfc1382b50ad10d1d50ec177d3b205fb2"
479+dependencies = [
480+ "compression-core",
481+ "flate2",
482+ "memchr",
483+]
484+
485+[[package]]
486+name = "compression-core"
487+version = "0.4.33"
488+source = "registry+https://github.com/rust-lang/crates.io-index"
489+checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
490+
491+[[package]]
492+name = "concurrent-queue"
493+version = "2.5.0"
494+source = "registry+https://github.com/rust-lang/crates.io-index"
495+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
496+dependencies = [
497+ "crossbeam-utils",
498+]
499+
500+[[package]]
501+name = "console"
502+version = "0.16.4"
503+source = "registry+https://github.com/rust-lang/crates.io-index"
504+checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
505+dependencies = [
506+ "encode_unicode",
507+ "libc",
508+ "windows-sys 0.61.2",
509+]
510+
511+[[package]]
512+name = "const-oid"
513+version = "0.9.6"
514+source = "registry+https://github.com/rust-lang/crates.io-index"
515+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
516+
517+[[package]]
518+name = "const_panic"
519+version = "0.2.17"
520+source = "registry+https://github.com/rust-lang/crates.io-index"
521+checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3"
522+dependencies = [
523+ "typewit",
524+]
525+
526+[[package]]
527+name = "constant_time_eq"
528+version = "0.4.2"
529+source = "registry+https://github.com/rust-lang/crates.io-index"
530+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
531+
532+[[package]]
533+name = "core-foundation"
534+version = "0.10.1"
535+source = "registry+https://github.com/rust-lang/crates.io-index"
536+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
537+dependencies = [
538+ "core-foundation-sys",
539+ "libc",
540+]
541+
542+[[package]]
543+name = "core-foundation-sys"
544+version = "0.8.7"
545+source = "registry+https://github.com/rust-lang/crates.io-index"
546+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
547+
548+[[package]]
549+name = "cpufeatures"
550+version = "0.2.17"
551+source = "registry+https://github.com/rust-lang/crates.io-index"
552+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
553+dependencies = [
554+ "libc",
555+]
556+
557+[[package]]
558+name = "cpufeatures"
559+version = "0.3.1"
560+source = "registry+https://github.com/rust-lang/crates.io-index"
561+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
562+dependencies = [
563+ "libc",
564+]
565+
566+[[package]]
567+name = "crc32fast"
568+version = "1.5.1"
569+source = "registry+https://github.com/rust-lang/crates.io-index"
570+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
571+dependencies = [
572+ "cfg-if",
573+]
574+
575+[[package]]
576+name = "crossbeam-utils"
577+version = "0.8.23"
578+source = "registry+https://github.com/rust-lang/crates.io-index"
579+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
580+
581+[[package]]
582+name = "crypto-common"
583+version = "0.1.7"
584+source = "registry+https://github.com/rust-lang/crates.io-index"
585+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
586+dependencies = [
587+ "generic-array",
588+ "rand_core 0.6.4",
589+ "typenum",
590+]
591+
592+[[package]]
593+name = "ctor"
594+version = "0.2.9"
595+source = "registry+https://github.com/rust-lang/crates.io-index"
596+checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
597+dependencies = [
598+ "quote",
599+ "syn 2.0.119",
600+]
601+
602+[[package]]
603+name = "ctr"
604+version = "0.9.2"
605+source = "registry+https://github.com/rust-lang/crates.io-index"
606+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
607+dependencies = [
608+ "cipher",
609+]
610+
611+[[package]]
612+name = "curve25519-dalek"
613+version = "4.1.3"
614+source = "registry+https://github.com/rust-lang/crates.io-index"
615+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
616+dependencies = [
617+ "cfg-if",
618+ "cpufeatures 0.2.17",
619+ "curve25519-dalek-derive",
620+ "digest",
621+ "fiat-crypto",
622+ "rustc_version",
623+ "serde",
624+ "subtle",
625+ "zeroize",
626+]
627+
628+[[package]]
629+name = "curve25519-dalek-derive"
630+version = "0.1.1"
631+source = "registry+https://github.com/rust-lang/crates.io-index"
632+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
633+dependencies = [
634+ "proc-macro2",
635+ "quote",
636+ "syn 2.0.119",
637+]
638+
639+[[package]]
640+name = "date_header"
641+version = "1.0.5"
642+source = "registry+https://github.com/rust-lang/crates.io-index"
643+checksum = "0c03c416ed1a30fbb027ef484ba6ab6f80e1eada675e1a2b92fd673c045a1f1d"
644+
645+[[package]]
646+name = "deadpool"
647+version = "0.12.3"
648+source = "registry+https://github.com/rust-lang/crates.io-index"
649+checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
650+dependencies = [
651+ "deadpool-runtime 0.1.4",
652+ "lazy_static",
653+ "num_cpus",
654+ "tokio",
655+]
656+
657+[[package]]
658+name = "deadpool"
659+version = "0.13.1"
660+source = "registry+https://github.com/rust-lang/crates.io-index"
661+checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e"
662+dependencies = [
663+ "deadpool-runtime 0.3.1",
664+ "tokio",
665+]
666+
667+[[package]]
668+name = "deadpool-runtime"
669+version = "0.1.4"
670+source = "registry+https://github.com/rust-lang/crates.io-index"
671+checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
672+
673+[[package]]
674+name = "deadpool-runtime"
675+version = "0.3.1"
676+source = "registry+https://github.com/rust-lang/crates.io-index"
677+checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae"
678+dependencies = [
679+ "tokio",
680+]
681+
682+[[package]]
683+name = "deadpool-sync"
684+version = "0.2.0"
685+source = "registry+https://github.com/rust-lang/crates.io-index"
686+checksum = "e385cc95d3d582c328b36d1ff90feac061102b001894b555e6b465a2e0eaabbf"
687+dependencies = [
688+ "deadpool-runtime 0.3.1",
689+]
690+
691+[[package]]
692+name = "decancer"
693+version = "3.3.3"
694+source = "registry+https://github.com/rust-lang/crates.io-index"
695+checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"
696+
697+[[package]]
698+name = "delegate-display"
699+version = "3.0.1"
700+source = "registry+https://github.com/rust-lang/crates.io-index"
701+checksum = "54e3a499943fd5180aeffcb708164ffb22125ceeccb05491b5297981d37d6249"
702+dependencies = [
703+ "impartial-ord",
704+ "itoa",
705+ "macroific 3.0.1",
706+ "proc-macro2",
707+ "quote",
708+ "syn 3.0.5",
709+]
710+
711+[[package]]
712+name = "der"
713+version = "0.7.10"
714+source = "registry+https://github.com/rust-lang/crates.io-index"
715+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
716+dependencies = [
717+ "const-oid",
718+ "zeroize",
719+]
720+
721+[[package]]
722+name = "deranged"
723+version = "0.5.8"
724+source = "registry+https://github.com/rust-lang/crates.io-index"
725+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
726+
727+[[package]]
728+name = "derivative"
729+version = "2.2.0"
730+source = "registry+https://github.com/rust-lang/crates.io-index"
731+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
732+dependencies = [
733+ "proc-macro2",
734+ "quote",
735+ "syn 1.0.109",
736+]
737+
738+[[package]]
739+name = "derive_more"
740+version = "1.0.0"
741+source = "registry+https://github.com/rust-lang/crates.io-index"
742+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
743+dependencies = [
744+ "derive_more-impl 1.0.0",
745+]
746+
747+[[package]]
748+name = "derive_more"
749+version = "2.1.1"
750+source = "registry+https://github.com/rust-lang/crates.io-index"
751+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
752+dependencies = [
753+ "derive_more-impl 2.1.1",
754+]
755+
756+[[package]]
757+name = "derive_more-impl"
758+version = "1.0.0"
759+source = "registry+https://github.com/rust-lang/crates.io-index"
760+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
761+dependencies = [
762+ "proc-macro2",
763+ "quote",
764+ "syn 2.0.119",
765+]
766+
767+[[package]]
768+name = "derive_more-impl"
769+version = "2.1.1"
770+source = "registry+https://github.com/rust-lang/crates.io-index"
771+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
772+dependencies = [
773+ "proc-macro2",
774+ "quote",
775+ "rustc_version",
776+ "syn 2.0.119",
777+ "unicode-xid",
778+]
779+
780+[[package]]
781+name = "digest"
782+version = "0.10.7"
783+source = "registry+https://github.com/rust-lang/crates.io-index"
784+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
785+dependencies = [
786+ "block-buffer",
787+ "crypto-common",
788+ "subtle",
789+]
790+
791+[[package]]
792+name = "displaydoc"
793+version = "0.2.7"
794+source = "registry+https://github.com/rust-lang/crates.io-index"
795+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
796+dependencies = [
797+ "proc-macro2",
798+ "quote",
799+ "syn 3.0.5",
800+]
801+
802+[[package]]
803+name = "dunce"
804+version = "1.0.5"
805+source = "registry+https://github.com/rust-lang/crates.io-index"
806+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
807+
808+[[package]]
809+name = "ed25519"
810+version = "2.2.3"
811+source = "registry+https://github.com/rust-lang/crates.io-index"
812+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
813+dependencies = [
814+ "pkcs8",
815+ "serde",
816+ "signature",
817+]
818+
819+[[package]]
820+name = "ed25519-dalek"
821+version = "2.2.0"
822+source = "registry+https://github.com/rust-lang/crates.io-index"
823+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
824+dependencies = [
825+ "curve25519-dalek",
826+ "ed25519",
827+ "rand_core 0.6.4",
828+ "serde",
829+ "sha2",
830+ "subtle",
831+ "zeroize",
832+]
833+
834+[[package]]
835+name = "either"
836+version = "1.18.0"
837+source = "registry+https://github.com/rust-lang/crates.io-index"
838+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
839+
840+[[package]]
841+name = "emojis"
842+version = "0.8.2"
843+source = "registry+https://github.com/rust-lang/crates.io-index"
844+checksum = "0a4d5d50b0b58df5173d8ff1192b4d1422ceae5d981b30d4b6f8ed1d673a2bc4"
845+dependencies = [
846+ "phf",
847+]
848+
849+[[package]]
850+name = "encode_unicode"
851+version = "1.0.0"
852+source = "registry+https://github.com/rust-lang/crates.io-index"
853+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
854+
855+[[package]]
856+name = "equivalent"
857+version = "1.0.2"
858+source = "registry+https://github.com/rust-lang/crates.io-index"
859+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
860+
861+[[package]]
862+name = "errno"
863+version = "0.3.14"
864+source = "registry+https://github.com/rust-lang/crates.io-index"
865+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
866+dependencies = [
867+ "libc",
868+ "windows-sys 0.61.2",
869+]
870+
871+[[package]]
872+name = "event-listener"
873+version = "5.4.2"
874+source = "registry+https://github.com/rust-lang/crates.io-index"
875+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
876+dependencies = [
877+ "parking",
878+ "pin-project-lite",
879+]
880+
881+[[package]]
882+name = "event-listener-strategy"
883+version = "0.5.4"
884+source = "registry+https://github.com/rust-lang/crates.io-index"
885+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
886+dependencies = [
887+ "event-listener",
888+ "pin-project-lite",
889+]
890+
891+[[package]]
892+name = "eyeball"
893+version = "0.8.8"
894+source = "registry+https://github.com/rust-lang/crates.io-index"
895+checksum = "d93bd0ebf93d61d6332d3c09a96e97975968a44e19a64c947bde06e6baff383f"
896+dependencies = [
897+ "futures-core",
898+ "readlock",
899+ "readlock-tokio",
900+ "tokio",
901+ "tokio-util",
902+ "tracing",
903+]
904+
905+[[package]]
906+name = "eyeball-im"
907+version = "0.8.0"
908+source = "registry+https://github.com/rust-lang/crates.io-index"
909+checksum = "4790c03df183c2b46665c1a58118c04fd3e3976ec2fe16a0aa00e00c9eea7754"
910+dependencies = [
911+ "futures-core",
912+ "imbl",
913+ "tokio",
914+ "tracing",
915+]
916+
917+[[package]]
918+name = "eyeball-im-util"
919+version = "0.10.0"
920+source = "registry+https://github.com/rust-lang/crates.io-index"
921+checksum = "809fa2e8e59c25a41f5b2440aa937f1e84df6ae48b707e65f69ce259b1c44bda"
922+dependencies = [
923+ "arrayvec",
924+ "eyeball-im",
925+ "futures-core",
926+ "imbl",
927+ "pin-project-lite",
928+ "smallvec",
929+]
930+
931+[[package]]
932+name = "fallible-iterator"
933+version = "0.3.0"
934+source = "registry+https://github.com/rust-lang/crates.io-index"
935+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
936+
937+[[package]]
938+name = "fallible-streaming-iterator"
939+version = "0.1.9"
940+source = "registry+https://github.com/rust-lang/crates.io-index"
941+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
942+
943+[[package]]
944+name = "fancy_constructor"
945+version = "2.1.1"
946+source = "registry+https://github.com/rust-lang/crates.io-index"
947+checksum = "4a8211ab12c36b63c269e17873331dc1da196a3edb7a5c79015677ef972da34a"
948+dependencies = [
949+ "macroific 3.0.1",
950+ "proc-macro2",
951+ "quote",
952+ "syn 3.0.5",
953+]
954+
955+[[package]]
956+name = "fastrand"
957+version = "2.5.0"
958+source = "registry+https://github.com/rust-lang/crates.io-index"
959+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
960+
961+[[package]]
962+name = "fiat-crypto"
963+version = "0.2.9"
964+source = "registry+https://github.com/rust-lang/crates.io-index"
965+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
966+
967+[[package]]
968+name = "find-msvc-tools"
969+version = "0.1.12"
970+source = "registry+https://github.com/rust-lang/crates.io-index"
971+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
972+
973+[[package]]
974+name = "flate2"
975+version = "1.1.10"
976+source = "registry+https://github.com/rust-lang/crates.io-index"
977+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
978+dependencies = [
979+ "crc32fast",
980+ "miniz_oxide",
981+ "zlib-rs",
982+]
983+
984+[[package]]
985+name = "fnv"
986+version = "1.0.7"
987+source = "registry+https://github.com/rust-lang/crates.io-index"
988+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
989+
990+[[package]]
991+name = "foldhash"
992+version = "0.1.5"
993+source = "registry+https://github.com/rust-lang/crates.io-index"
994+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
995+
996+[[package]]
997+name = "form_urlencoded"
998+version = "1.2.2"
999+source = "registry+https://github.com/rust-lang/crates.io-index"
1000+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
1001+dependencies = [
1002+ "percent-encoding",
1003+]
1004+
1005+[[package]]
1006+name = "fs_extra"
1007+version = "1.3.0"
1008+source = "registry+https://github.com/rust-lang/crates.io-index"
1009+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
1010+
1011+[[package]]
1012+name = "futures"
1013+version = "0.3.34"
1014+source = "registry+https://github.com/rust-lang/crates.io-index"
1015+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
1016+dependencies = [
1017+ "futures-channel",
1018+ "futures-core",
1019+ "futures-executor",
1020+ "futures-io",
1021+ "futures-sink",
1022+ "futures-task",
1023+ "futures-util",
1024+]
1025+
1026+[[package]]
1027+name = "futures-channel"
1028+version = "0.3.34"
1029+source = "registry+https://github.com/rust-lang/crates.io-index"
1030+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
1031+dependencies = [
1032+ "futures-core",
1033+ "futures-sink",
1034+]
1035+
1036+[[package]]
1037+name = "futures-core"
1038+version = "0.3.34"
1039+source = "registry+https://github.com/rust-lang/crates.io-index"
1040+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
1041+
1042+[[package]]
1043+name = "futures-executor"
1044+version = "0.3.34"
1045+source = "registry+https://github.com/rust-lang/crates.io-index"
1046+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
1047+dependencies = [
1048+ "futures-core",
1049+ "futures-task",
1050+ "futures-util",
1051+]
1052+
1053+[[package]]
1054+name = "futures-io"
1055+version = "0.3.34"
1056+source = "registry+https://github.com/rust-lang/crates.io-index"
1057+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
1058+
1059+[[package]]
1060+name = "futures-macro"
1061+version = "0.3.34"
1062+source = "registry+https://github.com/rust-lang/crates.io-index"
1063+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
1064+dependencies = [
1065+ "proc-macro2",
1066+ "quote",
1067+ "syn 3.0.5",
1068+]
1069+
1070+[[package]]
1071+name = "futures-sink"
1072+version = "0.3.34"
1073+source = "registry+https://github.com/rust-lang/crates.io-index"
1074+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
1075+
1076+[[package]]
1077+name = "futures-task"
1078+version = "0.3.34"
1079+source = "registry+https://github.com/rust-lang/crates.io-index"
1080+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
1081+
1082+[[package]]
1083+name = "futures-util"
1084+version = "0.3.34"
1085+source = "registry+https://github.com/rust-lang/crates.io-index"
1086+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
1087+dependencies = [
1088+ "futures-channel",
1089+ "futures-core",
1090+ "futures-io",
1091+ "futures-macro",
1092+ "futures-sink",
1093+ "futures-task",
1094+ "memchr",
1095+ "pin-project-lite",
1096+ "slab",
1097+]
1098+
1099+[[package]]
1100+name = "fuzzy-matcher"
1101+version = "0.3.7"
1102+source = "registry+https://github.com/rust-lang/crates.io-index"
1103+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
1104+dependencies = [
1105+ "thread_local",
1106+]
1107+
1108+[[package]]
1109+name = "generator"
1110+version = "0.8.9"
1111+source = "registry+https://github.com/rust-lang/crates.io-index"
1112+checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
1113+dependencies = [
1114+ "cc",
1115+ "cfg-if",
1116+ "libc",
1117+ "log",
1118+ "rustversion",
1119+ "windows-link",
1120+ "windows-result",
1121+]
1122+
1123+[[package]]
1124+name = "generic-array"
1125+version = "0.14.7"
1126+source = "registry+https://github.com/rust-lang/crates.io-index"
1127+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1128+dependencies = [
1129+ "typenum",
1130+ "version_check",
1131+]
1132+
1133+[[package]]
1134+name = "getrandom"
1135+version = "0.2.17"
1136+source = "registry+https://github.com/rust-lang/crates.io-index"
1137+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
1138+dependencies = [
1139+ "cfg-if",
1140+ "js-sys",
1141+ "libc",
1142+ "wasi",
1143+ "wasm-bindgen",
1144+]
1145+
1146+[[package]]
1147+name = "getrandom"
1148+version = "0.3.4"
1149+source = "registry+https://github.com/rust-lang/crates.io-index"
1150+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1151+dependencies = [
1152+ "cfg-if",
1153+ "libc",
1154+ "r-efi 5.3.0",
1155+ "wasip2",
1156+]
1157+
1158+[[package]]
1159+name = "getrandom"
1160+version = "0.4.3"
1161+source = "registry+https://github.com/rust-lang/crates.io-index"
1162+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
1163+dependencies = [
1164+ "cfg-if",
1165+ "js-sys",
1166+ "libc",
1167+ "r-efi 6.0.0",
1168+ "rand_core 0.10.1",
1169+ "wasm-bindgen",
1170+]
1171+
1172+[[package]]
1173+name = "gloo-timers"
1174+version = "0.3.0"
1175+source = "registry+https://github.com/rust-lang/crates.io-index"
1176+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
1177+dependencies = [
1178+ "futures-channel",
1179+ "futures-core",
1180+ "js-sys",
1181+ "wasm-bindgen",
1182+]
1183+
1184+[[package]]
1185+name = "gloo-utils"
1186+version = "0.2.0"
1187+source = "registry+https://github.com/rust-lang/crates.io-index"
1188+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
1189+dependencies = [
1190+ "js-sys",
1191+ "serde",
1192+ "serde_json",
1193+ "wasm-bindgen",
1194+ "web-sys",
1195+]
1196+
1197+[[package]]
1198+name = "growable-bloom-filter"
1199+version = "2.1.1"
1200+source = "registry+https://github.com/rust-lang/crates.io-index"
1201+checksum = "d174ccb4ba660d431329e7f0797870d0a4281e36353ec4b4a3c5eab6c2cfb6f1"
1202+dependencies = [
1203+ "serde",
1204+ "serde_bytes",
1205+ "serde_derive",
1206+ "xxhash-rust",
1207+]
1208+
1209+[[package]]
1210+name = "h2"
1211+version = "0.4.19"
1212+source = "registry+https://github.com/rust-lang/crates.io-index"
1213+checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
1214+dependencies = [
1215+ "atomic-waker",
1216+ "bytes",
1217+ "fnv",
1218+ "futures-core",
1219+ "futures-sink",
1220+ "http",
1221+ "indexmap",
1222+ "slab",
1223+ "tokio",
1224+ "tokio-util",
1225+ "tracing",
1226+]
1227+
1228+[[package]]
1229+name = "hashbrown"
1230+version = "0.15.5"
1231+source = "registry+https://github.com/rust-lang/crates.io-index"
1232+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
1233+dependencies = [
1234+ "foldhash",
1235+]
1236+
1237+[[package]]
1238+name = "hashbrown"
1239+version = "0.17.1"
1240+source = "registry+https://github.com/rust-lang/crates.io-index"
1241+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
1242+
1243+[[package]]
1244+name = "hashlink"
1245+version = "0.10.0"
1246+source = "registry+https://github.com/rust-lang/crates.io-index"
1247+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
1248+dependencies = [
1249+ "hashbrown 0.15.5",
1250+]
1251+
1252+[[package]]
1253+name = "hermit-abi"
1254+version = "0.5.3"
1255+source = "registry+https://github.com/rust-lang/crates.io-index"
1256+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
1257+
1258+[[package]]
1259+name = "hkdf"
1260+version = "0.12.4"
1261+source = "registry+https://github.com/rust-lang/crates.io-index"
1262+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
1263+dependencies = [
1264+ "hmac",
1265+]
1266+
1267+[[package]]
1268+name = "hmac"
1269+version = "0.12.1"
1270+source = "registry+https://github.com/rust-lang/crates.io-index"
1271+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
1272+dependencies = [
1273+ "digest",
1274+]
1275+
1276+[[package]]
1277+name = "html5ever"
1278+version = "0.39.0"
1279+source = "registry+https://github.com/rust-lang/crates.io-index"
1280+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
1281+dependencies = [
1282+ "log",
1283+ "markup5ever",
1284+]
1285+
1286+[[package]]
1287+name = "http"
1288+version = "1.5.0"
1289+source = "registry+https://github.com/rust-lang/crates.io-index"
1290+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
1291+dependencies = [
1292+ "bytes",
1293+ "itoa",
1294+]
1295+
1296+[[package]]
1297+name = "http-body"
1298+version = "1.1.0"
1299+source = "registry+https://github.com/rust-lang/crates.io-index"
1300+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
1301+dependencies = [
1302+ "bytes",
1303+ "http",
1304+]
1305+
1306+[[package]]
1307+name = "http-body-util"
1308+version = "0.1.5"
1309+source = "registry+https://github.com/rust-lang/crates.io-index"
1310+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
1311+dependencies = [
1312+ "bytes",
1313+ "futures-core",
1314+ "http",
1315+ "http-body",
1316+ "pin-project-lite",
1317+]
1318+
1319+[[package]]
1320+name = "httparse"
1321+version = "1.10.1"
1322+source = "registry+https://github.com/rust-lang/crates.io-index"
1323+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
1324+
1325+[[package]]
1326+name = "httpdate"
1327+version = "1.0.3"
1328+source = "registry+https://github.com/rust-lang/crates.io-index"
1329+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
1330+
1331+[[package]]
1332+name = "hyper"
1333+version = "1.11.1"
1334+source = "registry+https://github.com/rust-lang/crates.io-index"
1335+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
1336+dependencies = [
1337+ "atomic-waker",
1338+ "bytes",
1339+ "futures-channel",
1340+ "futures-core",
1341+ "h2",
1342+ "http",
1343+ "http-body",
1344+ "httparse",
1345+ "httpdate",
1346+ "itoa",
1347+ "pin-project-lite",
1348+ "smallvec",
1349+ "tokio",
1350+ "want",
1351+]
1352+
1353+[[package]]
1354+name = "hyper-rustls"
1355+version = "0.27.9"
1356+source = "registry+https://github.com/rust-lang/crates.io-index"
1357+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
1358+dependencies = [
1359+ "http",
1360+ "hyper",
1361+ "hyper-util",
1362+ "rustls",
1363+ "tokio",
1364+ "tokio-rustls",
1365+ "tower-service",
1366+]
1367+
1368+[[package]]
1369+name = "hyper-util"
1370+version = "0.1.20"
1371+source = "registry+https://github.com/rust-lang/crates.io-index"
1372+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
1373+dependencies = [
1374+ "base64",
1375+ "bytes",
1376+ "futures-channel",
1377+ "futures-util",
1378+ "http",
1379+ "http-body",
1380+ "hyper",
1381+ "ipnet",
1382+ "libc",
1383+ "percent-encoding",
1384+ "pin-project-lite",
1385+ "socket2",
1386+ "tokio",
1387+ "tower-service",
1388+ "tracing",
1389+]
1390+
1391+[[package]]
1392+name = "iana-time-zone"
1393+version = "0.1.65"
1394+source = "registry+https://github.com/rust-lang/crates.io-index"
1395+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
1396+dependencies = [
1397+ "android_system_properties",
1398+ "core-foundation-sys",
1399+ "iana-time-zone-haiku",
1400+ "js-sys",
1401+ "log",
1402+ "wasm-bindgen",
1403+ "windows-core",
1404+]
1405+
1406+[[package]]
1407+name = "iana-time-zone-haiku"
1408+version = "0.1.2"
1409+source = "registry+https://github.com/rust-lang/crates.io-index"
1410+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1411+dependencies = [
1412+ "cc",
1413+]
1414+
1415+[[package]]
1416+name = "icu_collections"
1417+version = "2.3.0"
1418+source = "registry+https://github.com/rust-lang/crates.io-index"
1419+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
1420+dependencies = [
1421+ "displaydoc",
1422+ "potential_utf",
1423+ "utf8_iter",
1424+ "yoke",
1425+ "zerofrom",
1426+ "zerovec",
1427+]
1428+
1429+[[package]]
1430+name = "icu_locale_core"
1431+version = "2.3.0"
1432+source = "registry+https://github.com/rust-lang/crates.io-index"
1433+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
1434+dependencies = [
1435+ "displaydoc",
1436+ "litemap",
1437+ "tinystr",
1438+ "writeable",
1439+ "zerovec",
1440+]
1441+
1442+[[package]]
1443+name = "icu_normalizer"
1444+version = "2.3.0"
1445+source = "registry+https://github.com/rust-lang/crates.io-index"
1446+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
1447+dependencies = [
1448+ "icu_collections",
1449+ "icu_normalizer_data",
1450+ "icu_properties",
1451+ "icu_provider",
1452+ "smallvec",
1453+ "zerovec",
1454+]
1455+
1456+[[package]]
1457+name = "icu_normalizer_data"
1458+version = "2.3.0"
1459+source = "registry+https://github.com/rust-lang/crates.io-index"
1460+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
1461+
1462+[[package]]
1463+name = "icu_properties"
1464+version = "2.3.0"
1465+source = "registry+https://github.com/rust-lang/crates.io-index"
1466+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
1467+dependencies = [
1468+ "displaydoc",
1469+ "icu_collections",
1470+ "icu_locale_core",
1471+ "icu_properties_data",
1472+ "icu_provider",
1473+ "zerotrie",
1474+ "zerovec",
1475+]
1476+
1477+[[package]]
1478+name = "icu_properties_data"
1479+version = "2.3.0"
1480+source = "registry+https://github.com/rust-lang/crates.io-index"
1481+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
1482+
1483+[[package]]
1484+name = "icu_provider"
1485+version = "2.3.1"
1486+source = "registry+https://github.com/rust-lang/crates.io-index"
1487+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
1488+dependencies = [
1489+ "displaydoc",
1490+ "icu_locale_core",
1491+ "writeable",
1492+ "yoke",
1493+ "zerofrom",
1494+ "zerotrie",
1495+ "zerovec",
1496+]
1497+
1498+[[package]]
1499+name = "idna"
1500+version = "1.1.0"
1501+source = "registry+https://github.com/rust-lang/crates.io-index"
1502+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
1503+dependencies = [
1504+ "idna_adapter",
1505+ "smallvec",
1506+ "utf8_iter",
1507+]
1508+
1509+[[package]]
1510+name = "idna_adapter"
1511+version = "1.2.2"
1512+source = "registry+https://github.com/rust-lang/crates.io-index"
1513+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
1514+dependencies = [
1515+ "icu_normalizer",
1516+ "icu_properties",
1517+]
1518+
1519+[[package]]
1520+name = "imbl"
1521+version = "6.1.0"
1522+source = "registry+https://github.com/rust-lang/crates.io-index"
1523+checksum = "0fade8ae6828627ad1fa094a891eccfb25150b383047190a3648d66d06186501"
1524+dependencies = [
1525+ "archery",
1526+ "bitmaps",
1527+ "imbl-sized-chunks",
1528+ "rand_core 0.9.5",
1529+ "rand_xoshiro",
1530+ "serde",
1531+ "version_check",
1532+]
1533+
1534+[[package]]
1535+name = "imbl-sized-chunks"
1536+version = "0.1.3"
1537+source = "registry+https://github.com/rust-lang/crates.io-index"
1538+checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d"
1539+dependencies = [
1540+ "bitmaps",
1541+]
1542+
1543+[[package]]
1544+name = "impartial-ord"
1545+version = "2.0.0"
1546+source = "registry+https://github.com/rust-lang/crates.io-index"
1547+checksum = "bc680e03d1a08bdc1c01b60e0c39723430ced63d8cabd93cee0449b6ba916a25"
1548+dependencies = [
1549+ "proc-macro2",
1550+ "quote",
1551+ "syn 3.0.5",
1552+]
1553+
1554+[[package]]
1555+name = "include_dir"
1556+version = "0.7.4"
1557+source = "registry+https://github.com/rust-lang/crates.io-index"
1558+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
1559+dependencies = [
1560+ "include_dir_macros",
1561+]
1562+
1563+[[package]]
1564+name = "include_dir_macros"
1565+version = "0.7.4"
1566+source = "registry+https://github.com/rust-lang/crates.io-index"
1567+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
1568+dependencies = [
1569+ "proc-macro2",
1570+ "quote",
1571+]
1572+
1573+[[package]]
1574+name = "indexmap"
1575+version = "2.14.2"
1576+source = "registry+https://github.com/rust-lang/crates.io-index"
1577+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
1578+dependencies = [
1579+ "equivalent",
1580+ "hashbrown 0.17.1",
1581+ "serde",
1582+ "serde_core",
1583+]
1584+
1585+[[package]]
1586+name = "inout"
1587+version = "0.1.4"
1588+source = "registry+https://github.com/rust-lang/crates.io-index"
1589+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
1590+dependencies = [
1591+ "block-padding",
1592+ "generic-array",
1593+]
1594+
1595+[[package]]
1596+name = "insta"
1597+version = "1.48.0"
1598+source = "registry+https://github.com/rust-lang/crates.io-index"
1599+checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
1600+dependencies = [
1601+ "console",
1602+ "once_cell",
1603+ "pest",
1604+ "pest_derive",
1605+ "serde",
1606+ "similar",
1607+ "tempfile",
1608+]
1609+
1610+[[package]]
1611+name = "ipnet"
1612+version = "2.12.2"
1613+source = "registry+https://github.com/rust-lang/crates.io-index"
1614+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
1615+
1616+[[package]]
1617+name = "itertools"
1618+version = "0.10.5"
1619+source = "registry+https://github.com/rust-lang/crates.io-index"
1620+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
1621+dependencies = [
1622+ "either",
1623+]
1624+
1625+[[package]]
1626+name = "itertools"
1627+version = "0.14.0"
1628+source = "registry+https://github.com/rust-lang/crates.io-index"
1629+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
1630+dependencies = [
1631+ "either",
1632+]
1633+
1634+[[package]]
1635+name = "itoa"
1636+version = "1.0.18"
1637+source = "registry+https://github.com/rust-lang/crates.io-index"
1638+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1639+
1640+[[package]]
1641+name = "jni"
1642+version = "0.22.4"
1643+source = "registry+https://github.com/rust-lang/crates.io-index"
1644+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
1645+dependencies = [
1646+ "cfg-if",
1647+ "combine",
1648+ "jni-macros",
1649+ "jni-sys",
1650+ "log",
1651+ "simd_cesu8",
1652+ "thiserror 2.0.20",
1653+ "walkdir",
1654+ "windows-link",
1655+]
1656+
1657+[[package]]
1658+name = "jni-macros"
1659+version = "0.22.4"
1660+source = "registry+https://github.com/rust-lang/crates.io-index"
1661+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
1662+dependencies = [
1663+ "proc-macro2",
1664+ "quote",
1665+ "rustc_version",
1666+ "simd_cesu8",
1667+ "syn 2.0.119",
1668+]
1669+
1670+[[package]]
1671+name = "jni-sys"
1672+version = "0.4.1"
1673+source = "registry+https://github.com/rust-lang/crates.io-index"
1674+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
1675+dependencies = [
1676+ "jni-sys-macros",
1677+]
1678+
1679+[[package]]
1680+name = "jni-sys-macros"
1681+version = "0.4.1"
1682+source = "registry+https://github.com/rust-lang/crates.io-index"
1683+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
1684+dependencies = [
1685+ "quote",
1686+ "syn 2.0.119",
1687+]
1688+
1689+[[package]]
1690+name = "jobserver"
1691+version = "0.1.35"
1692+source = "registry+https://github.com/rust-lang/crates.io-index"
1693+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
1694+dependencies = [
1695+ "getrandom 0.4.3",
1696+ "libc",
1697+]
1698+
1699+[[package]]
1700+name = "js-sys"
1701+version = "0.3.105"
1702+source = "registry+https://github.com/rust-lang/crates.io-index"
1703+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
1704+dependencies = [
1705+ "cfg-if",
1706+ "futures-util",
1707+ "wasm-bindgen",
1708+]
1709+
1710+[[package]]
1711+name = "js_int"
1712+version = "0.2.2"
1713+source = "registry+https://github.com/rust-lang/crates.io-index"
1714+checksum = "d937f95470b270ce8b8950207715d71aa8e153c0d44c6684d59397ed4949160a"
1715+dependencies = [
1716+ "serde",
1717+]
1718+
1719+[[package]]
1720+name = "js_option"
1721+version = "0.2.0"
1722+source = "registry+https://github.com/rust-lang/crates.io-index"
1723+checksum = "c7dd3e281add16813cf673bf74a32249b0aa0d1c8117519a17b3ada5e8552b3c"
1724+dependencies = [
1725+ "serde_core",
1726+]
1727+
1728+[[package]]
1729+name = "konst"
1730+version = "0.4.3"
1731+source = "registry+https://github.com/rust-lang/crates.io-index"
1732+checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d"
1733+dependencies = [
1734+ "const_panic",
1735+ "typewit",
1736+]
1737+
1738+[[package]]
1739+name = "language-tags"
1740+version = "0.3.2"
1741+source = "registry+https://github.com/rust-lang/crates.io-index"
1742+checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
1743+
1744+[[package]]
1745+name = "lazy_static"
1746+version = "1.5.0"
1747+source = "registry+https://github.com/rust-lang/crates.io-index"
1748+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1749+
1750+[[package]]
1751+name = "libc"
1752+version = "0.2.189"
1753+source = "registry+https://github.com/rust-lang/crates.io-index"
1754+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
1755+
1756+[[package]]
1757+name = "libm"
1758+version = "0.2.16"
1759+source = "registry+https://github.com/rust-lang/crates.io-index"
1760+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1761+
1762+[[package]]
1763+name = "libsqlite3-sys"
1764+version = "0.35.0"
1765+source = "registry+https://github.com/rust-lang/crates.io-index"
1766+checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
1767+dependencies = [
1768+ "cc",
1769+ "pkg-config",
1770+ "vcpkg",
1771+]
1772+
1773+[[package]]
1774+name = "linux-raw-sys"
1775+version = "0.12.1"
1776+source = "registry+https://github.com/rust-lang/crates.io-index"
1777+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
1778+
1779+[[package]]
1780+name = "litemap"
1781+version = "0.8.3"
1782+source = "registry+https://github.com/rust-lang/crates.io-index"
1783+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
1784+
1785+[[package]]
1786+name = "lock_api"
1787+version = "0.4.14"
1788+source = "registry+https://github.com/rust-lang/crates.io-index"
1789+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1790+dependencies = [
1791+ "scopeguard",
1792+]
1793+
1794+[[package]]
1795+name = "log"
1796+version = "0.4.34"
1797+source = "registry+https://github.com/rust-lang/crates.io-index"
1798+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
1799+
1800+[[package]]
1801+name = "loom"
1802+version = "0.7.2"
1803+source = "registry+https://github.com/rust-lang/crates.io-index"
1804+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
1805+dependencies = [
1806+ "cfg-if",
1807+ "generator",
1808+ "scoped-tls",
1809+ "tracing",
1810+ "tracing-subscriber",
1811+]
1812+
1813+[[package]]
1814+name = "lru-slab"
1815+version = "0.1.2"
1816+source = "registry+https://github.com/rust-lang/crates.io-index"
1817+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
1818+
1819+[[package]]
1820+name = "macltok"
1821+version = "0.1.0"
1822+dependencies = [
1823+ "futures-util",
1824+ "http",
1825+ "matrix-sdk",
1826+ "matrix-sdk-crypto",
1827+ "matrix-sdk-test",
1828+ "matrix-sdk-ui",
1829+ "mime",
1830+ "ruma",
1831+ "serde",
1832+ "serde_json",
1833+ "thiserror 2.0.20",
1834+ "tokio",
1835+ "tracing",
1836+ "url",
1837+ "wiremock",
1838+]
1839+
1840+[[package]]
1841+name = "macroific"
1842+version = "2.0.0"
1843+source = "registry+https://github.com/rust-lang/crates.io-index"
1844+checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285"
1845+dependencies = [
1846+ "macroific_attr_parse 2.0.0",
1847+ "macroific_core 2.0.0",
1848+ "macroific_macro 2.0.0",
1849+]
1850+
1851+[[package]]
1852+name = "macroific"
1853+version = "3.0.1"
1854+source = "registry+https://github.com/rust-lang/crates.io-index"
1855+checksum = "c437abb3bf40939b00d1dff858ed2050d948bc230dee39546ed16795fcf10ec2"
1856+dependencies = [
1857+ "macroific_attr_parse 3.0.1",
1858+ "macroific_core 3.0.1",
1859+ "macroific_macro 3.0.1",
1860+]
1861+
1862+[[package]]
1863+name = "macroific_attr_parse"
1864+version = "2.0.0"
1865+source = "registry+https://github.com/rust-lang/crates.io-index"
1866+checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9"
1867+dependencies = [
1868+ "proc-macro2",
1869+ "quote",
1870+ "sealed 0.6.0",
1871+ "syn 2.0.119",
1872+]
1873+
1874+[[package]]
1875+name = "macroific_attr_parse"
1876+version = "3.0.1"
1877+source = "registry+https://github.com/rust-lang/crates.io-index"
1878+checksum = "aeb7a82ec1aa16094da719be18615d79cb2574b037358e4e6c6d96eedcff3a93"
1879+dependencies = [
1880+ "proc-macro2",
1881+ "quote",
1882+ "sealed 0.7.0",
1883+ "syn 3.0.5",
1884+]
1885+
1886+[[package]]
1887+name = "macroific_core"
1888+version = "2.0.0"
1889+source = "registry+https://github.com/rust-lang/crates.io-index"
1890+checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3"
1891+dependencies = [
1892+ "proc-macro2",
1893+ "quote",
1894+ "sealed 0.6.0",
1895+ "syn 2.0.119",
1896+]
1897+
1898+[[package]]
1899+name = "macroific_core"
1900+version = "3.0.1"
1901+source = "registry+https://github.com/rust-lang/crates.io-index"
1902+checksum = "26c8e789484d2fa216eee9d5e0565ba80ccda389bcf0e111143a7b8f4792336e"
1903+dependencies = [
1904+ "proc-macro2",
1905+ "quote",
1906+ "sealed 0.7.0",
1907+ "syn 3.0.5",
1908+]
1909+
1910+[[package]]
1911+name = "macroific_macro"
1912+version = "2.0.0"
1913+source = "registry+https://github.com/rust-lang/crates.io-index"
1914+checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b"
1915+dependencies = [
1916+ "macroific_attr_parse 2.0.0",
1917+ "macroific_core 2.0.0",
1918+ "proc-macro2",
1919+ "quote",
1920+ "syn 2.0.119",
1921+]
1922+
1923+[[package]]
1924+name = "macroific_macro"
1925+version = "3.0.1"
1926+source = "registry+https://github.com/rust-lang/crates.io-index"
1927+checksum = "e30d63cb102648965753f0d47a6ab171b1cbb1fb5c363f4784ea986782fc7b77"
1928+dependencies = [
1929+ "macroific_attr_parse 3.0.1",
1930+ "macroific_core 3.0.1",
1931+ "proc-macro2",
1932+ "quote",
1933+ "syn 3.0.5",
1934+]
1935+
1936+[[package]]
1937+name = "maplit"
1938+version = "1.0.2"
1939+source = "registry+https://github.com/rust-lang/crates.io-index"
1940+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
1941+
1942+[[package]]
1943+name = "markup5ever"
1944+version = "0.39.0"
1945+source = "registry+https://github.com/rust-lang/crates.io-index"
1946+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
1947+dependencies = [
1948+ "log",
1949+ "tendril",
1950+ "web_atoms",
1951+]
1952+
1953+[[package]]
1954+name = "matchers"
1955+version = "0.2.0"
1956+source = "registry+https://github.com/rust-lang/crates.io-index"
1957+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
1958+dependencies = [
1959+ "regex-automata",
1960+]
1961+
1962+[[package]]
1963+name = "matrix-pickle"
1964+version = "0.2.3"
1965+source = "registry+https://github.com/rust-lang/crates.io-index"
1966+checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0"
1967+dependencies = [
1968+ "matrix-pickle-derive",
1969+ "thiserror 2.0.20",
1970+]
1971+
1972+[[package]]
1973+name = "matrix-pickle-derive"
1974+version = "0.2.3"
1975+source = "registry+https://github.com/rust-lang/crates.io-index"
1976+checksum = "414b5e4c34009f2bc3fe35dd018f25755ca38858096574841c7332f99e2c7e77"
1977+dependencies = [
1978+ "proc-macro-crate",
1979+ "proc-macro2",
1980+ "quote",
1981+ "syn 2.0.119",
1982+]
1983+
1984+[[package]]
1985+name = "matrix-sdk"
1986+version = "0.18.0"
1987+source = "registry+https://github.com/rust-lang/crates.io-index"
1988+checksum = "7083d580527511ac5d9369e03b9f2b20902e76949f1b3964051f978e4d3756ae"
1989+dependencies = [
1990+ "anymap2",
1991+ "aquamarine",
1992+ "as_variant",
1993+ "assert_matches2",
1994+ "async-channel",
1995+ "async-once-cell",
1996+ "async-stream",
1997+ "async-trait",
1998+ "backon",
1999+ "bytes",
2000+ "bytesize",
2001+ "cfg-if",
2002+ "event-listener",
2003+ "eyeball",
2004+ "eyeball-im",
2005+ "futures-core",
2006+ "futures-util",
2007+ "gloo-timers",
2008+ "http",
2009+ "imbl",
2010+ "indexmap",
2011+ "itertools 0.14.0",
2012+ "js_int",
2013+ "language-tags",
2014+ "matrix-sdk-base",
2015+ "matrix-sdk-common",
2016+ "matrix-sdk-indexeddb",
2017+ "matrix-sdk-sqlite",
2018+ "matrix-sdk-test",
2019+ "mime",
2020+ "mime2ext",
2021+ "oauth2",
2022+ "oauth2-reqwest",
2023+ "percent-encoding",
2024+ "pin-project-lite",
2025+ "reqwest",
2026+ "ruma",
2027+ "rustls",
2028+ "rustls-native-certs",
2029+ "rustls-pki-types",
2030+ "serde",
2031+ "serde_html_form",
2032+ "serde_json",
2033+ "sha2",
2034+ "tempfile",
2035+ "thiserror 2.0.20",
2036+ "tokio",
2037+ "tokio-stream",
2038+ "tokio-util",
2039+ "tracing",
2040+ "url",
2041+ "urlencoding",
2042+ "vodozemac",
2043+ "webpki-roots",
2044+ "wiremock",
2045+ "zeroize",
2046+]
2047+
2048+[[package]]
2049+name = "matrix-sdk-base"
2050+version = "0.18.0"
2051+source = "registry+https://github.com/rust-lang/crates.io-index"
2052+checksum = "e09a917eb1f7643d9d9a06b2f131e2ceb39df6910a7b830dac18bfd6db37a1f5"
2053+dependencies = [
2054+ "as_variant",
2055+ "assert_matches",
2056+ "assert_matches2",
2057+ "async-trait",
2058+ "bitflags",
2059+ "decancer",
2060+ "eyeball",
2061+ "eyeball-im",
2062+ "futures-util",
2063+ "growable-bloom-filter",
2064+ "http",
2065+ "matrix-sdk-common",
2066+ "matrix-sdk-crypto",
2067+ "matrix-sdk-store-encryption",
2068+ "matrix-sdk-test",
2069+ "regex",
2070+ "ruma",
2071+ "serde",
2072+ "serde_json",
2073+ "thiserror 2.0.20",
2074+ "tokio",
2075+ "tracing",
2076+ "unicode-normalization",
2077+]
2078+
2079+[[package]]
2080+name = "matrix-sdk-common"
2081+version = "0.18.0"
2082+source = "registry+https://github.com/rust-lang/crates.io-index"
2083+checksum = "2b9d1e0fee0f090180ef9457034adc547e80de9e6e3eb2e63aaac68a8476f836"
2084+dependencies = [
2085+ "eyeball-im",
2086+ "futures-core",
2087+ "futures-executor",
2088+ "futures-util",
2089+ "gloo-timers",
2090+ "imbl",
2091+ "ruma",
2092+ "serde",
2093+ "serde_json",
2094+ "thiserror 2.0.20",
2095+ "tokio",
2096+ "tracing",
2097+ "tracing-subscriber",
2098+ "wasm-bindgen",
2099+ "wasm-bindgen-futures",
2100+ "web-sys",
2101+]
2102+
2103+[[package]]
2104+name = "matrix-sdk-crypto"
2105+version = "0.18.0"
2106+source = "registry+https://github.com/rust-lang/crates.io-index"
2107+checksum = "c54afd2a326f51c13a6ad44ec86315a688fffeb3f1e287fb343e0e1836a3bdaf"
2108+dependencies = [
2109+ "aes",
2110+ "aquamarine",
2111+ "as_variant",
2112+ "async-trait",
2113+ "bs58",
2114+ "byteorder",
2115+ "cfg-if",
2116+ "ctr",
2117+ "eyeball",
2118+ "futures-core",
2119+ "futures-util",
2120+ "hkdf",
2121+ "hmac",
2122+ "itertools 0.14.0",
2123+ "js_option",
2124+ "matrix-sdk-common",
2125+ "matrix-sdk-test",
2126+ "pbkdf2",
2127+ "rand 0.10.2",
2128+ "rmp-serde",
2129+ "ruma",
2130+ "serde",
2131+ "serde_json",
2132+ "sha2",
2133+ "subtle",
2134+ "thiserror 2.0.20",
2135+ "time",
2136+ "tokio",
2137+ "tokio-stream",
2138+ "tracing",
2139+ "ulid",
2140+ "url",
2141+ "vodozemac",
2142+ "zeroize",
2143+]
2144+
2145+[[package]]
2146+name = "matrix-sdk-indexeddb"
2147+version = "0.18.0"
2148+source = "registry+https://github.com/rust-lang/crates.io-index"
2149+checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47"
2150+dependencies = [
2151+ "async-trait",
2152+ "base64",
2153+ "futures-util",
2154+ "getrandom 0.4.3",
2155+ "gloo-utils",
2156+ "hkdf",
2157+ "js-sys",
2158+ "matrix-sdk-base",
2159+ "matrix-sdk-crypto",
2160+ "matrix-sdk-store-encryption",
2161+ "matrix_indexed_db_futures",
2162+ "rmp-serde",
2163+ "ruma",
2164+ "serde",
2165+ "serde-wasm-bindgen",
2166+ "serde_json",
2167+ "sha2",
2168+ "thiserror 2.0.20",
2169+ "tokio",
2170+ "tracing",
2171+ "uuid",
2172+ "wasm-bindgen",
2173+ "web-sys",
2174+ "zeroize",
2175+]
2176+
2177+[[package]]
2178+name = "matrix-sdk-sqlite"
2179+version = "0.18.0"
2180+source = "registry+https://github.com/rust-lang/crates.io-index"
2181+checksum = "a49133429271005745f8d5a05362d2ba6567274777250236799927ca30dc0670"
2182+dependencies = [
2183+ "as_variant",
2184+ "async-trait",
2185+ "deadpool 0.13.1",
2186+ "deadpool-sync",
2187+ "itertools 0.14.0",
2188+ "matrix-sdk-base",
2189+ "matrix-sdk-crypto",
2190+ "matrix-sdk-store-encryption",
2191+ "num_cpus",
2192+ "rmp-serde",
2193+ "ruma",
2194+ "rusqlite",
2195+ "serde",
2196+ "serde_json",
2197+ "serde_path_to_error",
2198+ "thiserror 2.0.20",
2199+ "tokio",
2200+ "tracing",
2201+ "vodozemac",
2202+ "zeroize",
2203+]
2204+
2205+[[package]]
2206+name = "matrix-sdk-store-encryption"
2207+version = "0.18.0"
2208+source = "registry+https://github.com/rust-lang/crates.io-index"
2209+checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e"
2210+dependencies = [
2211+ "base64",
2212+ "blake3",
2213+ "chacha20poly1305",
2214+ "getrandom 0.2.17",
2215+ "getrandom 0.4.3",
2216+ "hmac",
2217+ "pbkdf2",
2218+ "rand 0.10.2",
2219+ "rmp-serde",
2220+ "serde",
2221+ "serde_json",
2222+ "sha2",
2223+ "thiserror 2.0.20",
2224+ "zeroize",
2225+]
2226+
2227+[[package]]
2228+name = "matrix-sdk-test"
2229+version = "0.18.0"
2230+source = "registry+https://github.com/rust-lang/crates.io-index"
2231+checksum = "8023d4315fff35598c2d4a955920c1273c6aaab4dcd9ed405238a882581649bf"
2232+dependencies = [
2233+ "as_variant",
2234+ "ctor",
2235+ "getrandom 0.4.3",
2236+ "http",
2237+ "insta",
2238+ "matrix-sdk-common",
2239+ "matrix-sdk-test-macros",
2240+ "ruma",
2241+ "serde",
2242+ "serde_json",
2243+ "sha2",
2244+ "tokio",
2245+ "tracing-subscriber",
2246+ "vodozemac",
2247+ "wasm-bindgen-test",
2248+ "wiremock",
2249+]
2250+
2251+[[package]]
2252+name = "matrix-sdk-test-macros"
2253+version = "0.18.0"
2254+source = "registry+https://github.com/rust-lang/crates.io-index"
2255+checksum = "d97d6a0e5b1401c530b3c5d18cb98257d255d396bacf3903beda6718a2f4c4c5"
2256+dependencies = [
2257+ "quote",
2258+ "syn 2.0.119",
2259+]
2260+
2261+[[package]]
2262+name = "matrix-sdk-ui"
2263+version = "0.18.0"
2264+source = "registry+https://github.com/rust-lang/crates.io-index"
2265+checksum = "6d1f2b8a8cf57164d7cee4eb61c0b9f074b2e11d766a9391b7db0b9f2cbd83d8"
2266+dependencies = [
2267+ "as_variant",
2268+ "async-rx",
2269+ "async-stream",
2270+ "async_cell",
2271+ "bitflags",
2272+ "chrono",
2273+ "emojis",
2274+ "eyeball",
2275+ "eyeball-im",
2276+ "eyeball-im-util",
2277+ "futures-core",
2278+ "futures-util",
2279+ "fuzzy-matcher",
2280+ "growable-bloom-filter",
2281+ "imbl",
2282+ "indexmap",
2283+ "itertools 0.14.0",
2284+ "matrix-sdk",
2285+ "matrix-sdk-base",
2286+ "matrix-sdk-common",
2287+ "mime",
2288+ "pin-project-lite",
2289+ "ruma",
2290+ "serde",
2291+ "serde_json",
2292+ "thiserror 2.0.20",
2293+ "tokio",
2294+ "tokio-stream",
2295+ "tracing",
2296+ "unicode-normalization",
2297+ "unicode-segmentation",
2298+]
2299+
2300+[[package]]
2301+name = "matrix_indexed_db_futures"
2302+version = "0.7.0"
2303+source = "registry+https://github.com/rust-lang/crates.io-index"
2304+checksum = "245ff6a224b4df7b0c90dda2dd5a6eb46112708d49e8bdd8b007fccb09fea8e4"
2305+dependencies = [
2306+ "accessory",
2307+ "cfg-if",
2308+ "delegate-display",
2309+ "derive_more 2.1.1",
2310+ "fancy_constructor",
2311+ "futures-core",
2312+ "js-sys",
2313+ "matrix_indexed_db_futures_macros_internal",
2314+ "sealed 0.6.0",
2315+ "serde",
2316+ "serde-wasm-bindgen",
2317+ "smallvec",
2318+ "thiserror 2.0.20",
2319+ "tokio",
2320+ "wasm-bindgen",
2321+ "wasm-bindgen-futures",
2322+ "wasm_evt_listener",
2323+ "web-sys",
2324+ "web-time",
2325+]
2326+
2327+[[package]]
2328+name = "matrix_indexed_db_futures_macros_internal"
2329+version = "1.0.0"
2330+source = "registry+https://github.com/rust-lang/crates.io-index"
2331+checksum = "b428aee5c0fe9e5babd29e99d289b7f64718c444989aac0442d1fd6d3e3f66d1"
2332+dependencies = [
2333+ "macroific 2.0.0",
2334+ "proc-macro2",
2335+ "quote",
2336+ "syn 2.0.119",
2337+]
2338+
2339+[[package]]
2340+name = "memchr"
2341+version = "2.8.3"
2342+source = "registry+https://github.com/rust-lang/crates.io-index"
2343+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
2344+
2345+[[package]]
2346+name = "mime"
2347+version = "0.3.17"
2348+source = "registry+https://github.com/rust-lang/crates.io-index"
2349+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
2350+
2351+[[package]]
2352+name = "mime2ext"
2353+version = "0.1.54"
2354+source = "registry+https://github.com/rust-lang/crates.io-index"
2355+checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc"
2356+
2357+[[package]]
2358+name = "minicov"
2359+version = "0.3.8"
2360+source = "registry+https://github.com/rust-lang/crates.io-index"
2361+checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
2362+dependencies = [
2363+ "cc",
2364+ "walkdir",
2365+]
2366+
2367+[[package]]
2368+name = "miniz_oxide"
2369+version = "0.9.1"
2370+source = "registry+https://github.com/rust-lang/crates.io-index"
2371+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
2372+dependencies = [
2373+ "adler2",
2374+ "simd-adler32",
2375+]
2376+
2377+[[package]]
2378+name = "mio"
2379+version = "1.2.3"
2380+source = "registry+https://github.com/rust-lang/crates.io-index"
2381+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
2382+dependencies = [
2383+ "libc",
2384+ "wasi",
2385+ "windows-sys 0.61.2",
2386+]
2387+
2388+[[package]]
2389+name = "new_debug_unreachable"
2390+version = "1.0.6"
2391+source = "registry+https://github.com/rust-lang/crates.io-index"
2392+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
2393+
2394+[[package]]
2395+name = "nu-ansi-term"
2396+version = "0.50.3"
2397+source = "registry+https://github.com/rust-lang/crates.io-index"
2398+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
2399+dependencies = [
2400+ "windows-sys 0.61.2",
2401+]
2402+
2403+[[package]]
2404+name = "num-conv"
2405+version = "0.2.2"
2406+source = "registry+https://github.com/rust-lang/crates.io-index"
2407+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
2408+
2409+[[package]]
2410+name = "num-traits"
2411+version = "0.2.19"
2412+source = "registry+https://github.com/rust-lang/crates.io-index"
2413+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
2414+dependencies = [
2415+ "autocfg",
2416+ "libm",
2417+]
2418+
2419+[[package]]
2420+name = "num_cpus"
2421+version = "1.17.0"
2422+source = "registry+https://github.com/rust-lang/crates.io-index"
2423+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
2424+dependencies = [
2425+ "hermit-abi",
2426+ "libc",
2427+]
2428+
2429+[[package]]
2430+name = "oauth2"
2431+version = "5.0.0"
2432+source = "registry+https://github.com/rust-lang/crates.io-index"
2433+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
2434+dependencies = [
2435+ "base64",
2436+ "chrono",
2437+ "getrandom 0.2.17",
2438+ "http",
2439+ "rand 0.8.8",
2440+ "serde",
2441+ "serde_json",
2442+ "serde_path_to_error",
2443+ "sha2",
2444+ "thiserror 1.0.69",
2445+ "url",
2446+]
2447+
2448+[[package]]
2449+name = "oauth2-reqwest"
2450+version = "0.1.0-alpha.3"
2451+source = "registry+https://github.com/rust-lang/crates.io-index"
2452+checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2"
2453+dependencies = [
2454+ "oauth2",
2455+ "reqwest",
2456+]
2457+
2458+[[package]]
2459+name = "once_cell"
2460+version = "1.21.4"
2461+source = "registry+https://github.com/rust-lang/crates.io-index"
2462+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
2463+
2464+[[package]]
2465+name = "oorandom"
2466+version = "11.1.5"
2467+source = "registry+https://github.com/rust-lang/crates.io-index"
2468+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
2469+
2470+[[package]]
2471+name = "opaque-debug"
2472+version = "0.3.1"
2473+source = "registry+https://github.com/rust-lang/crates.io-index"
2474+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
2475+
2476+[[package]]
2477+name = "openssl-probe"
2478+version = "0.2.1"
2479+source = "registry+https://github.com/rust-lang/crates.io-index"
2480+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
2481+
2482+[[package]]
2483+name = "parking"
2484+version = "2.2.1"
2485+source = "registry+https://github.com/rust-lang/crates.io-index"
2486+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2487+
2488+[[package]]
2489+name = "parking_lot"
2490+version = "0.12.5"
2491+source = "registry+https://github.com/rust-lang/crates.io-index"
2492+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
2493+dependencies = [
2494+ "lock_api",
2495+ "parking_lot_core",
2496+]
2497+
2498+[[package]]
2499+name = "parking_lot_core"
2500+version = "0.9.12"
2501+source = "registry+https://github.com/rust-lang/crates.io-index"
2502+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
2503+dependencies = [
2504+ "cfg-if",
2505+ "libc",
2506+ "redox_syscall",
2507+ "smallvec",
2508+ "windows-link",
2509+]
2510+
2511+[[package]]
2512+name = "pbkdf2"
2513+version = "0.12.2"
2514+source = "registry+https://github.com/rust-lang/crates.io-index"
2515+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
2516+dependencies = [
2517+ "digest",
2518+]
2519+
2520+[[package]]
2521+name = "percent-encoding"
2522+version = "2.3.2"
2523+source = "registry+https://github.com/rust-lang/crates.io-index"
2524+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
2525+
2526+[[package]]
2527+name = "pest"
2528+version = "2.9.1"
2529+source = "registry+https://github.com/rust-lang/crates.io-index"
2530+checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"
2531+dependencies = [
2532+ "memchr",
2533+ "ucd-trie",
2534+]
2535+
2536+[[package]]
2537+name = "pest_derive"
2538+version = "2.9.1"
2539+source = "registry+https://github.com/rust-lang/crates.io-index"
2540+checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"
2541+dependencies = [
2542+ "pest",
2543+ "pest_generator",
2544+]
2545+
2546+[[package]]
2547+name = "pest_generator"
2548+version = "2.9.1"
2549+source = "registry+https://github.com/rust-lang/crates.io-index"
2550+checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"
2551+dependencies = [
2552+ "pest",
2553+ "pest_meta",
2554+ "proc-macro2",
2555+ "quote",
2556+ "syn 2.0.119",
2557+]
2558+
2559+[[package]]
2560+name = "pest_meta"
2561+version = "2.9.1"
2562+source = "registry+https://github.com/rust-lang/crates.io-index"
2563+checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"
2564+dependencies = [
2565+ "pest",
2566+]
2567+
2568+[[package]]
2569+name = "phf"
2570+version = "0.13.1"
2571+source = "registry+https://github.com/rust-lang/crates.io-index"
2572+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
2573+dependencies = [
2574+ "phf_shared",
2575+ "serde",
2576+]
2577+
2578+[[package]]
2579+name = "phf_codegen"
2580+version = "0.13.1"
2581+source = "registry+https://github.com/rust-lang/crates.io-index"
2582+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
2583+dependencies = [
2584+ "phf_generator",
2585+ "phf_shared",
2586+]
2587+
2588+[[package]]
2589+name = "phf_generator"
2590+version = "0.13.1"
2591+source = "registry+https://github.com/rust-lang/crates.io-index"
2592+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
2593+dependencies = [
2594+ "fastrand",
2595+ "phf_shared",
2596+]
2597+
2598+[[package]]
2599+name = "phf_shared"
2600+version = "0.13.1"
2601+source = "registry+https://github.com/rust-lang/crates.io-index"
2602+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
2603+dependencies = [
2604+ "siphasher",
2605+]
2606+
2607+[[package]]
2608+name = "pin-project-lite"
2609+version = "0.2.17"
2610+source = "registry+https://github.com/rust-lang/crates.io-index"
2611+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
2612+
2613+[[package]]
2614+name = "pkcs8"
2615+version = "0.10.2"
2616+source = "registry+https://github.com/rust-lang/crates.io-index"
2617+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
2618+dependencies = [
2619+ "der",
2620+ "spki",
2621+]
2622+
2623+[[package]]
2624+name = "pkg-config"
2625+version = "0.3.34"
2626+source = "registry+https://github.com/rust-lang/crates.io-index"
2627+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
2628+
2629+[[package]]
2630+name = "poly1305"
2631+version = "0.8.0"
2632+source = "registry+https://github.com/rust-lang/crates.io-index"
2633+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
2634+dependencies = [
2635+ "cpufeatures 0.2.17",
2636+ "opaque-debug",
2637+ "universal-hash",
2638+]
2639+
2640+[[package]]
2641+name = "potential_utf"
2642+version = "0.1.6"
2643+source = "registry+https://github.com/rust-lang/crates.io-index"
2644+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
2645+dependencies = [
2646+ "zerovec",
2647+]
2648+
2649+[[package]]
2650+name = "powerfmt"
2651+version = "0.2.0"
2652+source = "registry+https://github.com/rust-lang/crates.io-index"
2653+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
2654+
2655+[[package]]
2656+name = "ppv-lite86"
2657+version = "0.2.21"
2658+source = "registry+https://github.com/rust-lang/crates.io-index"
2659+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
2660+dependencies = [
2661+ "zerocopy",
2662+]
2663+
2664+[[package]]
2665+name = "precomputed-hash"
2666+version = "0.1.1"
2667+source = "registry+https://github.com/rust-lang/crates.io-index"
2668+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
2669+
2670+[[package]]
2671+name = "proc-macro-crate"
2672+version = "3.5.0"
2673+source = "registry+https://github.com/rust-lang/crates.io-index"
2674+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
2675+dependencies = [
2676+ "toml_edit",
2677+]
2678+
2679+[[package]]
2680+name = "proc-macro-error-attr2"
2681+version = "2.0.0"
2682+source = "registry+https://github.com/rust-lang/crates.io-index"
2683+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
2684+dependencies = [
2685+ "proc-macro2",
2686+ "quote",
2687+]
2688+
2689+[[package]]
2690+name = "proc-macro-error2"
2691+version = "2.0.1"
2692+source = "registry+https://github.com/rust-lang/crates.io-index"
2693+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
2694+dependencies = [
2695+ "proc-macro-error-attr2",
2696+ "proc-macro2",
2697+ "quote",
2698+]
2699+
2700+[[package]]
2701+name = "proc-macro2"
2702+version = "1.0.107"
2703+source = "registry+https://github.com/rust-lang/crates.io-index"
2704+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
2705+dependencies = [
2706+ "unicode-ident",
2707+]
2708+
2709+[[package]]
2710+name = "prost"
2711+version = "0.14.4"
2712+source = "registry+https://github.com/rust-lang/crates.io-index"
2713+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
2714+dependencies = [
2715+ "bytes",
2716+ "prost-derive",
2717+]
2718+
2719+[[package]]
2720+name = "prost-derive"
2721+version = "0.14.4"
2722+source = "registry+https://github.com/rust-lang/crates.io-index"
2723+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
2724+dependencies = [
2725+ "anyhow",
2726+ "itertools 0.14.0",
2727+ "proc-macro2",
2728+ "quote",
2729+ "syn 2.0.119",
2730+]
2731+
2732+[[package]]
2733+name = "quinn"
2734+version = "0.11.11"
2735+source = "registry+https://github.com/rust-lang/crates.io-index"
2736+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
2737+dependencies = [
2738+ "bytes",
2739+ "cfg_aliases",
2740+ "pin-project-lite",
2741+ "quinn-proto",
2742+ "quinn-udp",
2743+ "rustc-hash",
2744+ "rustls",
2745+ "socket2",
2746+ "thiserror 2.0.20",
2747+ "tokio",
2748+ "tracing",
2749+ "web-time",
2750+]
2751+
2752+[[package]]
2753+name = "quinn-proto"
2754+version = "0.11.17"
2755+source = "registry+https://github.com/rust-lang/crates.io-index"
2756+checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
2757+dependencies = [
2758+ "aws-lc-rs",
2759+ "bytes",
2760+ "getrandom 0.4.3",
2761+ "lru-slab",
2762+ "rand 0.10.2",
2763+ "rand_pcg",
2764+ "ring",
2765+ "rustc-hash",
2766+ "rustls",
2767+ "rustls-pki-types",
2768+ "slab",
2769+ "thiserror 2.0.20",
2770+ "tinyvec",
2771+ "tracing",
2772+ "web-time",
2773+]
2774+
2775+[[package]]
2776+name = "quinn-udp"
2777+version = "0.5.15"
2778+source = "registry+https://github.com/rust-lang/crates.io-index"
2779+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
2780+dependencies = [
2781+ "cfg_aliases",
2782+ "libc",
2783+ "once_cell",
2784+ "socket2",
2785+ "tracing",
2786+ "windows-sys 0.61.2",
2787+]
2788+
2789+[[package]]
2790+name = "quote"
2791+version = "1.0.47"
2792+source = "registry+https://github.com/rust-lang/crates.io-index"
2793+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
2794+dependencies = [
2795+ "proc-macro2",
2796+]
2797+
2798+[[package]]
2799+name = "r-efi"
2800+version = "5.3.0"
2801+source = "registry+https://github.com/rust-lang/crates.io-index"
2802+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
2803+
2804+[[package]]
2805+name = "r-efi"
2806+version = "6.0.0"
2807+source = "registry+https://github.com/rust-lang/crates.io-index"
2808+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
2809+
2810+[[package]]
2811+name = "rand"
2812+version = "0.8.8"
2813+source = "registry+https://github.com/rust-lang/crates.io-index"
2814+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
2815+dependencies = [
2816+ "libc",
2817+ "rand_chacha 0.3.1",
2818+ "rand_core 0.6.4",
2819+]
2820+
2821+[[package]]
2822+name = "rand"
2823+version = "0.9.5"
2824+source = "registry+https://github.com/rust-lang/crates.io-index"
2825+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
2826+dependencies = [
2827+ "rand_chacha 0.9.0",
2828+ "rand_core 0.9.5",
2829+]
2830+
2831+[[package]]
2832+name = "rand"
2833+version = "0.10.2"
2834+source = "registry+https://github.com/rust-lang/crates.io-index"
2835+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
2836+dependencies = [
2837+ "chacha20 0.10.2",
2838+ "getrandom 0.4.3",
2839+ "rand_core 0.10.1",
2840+]
2841+
2842+[[package]]
2843+name = "rand_chacha"
2844+version = "0.3.1"
2845+source = "registry+https://github.com/rust-lang/crates.io-index"
2846+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
2847+dependencies = [
2848+ "ppv-lite86",
2849+ "rand_core 0.6.4",
2850+]
2851+
2852+[[package]]
2853+name = "rand_chacha"
2854+version = "0.9.0"
2855+source = "registry+https://github.com/rust-lang/crates.io-index"
2856+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
2857+dependencies = [
2858+ "ppv-lite86",
2859+ "rand_core 0.9.5",
2860+]
2861+
2862+[[package]]
2863+name = "rand_core"
2864+version = "0.6.4"
2865+source = "registry+https://github.com/rust-lang/crates.io-index"
2866+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
2867+dependencies = [
2868+ "getrandom 0.2.17",
2869+]
2870+
2871+[[package]]
2872+name = "rand_core"
2873+version = "0.9.5"
2874+source = "registry+https://github.com/rust-lang/crates.io-index"
2875+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
2876+dependencies = [
2877+ "getrandom 0.3.4",
2878+]
2879+
2880+[[package]]
2881+name = "rand_core"
2882+version = "0.10.1"
2883+source = "registry+https://github.com/rust-lang/crates.io-index"
2884+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
2885+
2886+[[package]]
2887+name = "rand_pcg"
2888+version = "0.10.2"
2889+source = "registry+https://github.com/rust-lang/crates.io-index"
2890+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
2891+dependencies = [
2892+ "rand_core 0.10.1",
2893+]
2894+
2895+[[package]]
2896+name = "rand_xoshiro"
2897+version = "0.7.0"
2898+source = "registry+https://github.com/rust-lang/crates.io-index"
2899+checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
2900+dependencies = [
2901+ "rand_core 0.9.5",
2902+]
2903+
2904+[[package]]
2905+name = "readlock"
2906+version = "0.1.11"
2907+source = "registry+https://github.com/rust-lang/crates.io-index"
2908+checksum = "6da6f291b23556edd9edaf655a0be2ad8ef8002ff5f1bca62b264f3f58b53f34"
2909+
2910+[[package]]
2911+name = "readlock-tokio"
2912+version = "0.1.6"
2913+source = "registry+https://github.com/rust-lang/crates.io-index"
2914+checksum = "fc7e264f9ec4f3d112e8e2f214e8e7cb5cf3b83278f3570b7e00bfe13d3bd8ff"
2915+dependencies = [
2916+ "tokio",
2917+]
2918+
2919+[[package]]
2920+name = "redox_syscall"
2921+version = "0.5.18"
2922+source = "registry+https://github.com/rust-lang/crates.io-index"
2923+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
2924+dependencies = [
2925+ "bitflags",
2926+]
2927+
2928+[[package]]
2929+name = "regex"
2930+version = "1.13.1"
2931+source = "registry+https://github.com/rust-lang/crates.io-index"
2932+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
2933+dependencies = [
2934+ "aho-corasick",
2935+ "memchr",
2936+ "regex-automata",
2937+ "regex-syntax",
2938+]
2939+
2940+[[package]]
2941+name = "regex-automata"
2942+version = "0.4.18"
2943+source = "registry+https://github.com/rust-lang/crates.io-index"
2944+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
2945+dependencies = [
2946+ "aho-corasick",
2947+ "memchr",
2948+ "regex-syntax",
2949+]
2950+
2951+[[package]]
2952+name = "regex-syntax"
2953+version = "0.8.11"
2954+source = "registry+https://github.com/rust-lang/crates.io-index"
2955+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
2956+
2957+[[package]]
2958+name = "reqwest"
2959+version = "0.13.4"
2960+source = "registry+https://github.com/rust-lang/crates.io-index"
2961+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
2962+dependencies = [
2963+ "base64",
2964+ "bytes",
2965+ "futures-core",
2966+ "futures-util",
2967+ "h2",
2968+ "http",
2969+ "http-body",
2970+ "http-body-util",
2971+ "hyper",
2972+ "hyper-rustls",
2973+ "hyper-util",
2974+ "js-sys",
2975+ "log",
2976+ "percent-encoding",
2977+ "pin-project-lite",
2978+ "quinn",
2979+ "rustls",
2980+ "rustls-pki-types",
2981+ "rustls-platform-verifier",
2982+ "sync_wrapper",
2983+ "tokio",
2984+ "tokio-rustls",
2985+ "tokio-util",
2986+ "tower",
2987+ "tower-http",
2988+ "tower-service",
2989+ "url",
2990+ "wasm-bindgen",
2991+ "wasm-bindgen-futures",
2992+ "wasm-streams",
2993+ "web-sys",
2994+]
2995+
2996+[[package]]
2997+name = "ring"
2998+version = "0.17.14"
2999+source = "registry+https://github.com/rust-lang/crates.io-index"
3000+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
3001+dependencies = [
3002+ "cc",
3003+ "cfg-if",
3004+ "getrandom 0.2.17",
3005+ "libc",
3006+ "untrusted",
3007+ "windows-sys 0.52.0",
3008+]
3009+
3010+[[package]]
3011+name = "rmp"
3012+version = "0.8.15"
3013+source = "registry+https://github.com/rust-lang/crates.io-index"
3014+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
3015+dependencies = [
3016+ "num-traits",
3017+]
3018+
3019+[[package]]
3020+name = "rmp-serde"
3021+version = "1.3.1"
3022+source = "registry+https://github.com/rust-lang/crates.io-index"
3023+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
3024+dependencies = [
3025+ "rmp",
3026+ "serde",
3027+]
3028+
3029+[[package]]
3030+name = "ruma"
3031+version = "0.16.0"
3032+source = "registry+https://github.com/rust-lang/crates.io-index"
3033+checksum = "ee4fe5bfacdb0e95e733da3b6c37d98edf46447a4a8e8dea824e0da266d8ad59"
3034+dependencies = [
3035+ "assign",
3036+ "js_int",
3037+ "js_option",
3038+ "ruma-client-api",
3039+ "ruma-common",
3040+ "ruma-events",
3041+ "ruma-html",
3042+ "web-time",
3043+]
3044+
3045+[[package]]
3046+name = "ruma-client-api"
3047+version = "0.24.0"
3048+source = "registry+https://github.com/rust-lang/crates.io-index"
3049+checksum = "bf7ca43a888ca569168d7e3901f4dd14a777b860bb19f4c08e35414162eb261c"
3050+dependencies = [
3051+ "as_variant",
3052+ "assign",
3053+ "bytes",
3054+ "http",
3055+ "js_int",
3056+ "js_option",
3057+ "maplit",
3058+ "ruma-common",
3059+ "ruma-events",
3060+ "serde",
3061+ "serde_html_form",
3062+ "serde_json",
3063+ "thiserror 2.0.20",
3064+ "url",
3065+ "web-time",
3066+]
3067+
3068+[[package]]
3069+name = "ruma-common"
3070+version = "0.19.0"
3071+source = "registry+https://github.com/rust-lang/crates.io-index"
3072+checksum = "2c3b4f00112791b490acce57df1ce3eb3f88899b045bebcff8a29f75369640cc"
3073+dependencies = [
3074+ "as_variant",
3075+ "base64",
3076+ "bytes",
3077+ "date_header",
3078+ "form_urlencoded",
3079+ "getrandom 0.4.3",
3080+ "http",
3081+ "indexmap",
3082+ "js_int",
3083+ "konst",
3084+ "percent-encoding",
3085+ "rand 0.10.2",
3086+ "regex",
3087+ "ruma-identifiers-validation",
3088+ "ruma-macros",
3089+ "serde",
3090+ "serde_html_form",
3091+ "serde_json",
3092+ "thiserror 2.0.20",
3093+ "time",
3094+ "tracing",
3095+ "url",
3096+ "uuid",
3097+ "web-time",
3098+ "wildmatch",
3099+ "zeroize",
3100+]
3101+
3102+[[package]]
3103+name = "ruma-events"
3104+version = "0.34.0"
3105+source = "registry+https://github.com/rust-lang/crates.io-index"
3106+checksum = "85d2f90830fc131691349b96a69ff53444eb6c3e8dc7869c77961b43cfaf3344"
3107+dependencies = [
3108+ "as_variant",
3109+ "indexmap",
3110+ "js_int",
3111+ "js_option",
3112+ "ruma-common",
3113+ "ruma-html",
3114+ "ruma-macros",
3115+ "serde",
3116+ "serde_json",
3117+ "thiserror 2.0.20",
3118+ "tracing",
3119+ "web-time",
3120+ "wildmatch",
3121+ "zeroize",
3122+]
3123+
3124+[[package]]
3125+name = "ruma-html"
3126+version = "0.8.0"
3127+source = "registry+https://github.com/rust-lang/crates.io-index"
3128+checksum = "48d33a944650f4bbd2188dd204d39dd87a9a1498f14b3a13252910c90ed7cd43"
3129+dependencies = [
3130+ "as_variant",
3131+ "html5ever",
3132+ "tracing",
3133+ "wildmatch",
3134+]
3135+
3136+[[package]]
3137+name = "ruma-identifiers-validation"
3138+version = "0.12.1"
3139+source = "registry+https://github.com/rust-lang/crates.io-index"
3140+checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432"
3141+dependencies = [
3142+ "js_int",
3143+ "thiserror 2.0.20",
3144+]
3145+
3146+[[package]]
3147+name = "ruma-macros"
3148+version = "0.19.0"
3149+source = "registry+https://github.com/rust-lang/crates.io-index"
3150+checksum = "c8cfb39eaa9b9fd389126ff941e060b496add5cbbfef559d80c46e571dda459c"
3151+dependencies = [
3152+ "as_variant",
3153+ "cfg-if",
3154+ "proc-macro-crate",
3155+ "proc-macro2",
3156+ "quote",
3157+ "ruma-identifiers-validation",
3158+ "serde",
3159+ "syn 2.0.119",
3160+ "toml",
3161+]
3162+
3163+[[package]]
3164+name = "rusqlite"
3165+version = "0.37.0"
3166+source = "registry+https://github.com/rust-lang/crates.io-index"
3167+checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
3168+dependencies = [
3169+ "bitflags",
3170+ "fallible-iterator",
3171+ "fallible-streaming-iterator",
3172+ "hashlink",
3173+ "libsqlite3-sys",
3174+ "smallvec",
3175+]
3176+
3177+[[package]]
3178+name = "rustc-hash"
3179+version = "2.1.3"
3180+source = "registry+https://github.com/rust-lang/crates.io-index"
3181+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
3182+
3183+[[package]]
3184+name = "rustc_version"
3185+version = "0.4.1"
3186+source = "registry+https://github.com/rust-lang/crates.io-index"
3187+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
3188+dependencies = [
3189+ "semver",
3190+]
3191+
3192+[[package]]
3193+name = "rustix"
3194+version = "1.1.4"
3195+source = "registry+https://github.com/rust-lang/crates.io-index"
3196+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
3197+dependencies = [
3198+ "bitflags",
3199+ "errno",
3200+ "libc",
3201+ "linux-raw-sys",
3202+ "windows-sys 0.61.2",
3203+]
3204+
3205+[[package]]
3206+name = "rustls"
3207+version = "0.23.44"
3208+source = "registry+https://github.com/rust-lang/crates.io-index"
3209+checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
3210+dependencies = [
3211+ "aws-lc-rs",
3212+ "log",
3213+ "once_cell",
3214+ "rustls-pki-types",
3215+ "rustls-webpki",
3216+ "subtle",
3217+ "zeroize",
3218+]
3219+
3220+[[package]]
3221+name = "rustls-native-certs"
3222+version = "0.8.4"
3223+source = "registry+https://github.com/rust-lang/crates.io-index"
3224+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
3225+dependencies = [
3226+ "openssl-probe",
3227+ "rustls-pki-types",
3228+ "schannel",
3229+ "security-framework",
3230+]
3231+
3232+[[package]]
3233+name = "rustls-pki-types"
3234+version = "1.15.1"
3235+source = "registry+https://github.com/rust-lang/crates.io-index"
3236+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
3237+dependencies = [
3238+ "web-time",
3239+ "zeroize",
3240+]
3241+
3242+[[package]]
3243+name = "rustls-platform-verifier"
3244+version = "0.7.0"
3245+source = "registry+https://github.com/rust-lang/crates.io-index"
3246+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
3247+dependencies = [
3248+ "core-foundation",
3249+ "core-foundation-sys",
3250+ "jni",
3251+ "log",
3252+ "once_cell",
3253+ "rustls",
3254+ "rustls-native-certs",
3255+ "rustls-platform-verifier-android",
3256+ "rustls-webpki",
3257+ "security-framework",
3258+ "security-framework-sys",
3259+ "webpki-root-certs",
3260+ "windows-sys 0.61.2",
3261+]
3262+
3263+[[package]]
3264+name = "rustls-platform-verifier-android"
3265+version = "0.1.1"
3266+source = "registry+https://github.com/rust-lang/crates.io-index"
3267+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
3268+
3269+[[package]]
3270+name = "rustls-webpki"
3271+version = "0.103.15"
3272+source = "registry+https://github.com/rust-lang/crates.io-index"
3273+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
3274+dependencies = [
3275+ "aws-lc-rs",
3276+ "ring",
3277+ "rustls-pki-types",
3278+ "untrusted",
3279+]
3280+
3281+[[package]]
3282+name = "rustversion"
3283+version = "1.0.23"
3284+source = "registry+https://github.com/rust-lang/crates.io-index"
3285+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
3286+
3287+[[package]]
3288+name = "same-file"
3289+version = "1.0.6"
3290+source = "registry+https://github.com/rust-lang/crates.io-index"
3291+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
3292+dependencies = [
3293+ "winapi-util",
3294+]
3295+
3296+[[package]]
3297+name = "schannel"
3298+version = "0.1.29"
3299+source = "registry+https://github.com/rust-lang/crates.io-index"
3300+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
3301+dependencies = [
3302+ "windows-sys 0.61.2",
3303+]
3304+
3305+[[package]]
3306+name = "scoped-tls"
3307+version = "1.0.1"
3308+source = "registry+https://github.com/rust-lang/crates.io-index"
3309+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
3310+
3311+[[package]]
3312+name = "scopeguard"
3313+version = "1.2.0"
3314+source = "registry+https://github.com/rust-lang/crates.io-index"
3315+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
3316+
3317+[[package]]
3318+name = "sealed"
3319+version = "0.6.0"
3320+source = "registry+https://github.com/rust-lang/crates.io-index"
3321+checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
3322+dependencies = [
3323+ "proc-macro2",
3324+ "quote",
3325+ "syn 2.0.119",
3326+]
3327+
3328+[[package]]
3329+name = "sealed"
3330+version = "0.7.0"
3331+source = "registry+https://github.com/rust-lang/crates.io-index"
3332+checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"
3333+dependencies = [
3334+ "proc-macro2",
3335+ "quote",
3336+ "syn 3.0.5",
3337+]
3338+
3339+[[package]]
3340+name = "security-framework"
3341+version = "3.7.0"
3342+source = "registry+https://github.com/rust-lang/crates.io-index"
3343+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
3344+dependencies = [
3345+ "bitflags",
3346+ "core-foundation",
3347+ "core-foundation-sys",
3348+ "libc",
3349+ "security-framework-sys",
3350+]
3351+
3352+[[package]]
3353+name = "security-framework-sys"
3354+version = "2.17.0"
3355+source = "registry+https://github.com/rust-lang/crates.io-index"
3356+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
3357+dependencies = [
3358+ "core-foundation-sys",
3359+ "libc",
3360+]
3361+
3362+[[package]]
3363+name = "semver"
3364+version = "1.0.28"
3365+source = "registry+https://github.com/rust-lang/crates.io-index"
3366+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
3367+
3368+[[package]]
3369+name = "serde"
3370+version = "1.0.229"
3371+source = "registry+https://github.com/rust-lang/crates.io-index"
3372+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
3373+dependencies = [
3374+ "serde_core",
3375+ "serde_derive",
3376+]
3377+
3378+[[package]]
3379+name = "serde-wasm-bindgen"
3380+version = "0.6.5"
3381+source = "registry+https://github.com/rust-lang/crates.io-index"
3382+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
3383+dependencies = [
3384+ "js-sys",
3385+ "serde",
3386+ "wasm-bindgen",
3387+]
3388+
3389+[[package]]
3390+name = "serde_bytes"
3391+version = "0.11.19"
3392+source = "registry+https://github.com/rust-lang/crates.io-index"
3393+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
3394+dependencies = [
3395+ "serde",
3396+ "serde_core",
3397+]
3398+
3399+[[package]]
3400+name = "serde_core"
3401+version = "1.0.229"
3402+source = "registry+https://github.com/rust-lang/crates.io-index"
3403+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
3404+dependencies = [
3405+ "serde_derive",
3406+]
3407+
3408+[[package]]
3409+name = "serde_derive"
3410+version = "1.0.229"
3411+source = "registry+https://github.com/rust-lang/crates.io-index"
3412+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
3413+dependencies = [
3414+ "proc-macro2",
3415+ "quote",
3416+ "syn 3.0.5",
3417+]
3418+
3419+[[package]]
3420+name = "serde_html_form"
3421+version = "0.4.1"
3422+source = "registry+https://github.com/rust-lang/crates.io-index"
3423+checksum = "8f0346d7a342ab90f405cfc08f25d15075f944f42fcabbc5eac923829fa6d228"
3424+dependencies = [
3425+ "form_urlencoded",
3426+ "indexmap",
3427+ "itoa",
3428+ "serde_core",
3429+ "zmij",
3430+]
3431+
3432+[[package]]
3433+name = "serde_json"
3434+version = "1.0.151"
3435+source = "registry+https://github.com/rust-lang/crates.io-index"
3436+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
3437+dependencies = [
3438+ "itoa",
3439+ "memchr",
3440+ "serde",
3441+ "serde_core",
3442+ "zmij",
3443+]
3444+
3445+[[package]]
3446+name = "serde_path_to_error"
3447+version = "0.1.20"
3448+source = "registry+https://github.com/rust-lang/crates.io-index"
3449+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
3450+dependencies = [
3451+ "itoa",
3452+ "serde",
3453+ "serde_core",
3454+]
3455+
3456+[[package]]
3457+name = "serde_spanned"
3458+version = "1.1.1"
3459+source = "registry+https://github.com/rust-lang/crates.io-index"
3460+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
3461+dependencies = [
3462+ "serde_core",
3463+]
3464+
3465+[[package]]
3466+name = "sha2"
3467+version = "0.10.9"
3468+source = "registry+https://github.com/rust-lang/crates.io-index"
3469+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
3470+dependencies = [
3471+ "cfg-if",
3472+ "cpufeatures 0.2.17",
3473+ "digest",
3474+]
3475+
3476+[[package]]
3477+name = "sharded-slab"
3478+version = "0.1.7"
3479+source = "registry+https://github.com/rust-lang/crates.io-index"
3480+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
3481+dependencies = [
3482+ "lazy_static",
3483+]
3484+
3485+[[package]]
3486+name = "shlex"
3487+version = "2.0.1"
3488+source = "registry+https://github.com/rust-lang/crates.io-index"
3489+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
3490+
3491+[[package]]
3492+name = "signal-hook-registry"
3493+version = "1.4.8"
3494+source = "registry+https://github.com/rust-lang/crates.io-index"
3495+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
3496+dependencies = [
3497+ "errno",
3498+ "libc",
3499+]
3500+
3501+[[package]]
3502+name = "signature"
3503+version = "2.2.0"
3504+source = "registry+https://github.com/rust-lang/crates.io-index"
3505+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
3506+dependencies = [
3507+ "rand_core 0.6.4",
3508+]
3509+
3510+[[package]]
3511+name = "simd-adler32"
3512+version = "0.3.10"
3513+source = "registry+https://github.com/rust-lang/crates.io-index"
3514+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
3515+
3516+[[package]]
3517+name = "simd_cesu8"
3518+version = "1.2.0"
3519+source = "registry+https://github.com/rust-lang/crates.io-index"
3520+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
3521+dependencies = [
3522+ "rustc_version",
3523+ "simdutf8",
3524+]
3525+
3526+[[package]]
3527+name = "simdutf8"
3528+version = "0.1.5"
3529+source = "registry+https://github.com/rust-lang/crates.io-index"
3530+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
3531+
3532+[[package]]
3533+name = "similar"
3534+version = "2.7.0"
3535+source = "registry+https://github.com/rust-lang/crates.io-index"
3536+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
3537+
3538+[[package]]
3539+name = "siphasher"
3540+version = "1.0.3"
3541+source = "registry+https://github.com/rust-lang/crates.io-index"
3542+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
3543+
3544+[[package]]
3545+name = "slab"
3546+version = "0.4.12"
3547+source = "registry+https://github.com/rust-lang/crates.io-index"
3548+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
3549+
3550+[[package]]
3551+name = "smallvec"
3552+version = "1.16.0"
3553+source = "registry+https://github.com/rust-lang/crates.io-index"
3554+checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
3555+dependencies = [
3556+ "serde",
3557+]
3558+
3559+[[package]]
3560+name = "socket2"
3561+version = "0.6.5"
3562+source = "registry+https://github.com/rust-lang/crates.io-index"
3563+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
3564+dependencies = [
3565+ "libc",
3566+ "windows-sys 0.61.2",
3567+]
3568+
3569+[[package]]
3570+name = "spki"
3571+version = "0.7.3"
3572+source = "registry+https://github.com/rust-lang/crates.io-index"
3573+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
3574+dependencies = [
3575+ "base64ct",
3576+ "der",
3577+]
3578+
3579+[[package]]
3580+name = "stable_deref_trait"
3581+version = "1.2.1"
3582+source = "registry+https://github.com/rust-lang/crates.io-index"
3583+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
3584+
3585+[[package]]
3586+name = "string_cache"
3587+version = "0.9.0"
3588+source = "registry+https://github.com/rust-lang/crates.io-index"
3589+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
3590+dependencies = [
3591+ "new_debug_unreachable",
3592+ "parking_lot",
3593+ "phf_shared",
3594+ "precomputed-hash",
3595+]
3596+
3597+[[package]]
3598+name = "string_cache_codegen"
3599+version = "0.6.1"
3600+source = "registry+https://github.com/rust-lang/crates.io-index"
3601+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
3602+dependencies = [
3603+ "phf_generator",
3604+ "phf_shared",
3605+ "proc-macro2",
3606+ "quote",
3607+]
3608+
3609+[[package]]
3610+name = "subtle"
3611+version = "2.6.1"
3612+source = "registry+https://github.com/rust-lang/crates.io-index"
3613+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
3614+
3615+[[package]]
3616+name = "syn"
3617+version = "1.0.109"
3618+source = "registry+https://github.com/rust-lang/crates.io-index"
3619+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
3620+dependencies = [
3621+ "proc-macro2",
3622+ "quote",
3623+ "unicode-ident",
3624+]
3625+
3626+[[package]]
3627+name = "syn"
3628+version = "2.0.119"
3629+source = "registry+https://github.com/rust-lang/crates.io-index"
3630+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
3631+dependencies = [
3632+ "proc-macro2",
3633+ "quote",
3634+ "unicode-ident",
3635+]
3636+
3637+[[package]]
3638+name = "syn"
3639+version = "3.0.5"
3640+source = "registry+https://github.com/rust-lang/crates.io-index"
3641+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
3642+dependencies = [
3643+ "proc-macro2",
3644+ "quote",
3645+ "unicode-ident",
3646+]
3647+
3648+[[package]]
3649+name = "sync_wrapper"
3650+version = "1.0.2"
3651+source = "registry+https://github.com/rust-lang/crates.io-index"
3652+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
3653+dependencies = [
3654+ "futures-core",
3655+]
3656+
3657+[[package]]
3658+name = "synstructure"
3659+version = "0.13.2"
3660+source = "registry+https://github.com/rust-lang/crates.io-index"
3661+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
3662+dependencies = [
3663+ "proc-macro2",
3664+ "quote",
3665+ "syn 2.0.119",
3666+]
3667+
3668+[[package]]
3669+name = "tempfile"
3670+version = "3.27.0"
3671+source = "registry+https://github.com/rust-lang/crates.io-index"
3672+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
3673+dependencies = [
3674+ "fastrand",
3675+ "getrandom 0.4.3",
3676+ "once_cell",
3677+ "rustix",
3678+ "windows-sys 0.61.2",
3679+]
3680+
3681+[[package]]
3682+name = "tendril"
3683+version = "0.5.1"
3684+source = "registry+https://github.com/rust-lang/crates.io-index"
3685+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
3686+dependencies = [
3687+ "new_debug_unreachable",
3688+]
3689+
3690+[[package]]
3691+name = "thiserror"
3692+version = "1.0.69"
3693+source = "registry+https://github.com/rust-lang/crates.io-index"
3694+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
3695+dependencies = [
3696+ "thiserror-impl 1.0.69",
3697+]
3698+
3699+[[package]]
3700+name = "thiserror"
3701+version = "2.0.20"
3702+source = "registry+https://github.com/rust-lang/crates.io-index"
3703+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
3704+dependencies = [
3705+ "thiserror-impl 2.0.20",
3706+]
3707+
3708+[[package]]
3709+name = "thiserror-impl"
3710+version = "1.0.69"
3711+source = "registry+https://github.com/rust-lang/crates.io-index"
3712+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
3713+dependencies = [
3714+ "proc-macro2",
3715+ "quote",
3716+ "syn 2.0.119",
3717+]
3718+
3719+[[package]]
3720+name = "thiserror-impl"
3721+version = "2.0.20"
3722+source = "registry+https://github.com/rust-lang/crates.io-index"
3723+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
3724+dependencies = [
3725+ "proc-macro2",
3726+ "quote",
3727+ "syn 3.0.5",
3728+]
3729+
3730+[[package]]
3731+name = "thread_local"
3732+version = "1.1.10"
3733+source = "registry+https://github.com/rust-lang/crates.io-index"
3734+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
3735+dependencies = [
3736+ "cfg-if",
3737+]
3738+
3739+[[package]]
3740+name = "time"
3741+version = "0.3.55"
3742+source = "registry+https://github.com/rust-lang/crates.io-index"
3743+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
3744+dependencies = [
3745+ "deranged",
3746+ "num-conv",
3747+ "powerfmt",
3748+ "serde_core",
3749+ "time-core",
3750+ "time-macros",
3751+]
3752+
3753+[[package]]
3754+name = "time-core"
3755+version = "0.1.9"
3756+source = "registry+https://github.com/rust-lang/crates.io-index"
3757+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
3758+
3759+[[package]]
3760+name = "time-macros"
3761+version = "0.2.32"
3762+source = "registry+https://github.com/rust-lang/crates.io-index"
3763+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
3764+dependencies = [
3765+ "num-conv",
3766+ "time-core",
3767+]
3768+
3769+[[package]]
3770+name = "tinystr"
3771+version = "0.8.4"
3772+source = "registry+https://github.com/rust-lang/crates.io-index"
3773+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
3774+dependencies = [
3775+ "displaydoc",
3776+ "zerovec",
3777+]
3778+
3779+[[package]]
3780+name = "tinyvec"
3781+version = "1.13.2"
3782+source = "registry+https://github.com/rust-lang/crates.io-index"
3783+checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
3784+dependencies = [
3785+ "tinyvec_macros",
3786+]
3787+
3788+[[package]]
3789+name = "tinyvec_macros"
3790+version = "0.1.1"
3791+source = "registry+https://github.com/rust-lang/crates.io-index"
3792+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
3793+
3794+[[package]]
3795+name = "tokio"
3796+version = "1.53.1"
3797+source = "registry+https://github.com/rust-lang/crates.io-index"
3798+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
3799+dependencies = [
3800+ "bytes",
3801+ "libc",
3802+ "mio",
3803+ "pin-project-lite",
3804+ "signal-hook-registry",
3805+ "socket2",
3806+ "tokio-macros",
3807+ "windows-sys 0.61.2",
3808+]
3809+
3810+[[package]]
3811+name = "tokio-macros"
3812+version = "2.7.2"
3813+source = "registry+https://github.com/rust-lang/crates.io-index"
3814+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
3815+dependencies = [
3816+ "proc-macro2",
3817+ "quote",
3818+ "syn 3.0.5",
3819+]
3820+
3821+[[package]]
3822+name = "tokio-rustls"
3823+version = "0.26.5"
3824+source = "registry+https://github.com/rust-lang/crates.io-index"
3825+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
3826+dependencies = [
3827+ "rustls",
3828+ "tokio",
3829+]
3830+
3831+[[package]]
3832+name = "tokio-stream"
3833+version = "0.1.19"
3834+source = "registry+https://github.com/rust-lang/crates.io-index"
3835+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
3836+dependencies = [
3837+ "futures-core",
3838+ "pin-project-lite",
3839+ "tokio",
3840+ "tokio-util",
3841+]
3842+
3843+[[package]]
3844+name = "tokio-util"
3845+version = "0.7.19"
3846+source = "registry+https://github.com/rust-lang/crates.io-index"
3847+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
3848+dependencies = [
3849+ "bytes",
3850+ "futures-core",
3851+ "futures-sink",
3852+ "libc",
3853+ "pin-project-lite",
3854+ "tokio",
3855+]
3856+
3857+[[package]]
3858+name = "toml"
3859+version = "1.1.5+spec-1.1.0"
3860+source = "registry+https://github.com/rust-lang/crates.io-index"
3861+checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"
3862+dependencies = [
3863+ "serde_core",
3864+ "serde_spanned",
3865+ "toml_datetime",
3866+ "toml_parser",
3867+ "winnow",
3868+]
3869+
3870+[[package]]
3871+name = "toml_datetime"
3872+version = "1.1.1+spec-1.1.0"
3873+source = "registry+https://github.com/rust-lang/crates.io-index"
3874+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
3875+dependencies = [
3876+ "serde_core",
3877+]
3878+
3879+[[package]]
3880+name = "toml_edit"
3881+version = "0.25.13+spec-1.1.0"
3882+source = "registry+https://github.com/rust-lang/crates.io-index"
3883+checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
3884+dependencies = [
3885+ "indexmap",
3886+ "toml_datetime",
3887+ "toml_parser",
3888+ "winnow",
3889+]
3890+
3891+[[package]]
3892+name = "toml_parser"
3893+version = "1.1.3+spec-1.1.0"
3894+source = "registry+https://github.com/rust-lang/crates.io-index"
3895+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
3896+dependencies = [
3897+ "winnow",
3898+]
3899+
3900+[[package]]
3901+name = "tower"
3902+version = "0.5.3"
3903+source = "registry+https://github.com/rust-lang/crates.io-index"
3904+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
3905+dependencies = [
3906+ "futures-core",
3907+ "futures-util",
3908+ "pin-project-lite",
3909+ "sync_wrapper",
3910+ "tokio",
3911+ "tower-layer",
3912+ "tower-service",
3913+]
3914+
3915+[[package]]
3916+name = "tower-http"
3917+version = "0.6.11"
3918+source = "registry+https://github.com/rust-lang/crates.io-index"
3919+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
3920+dependencies = [
3921+ "async-compression",
3922+ "bitflags",
3923+ "bytes",
3924+ "futures-core",
3925+ "futures-util",
3926+ "http",
3927+ "http-body",
3928+ "http-body-util",
3929+ "pin-project-lite",
3930+ "tokio",
3931+ "tokio-util",
3932+ "tower",
3933+ "tower-layer",
3934+ "tower-service",
3935+ "url",
3936+]
3937+
3938+[[package]]
3939+name = "tower-layer"
3940+version = "0.3.3"
3941+source = "registry+https://github.com/rust-lang/crates.io-index"
3942+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
3943+
3944+[[package]]
3945+name = "tower-service"
3946+version = "0.3.3"
3947+source = "registry+https://github.com/rust-lang/crates.io-index"
3948+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
3949+
3950+[[package]]
3951+name = "tracing"
3952+version = "0.1.44"
3953+source = "registry+https://github.com/rust-lang/crates.io-index"
3954+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
3955+dependencies = [
3956+ "pin-project-lite",
3957+ "tracing-attributes",
3958+ "tracing-core",
3959+]
3960+
3961+[[package]]
3962+name = "tracing-attributes"
3963+version = "0.1.31"
3964+source = "registry+https://github.com/rust-lang/crates.io-index"
3965+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
3966+dependencies = [
3967+ "proc-macro2",
3968+ "quote",
3969+ "syn 2.0.119",
3970+]
3971+
3972+[[package]]
3973+name = "tracing-core"
3974+version = "0.1.36"
3975+source = "registry+https://github.com/rust-lang/crates.io-index"
3976+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
3977+dependencies = [
3978+ "once_cell",
3979+ "valuable",
3980+]
3981+
3982+[[package]]
3983+name = "tracing-log"
3984+version = "0.2.0"
3985+source = "registry+https://github.com/rust-lang/crates.io-index"
3986+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
3987+dependencies = [
3988+ "log",
3989+ "once_cell",
3990+ "tracing-core",
3991+]
3992+
3993+[[package]]
3994+name = "tracing-subscriber"
3995+version = "0.3.23"
3996+source = "registry+https://github.com/rust-lang/crates.io-index"
3997+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
3998+dependencies = [
3999+ "matchers",
4000+ "nu-ansi-term",
4001+ "once_cell",
4002+ "regex-automata",
4003+ "sharded-slab",
4004+ "smallvec",
4005+ "thread_local",
4006+ "tracing",
4007+ "tracing-core",
4008+ "tracing-log",
4009+]
4010+
4011+[[package]]
4012+name = "try-lock"
4013+version = "0.2.5"
4014+source = "registry+https://github.com/rust-lang/crates.io-index"
4015+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
4016+
4017+[[package]]
4018+name = "typenum"
4019+version = "1.20.1"
4020+source = "registry+https://github.com/rust-lang/crates.io-index"
4021+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
4022+
4023+[[package]]
4024+name = "typewit"
4025+version = "1.15.2"
4026+source = "registry+https://github.com/rust-lang/crates.io-index"
4027+checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6"
4028+
4029+[[package]]
4030+name = "ucd-trie"
4031+version = "0.1.7"
4032+source = "registry+https://github.com/rust-lang/crates.io-index"
4033+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
4034+
4035+[[package]]
4036+name = "ulid"
4037+version = "1.2.1"
4038+source = "registry+https://github.com/rust-lang/crates.io-index"
4039+checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
4040+dependencies = [
4041+ "rand 0.9.5",
4042+ "web-time",
4043+]
4044+
4045+[[package]]
4046+name = "unicode-ident"
4047+version = "1.0.24"
4048+source = "registry+https://github.com/rust-lang/crates.io-index"
4049+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
4050+
4051+[[package]]
4052+name = "unicode-normalization"
4053+version = "0.1.25"
4054+source = "registry+https://github.com/rust-lang/crates.io-index"
4055+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
4056+dependencies = [
4057+ "tinyvec",
4058+]
4059+
4060+[[package]]
4061+name = "unicode-segmentation"
4062+version = "1.13.3"
4063+source = "registry+https://github.com/rust-lang/crates.io-index"
4064+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
4065+
4066+[[package]]
4067+name = "unicode-xid"
4068+version = "0.2.6"
4069+source = "registry+https://github.com/rust-lang/crates.io-index"
4070+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
4071+
4072+[[package]]
4073+name = "universal-hash"
4074+version = "0.5.1"
4075+source = "registry+https://github.com/rust-lang/crates.io-index"
4076+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
4077+dependencies = [
4078+ "crypto-common",
4079+ "subtle",
4080+]
4081+
4082+[[package]]
4083+name = "untrusted"
4084+version = "0.9.0"
4085+source = "registry+https://github.com/rust-lang/crates.io-index"
4086+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
4087+
4088+[[package]]
4089+name = "url"
4090+version = "2.5.8"
4091+source = "registry+https://github.com/rust-lang/crates.io-index"
4092+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
4093+dependencies = [
4094+ "form_urlencoded",
4095+ "idna",
4096+ "percent-encoding",
4097+ "serde",
4098+ "serde_derive",
4099+]
4100+
4101+[[package]]
4102+name = "urlencoding"
4103+version = "2.1.3"
4104+source = "registry+https://github.com/rust-lang/crates.io-index"
4105+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
4106+
4107+[[package]]
4108+name = "utf8_iter"
4109+version = "1.0.4"
4110+source = "registry+https://github.com/rust-lang/crates.io-index"
4111+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
4112+
4113+[[package]]
4114+name = "uuid"
4115+version = "1.26.0"
4116+source = "registry+https://github.com/rust-lang/crates.io-index"
4117+checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
4118+dependencies = [
4119+ "getrandom 0.4.3",
4120+ "js-sys",
4121+ "serde_core",
4122+ "wasm-bindgen",
4123+]
4124+
4125+[[package]]
4126+name = "valuable"
4127+version = "0.1.1"
4128+source = "registry+https://github.com/rust-lang/crates.io-index"
4129+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
4130+
4131+[[package]]
4132+name = "vcpkg"
4133+version = "0.2.15"
4134+source = "registry+https://github.com/rust-lang/crates.io-index"
4135+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
4136+
4137+[[package]]
4138+name = "version_check"
4139+version = "0.9.5"
4140+source = "registry+https://github.com/rust-lang/crates.io-index"
4141+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
4142+
4143+[[package]]
4144+name = "vodozemac"
4145+version = "0.10.0"
4146+source = "registry+https://github.com/rust-lang/crates.io-index"
4147+checksum = "b98bf83c0992966775b8012f194b07b44928996163e5a05b741b43891571ae5b"
4148+dependencies = [
4149+ "aes",
4150+ "arrayvec",
4151+ "base64",
4152+ "base64ct",
4153+ "cbc",
4154+ "chacha20poly1305",
4155+ "curve25519-dalek",
4156+ "ed25519-dalek",
4157+ "getrandom 0.2.17",
4158+ "hkdf",
4159+ "hmac",
4160+ "matrix-pickle",
4161+ "prost",
4162+ "rand 0.8.8",
4163+ "serde",
4164+ "serde_bytes",
4165+ "serde_json",
4166+ "sha2",
4167+ "subtle",
4168+ "thiserror 2.0.20",
4169+ "x25519-dalek",
4170+ "zeroize",
4171+]
4172+
4173+[[package]]
4174+name = "walkdir"
4175+version = "2.5.0"
4176+source = "registry+https://github.com/rust-lang/crates.io-index"
4177+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
4178+dependencies = [
4179+ "same-file",
4180+ "winapi-util",
4181+]
4182+
4183+[[package]]
4184+name = "want"
4185+version = "0.3.1"
4186+source = "registry+https://github.com/rust-lang/crates.io-index"
4187+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
4188+dependencies = [
4189+ "try-lock",
4190+]
4191+
4192+[[package]]
4193+name = "wasi"
4194+version = "0.11.1+wasi-snapshot-preview1"
4195+source = "registry+https://github.com/rust-lang/crates.io-index"
4196+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
4197+
4198+[[package]]
4199+name = "wasip2"
4200+version = "1.0.4+wasi-0.2.12"
4201+source = "registry+https://github.com/rust-lang/crates.io-index"
4202+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
4203+dependencies = [
4204+ "wit-bindgen",
4205+]
4206+
4207+[[package]]
4208+name = "wasm-bindgen"
4209+version = "0.2.128"
4210+source = "registry+https://github.com/rust-lang/crates.io-index"
4211+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
4212+dependencies = [
4213+ "cfg-if",
4214+ "once_cell",
4215+ "rustversion",
4216+ "wasm-bindgen-macro",
4217+ "wasm-bindgen-shared",
4218+]
4219+
4220+[[package]]
4221+name = "wasm-bindgen-futures"
4222+version = "0.4.78"
4223+source = "registry+https://github.com/rust-lang/crates.io-index"
4224+checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
4225+dependencies = [
4226+ "js-sys",
4227+ "wasm-bindgen",
4228+]
4229+
4230+[[package]]
4231+name = "wasm-bindgen-macro"
4232+version = "0.2.128"
4233+source = "registry+https://github.com/rust-lang/crates.io-index"
4234+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
4235+dependencies = [
4236+ "quote",
4237+ "wasm-bindgen-macro-support",
4238+]
4239+
4240+[[package]]
4241+name = "wasm-bindgen-macro-support"
4242+version = "0.2.128"
4243+source = "registry+https://github.com/rust-lang/crates.io-index"
4244+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
4245+dependencies = [
4246+ "bumpalo",
4247+ "proc-macro2",
4248+ "quote",
4249+ "syn 3.0.5",
4250+ "wasm-bindgen-shared",
4251+]
4252+
4253+[[package]]
4254+name = "wasm-bindgen-shared"
4255+version = "0.2.128"
4256+source = "registry+https://github.com/rust-lang/crates.io-index"
4257+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
4258+dependencies = [
4259+ "unicode-ident",
4260+]
4261+
4262+[[package]]
4263+name = "wasm-bindgen-test"
4264+version = "0.3.78"
4265+source = "registry+https://github.com/rust-lang/crates.io-index"
4266+checksum = "45863ef0bef521c12124eb39d9a38513c47db75f22e503c06beacd40afeb35db"
4267+dependencies = [
4268+ "async-trait",
4269+ "cast",
4270+ "js-sys",
4271+ "libm",
4272+ "minicov",
4273+ "nu-ansi-term",
4274+ "num-traits",
4275+ "oorandom",
4276+ "serde",
4277+ "serde_json",
4278+ "wasm-bindgen",
4279+ "wasm-bindgen-futures",
4280+ "wasm-bindgen-test-macro",
4281+ "wasm-bindgen-test-shared",
4282+]
4283+
4284+[[package]]
4285+name = "wasm-bindgen-test-macro"
4286+version = "0.3.78"
4287+source = "registry+https://github.com/rust-lang/crates.io-index"
4288+checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12"
4289+dependencies = [
4290+ "proc-macro2",
4291+ "quote",
4292+ "syn 3.0.5",
4293+]
4294+
4295+[[package]]
4296+name = "wasm-bindgen-test-shared"
4297+version = "0.2.128"
4298+source = "registry+https://github.com/rust-lang/crates.io-index"
4299+checksum = "f37b4f992cebe528ef34964ae69681ac0fe7080071e7298e46008f9d380302af"
4300+
4301+[[package]]
4302+name = "wasm-streams"
4303+version = "0.5.0"
4304+source = "registry+https://github.com/rust-lang/crates.io-index"
4305+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
4306+dependencies = [
4307+ "futures-util",
4308+ "js-sys",
4309+ "wasm-bindgen",
4310+ "wasm-bindgen-futures",
4311+ "web-sys",
4312+]
4313+
4314+[[package]]
4315+name = "wasm_evt_listener"
4316+version = "0.1.0"
4317+source = "registry+https://github.com/rust-lang/crates.io-index"
4318+checksum = "dc92d6378b411ed94839112a36d9dbc77143451d85b05dfb0cce93a78dab1963"
4319+dependencies = [
4320+ "accessory",
4321+ "derivative",
4322+ "derive_more 1.0.0",
4323+ "fancy_constructor",
4324+ "futures-core",
4325+ "js-sys",
4326+ "smallvec",
4327+ "tokio",
4328+ "wasm-bindgen",
4329+ "web-sys",
4330+]
4331+
4332+[[package]]
4333+name = "web-sys"
4334+version = "0.3.105"
4335+source = "registry+https://github.com/rust-lang/crates.io-index"
4336+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
4337+dependencies = [
4338+ "js-sys",
4339+ "wasm-bindgen",
4340+]
4341+
4342+[[package]]
4343+name = "web-time"
4344+version = "1.1.0"
4345+source = "registry+https://github.com/rust-lang/crates.io-index"
4346+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
4347+dependencies = [
4348+ "js-sys",
4349+ "serde",
4350+ "wasm-bindgen",
4351+]
4352+
4353+[[package]]
4354+name = "web_atoms"
4355+version = "0.2.6"
4356+source = "registry+https://github.com/rust-lang/crates.io-index"
4357+checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
4358+dependencies = [
4359+ "phf",
4360+ "phf_codegen",
4361+ "string_cache",
4362+ "string_cache_codegen",
4363+]
4364+
4365+[[package]]
4366+name = "webpki-root-certs"
4367+version = "1.0.9"
4368+source = "registry+https://github.com/rust-lang/crates.io-index"
4369+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
4370+dependencies = [
4371+ "rustls-pki-types",
4372+]
4373+
4374+[[package]]
4375+name = "webpki-roots"
4376+version = "1.0.9"
4377+source = "registry+https://github.com/rust-lang/crates.io-index"
4378+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
4379+dependencies = [
4380+ "rustls-pki-types",
4381+]
4382+
4383+[[package]]
4384+name = "wildmatch"
4385+version = "2.6.1"
4386+source = "registry+https://github.com/rust-lang/crates.io-index"
4387+checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68"
4388+
4389+[[package]]
4390+name = "winapi-util"
4391+version = "0.1.11"
4392+source = "registry+https://github.com/rust-lang/crates.io-index"
4393+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
4394+dependencies = [
4395+ "windows-sys 0.61.2",
4396+]
4397+
4398+[[package]]
4399+name = "windows-core"
4400+version = "0.62.2"
4401+source = "registry+https://github.com/rust-lang/crates.io-index"
4402+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
4403+dependencies = [
4404+ "windows-implement",
4405+ "windows-interface",
4406+ "windows-link",
4407+ "windows-result",
4408+ "windows-strings",
4409+]
4410+
4411+[[package]]
4412+name = "windows-implement"
4413+version = "0.60.2"
4414+source = "registry+https://github.com/rust-lang/crates.io-index"
4415+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
4416+dependencies = [
4417+ "proc-macro2",
4418+ "quote",
4419+ "syn 2.0.119",
4420+]
4421+
4422+[[package]]
4423+name = "windows-interface"
4424+version = "0.59.3"
4425+source = "registry+https://github.com/rust-lang/crates.io-index"
4426+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
4427+dependencies = [
4428+ "proc-macro2",
4429+ "quote",
4430+ "syn 2.0.119",
4431+]
4432+
4433+[[package]]
4434+name = "windows-link"
4435+version = "0.2.1"
4436+source = "registry+https://github.com/rust-lang/crates.io-index"
4437+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
4438+
4439+[[package]]
4440+name = "windows-result"
4441+version = "0.4.1"
4442+source = "registry+https://github.com/rust-lang/crates.io-index"
4443+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
4444+dependencies = [
4445+ "windows-link",
4446+]
4447+
4448+[[package]]
4449+name = "windows-strings"
4450+version = "0.5.1"
4451+source = "registry+https://github.com/rust-lang/crates.io-index"
4452+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
4453+dependencies = [
4454+ "windows-link",
4455+]
4456+
4457+[[package]]
4458+name = "windows-sys"
4459+version = "0.52.0"
4460+source = "registry+https://github.com/rust-lang/crates.io-index"
4461+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
4462+dependencies = [
4463+ "windows-targets",
4464+]
4465+
4466+[[package]]
4467+name = "windows-sys"
4468+version = "0.61.2"
4469+source = "registry+https://github.com/rust-lang/crates.io-index"
4470+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
4471+dependencies = [
4472+ "windows-link",
4473+]
4474+
4475+[[package]]
4476+name = "windows-targets"
4477+version = "0.52.6"
4478+source = "registry+https://github.com/rust-lang/crates.io-index"
4479+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
4480+dependencies = [
4481+ "windows_aarch64_gnullvm",
4482+ "windows_aarch64_msvc",
4483+ "windows_i686_gnu",
4484+ "windows_i686_gnullvm",
4485+ "windows_i686_msvc",
4486+ "windows_x86_64_gnu",
4487+ "windows_x86_64_gnullvm",
4488+ "windows_x86_64_msvc",
4489+]
4490+
4491+[[package]]
4492+name = "windows_aarch64_gnullvm"
4493+version = "0.52.6"
4494+source = "registry+https://github.com/rust-lang/crates.io-index"
4495+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
4496+
4497+[[package]]
4498+name = "windows_aarch64_msvc"
4499+version = "0.52.6"
4500+source = "registry+https://github.com/rust-lang/crates.io-index"
4501+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
4502+
4503+[[package]]
4504+name = "windows_i686_gnu"
4505+version = "0.52.6"
4506+source = "registry+https://github.com/rust-lang/crates.io-index"
4507+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
4508+
4509+[[package]]
4510+name = "windows_i686_gnullvm"
4511+version = "0.52.6"
4512+source = "registry+https://github.com/rust-lang/crates.io-index"
4513+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
4514+
4515+[[package]]
4516+name = "windows_i686_msvc"
4517+version = "0.52.6"
4518+source = "registry+https://github.com/rust-lang/crates.io-index"
4519+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
4520+
4521+[[package]]
4522+name = "windows_x86_64_gnu"
4523+version = "0.52.6"
4524+source = "registry+https://github.com/rust-lang/crates.io-index"
4525+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
4526+
4527+[[package]]
4528+name = "windows_x86_64_gnullvm"
4529+version = "0.52.6"
4530+source = "registry+https://github.com/rust-lang/crates.io-index"
4531+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
4532+
4533+[[package]]
4534+name = "windows_x86_64_msvc"
4535+version = "0.52.6"
4536+source = "registry+https://github.com/rust-lang/crates.io-index"
4537+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
4538+
4539+[[package]]
4540+name = "winnow"
4541+version = "1.0.4"
4542+source = "registry+https://github.com/rust-lang/crates.io-index"
4543+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
4544+dependencies = [
4545+ "memchr",
4546+]
4547+
4548+[[package]]
4549+name = "wiremock"
4550+version = "0.6.5"
4551+source = "registry+https://github.com/rust-lang/crates.io-index"
4552+checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
4553+dependencies = [
4554+ "assert-json-diff",
4555+ "base64",
4556+ "deadpool 0.12.3",
4557+ "futures",
4558+ "http",
4559+ "http-body-util",
4560+ "hyper",
4561+ "hyper-util",
4562+ "log",
4563+ "once_cell",
4564+ "regex",
4565+ "serde",
4566+ "serde_json",
4567+ "tokio",
4568+ "url",
4569+]
4570+
4571+[[package]]
4572+name = "wit-bindgen"
4573+version = "0.57.1"
4574+source = "registry+https://github.com/rust-lang/crates.io-index"
4575+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
4576+
4577+[[package]]
4578+name = "writeable"
4579+version = "0.6.4"
4580+source = "registry+https://github.com/rust-lang/crates.io-index"
4581+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
4582+
4583+[[package]]
4584+name = "x25519-dalek"
4585+version = "2.0.1"
4586+source = "registry+https://github.com/rust-lang/crates.io-index"
4587+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
4588+dependencies = [
4589+ "curve25519-dalek",
4590+ "rand_core 0.6.4",
4591+ "serde",
4592+ "zeroize",
4593+]
4594+
4595+[[package]]
4596+name = "xxhash-rust"
4597+version = "0.8.18"
4598+source = "registry+https://github.com/rust-lang/crates.io-index"
4599+checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
4600+
4601+[[package]]
4602+name = "yoke"
4603+version = "0.8.3"
4604+source = "registry+https://github.com/rust-lang/crates.io-index"
4605+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
4606+dependencies = [
4607+ "stable_deref_trait",
4608+ "yoke-derive",
4609+ "zerofrom",
4610+]
4611+
4612+[[package]]
4613+name = "yoke-derive"
4614+version = "0.8.2"
4615+source = "registry+https://github.com/rust-lang/crates.io-index"
4616+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
4617+dependencies = [
4618+ "proc-macro2",
4619+ "quote",
4620+ "syn 2.0.119",
4621+ "synstructure",
4622+]
4623+
4624+[[package]]
4625+name = "zerocopy"
4626+version = "0.8.56"
4627+source = "registry+https://github.com/rust-lang/crates.io-index"
4628+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
4629+dependencies = [
4630+ "zerocopy-derive",
4631+]
4632+
4633+[[package]]
4634+name = "zerocopy-derive"
4635+version = "0.8.56"
4636+source = "registry+https://github.com/rust-lang/crates.io-index"
4637+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
4638+dependencies = [
4639+ "proc-macro2",
4640+ "quote",
4641+ "syn 2.0.119",
4642+]
4643+
4644+[[package]]
4645+name = "zerofrom"
4646+version = "0.1.8"
4647+source = "registry+https://github.com/rust-lang/crates.io-index"
4648+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
4649+dependencies = [
4650+ "zerofrom-derive",
4651+]
4652+
4653+[[package]]
4654+name = "zerofrom-derive"
4655+version = "0.1.7"
4656+source = "registry+https://github.com/rust-lang/crates.io-index"
4657+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
4658+dependencies = [
4659+ "proc-macro2",
4660+ "quote",
4661+ "syn 2.0.119",
4662+ "synstructure",
4663+]
4664+
4665+[[package]]
4666+name = "zeroize"
4667+version = "1.9.0"
4668+source = "registry+https://github.com/rust-lang/crates.io-index"
4669+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
4670+dependencies = [
4671+ "zeroize_derive",
4672+]
4673+
4674+[[package]]
4675+name = "zeroize_derive"
4676+version = "1.5.0"
4677+source = "registry+https://github.com/rust-lang/crates.io-index"
4678+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
4679+dependencies = [
4680+ "proc-macro2",
4681+ "quote",
4682+ "syn 2.0.119",
4683+]
4684+
4685+[[package]]
4686+name = "zerotrie"
4687+version = "0.2.5"
4688+source = "registry+https://github.com/rust-lang/crates.io-index"
4689+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
4690+dependencies = [
4691+ "displaydoc",
4692+ "yoke",
4693+ "zerofrom",
4694+]
4695+
4696+[[package]]
4697+name = "zerovec"
4698+version = "0.11.8"
4699+source = "registry+https://github.com/rust-lang/crates.io-index"
4700+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
4701+dependencies = [
4702+ "yoke",
4703+ "zerofrom",
4704+ "zerovec-derive",
4705+]
4706+
4707+[[package]]
4708+name = "zerovec-derive"
4709+version = "0.11.6"
4710+source = "registry+https://github.com/rust-lang/crates.io-index"
4711+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
4712+dependencies = [
4713+ "proc-macro2",
4714+ "quote",
4715+ "syn 3.0.5",
4716+]
4717+
4718+[[package]]
4719+name = "zlib-rs"
4720+version = "0.6.7"
4721+source = "registry+https://github.com/rust-lang/crates.io-index"
4722+checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
4723+
4724+[[package]]
4725+name = "zmij"
4726+version = "1.0.23"
4727+source = "registry+https://github.com/rust-lang/crates.io-index"
4728+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Added file: Cargo.toml+42 additions
@@ -0,0 +1,42 @@
1+[package]
2+name = "macltok"
3+version = "0.1.0"
4+edition = "2024"
5+description = "A compact application backend on top of the Matrix Rust SDK"
6+
7+[features]
8+default = []
9+serde = ["dep:serde"]
10+sqlite = ["matrix-sdk/sqlite", "serde", "dep:url"]
11+bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"]
12+
13+[dependencies]
14+futures-util = { version = "0.3.31", default-features = false }
15+http = "1"
16+matrix-sdk = { version = "0.18.0", default-features = false, features = ["e2e-encryption"] }
17+matrix-sdk-crypto = { version = "0.18.0", default-features = false }
18+matrix-sdk-ui = { version = "0.18.0", default-features = false }
19+ruma = { version = "0.16.0", default-features = false }
20+serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }
21+thiserror = "2.0.16"
22+mime = "0.3.17"
23+tokio = { version = "1.47.1", features = ["rt", "sync"] }
24+tracing = "0.1.41"
25+url = { version = "2.5.7", optional = true }
26+
27+[dev-dependencies]
28+matrix-sdk = { version = "0.18.0", default-features = false, features = ["testing"] }
29+matrix-sdk-test = "0.18.0"
30+serde_json = "1.0.143"
31+tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal", "time"] }
32+wiremock = "0.6.5"
33+
34+[[example]]
35+name = "headless"
36+required-features = ["sqlite"]
37+
38+[lints.rust]
39+unsafe_code = "forbid"
40+
41+[lints.clippy]
42+all = "warn"
Added file: README.md+58 additions
@@ -0,0 +1,58 @@
1+# macltok
2+
3+`macltok` is a compact Rust application backend built above `matrix-sdk` and
4+`matrix-sdk-ui`. It aims to provide the stateful behavior needed by a useful
5+Matrix client without depending on a GUI toolkit or inventing another protocol
6+SDK.
7+
8+The crate currently provides
9+
10+- typed commands, owned UI-safe events and bounded per-client transport
11+- Sliding Sync with normalized connection and room-list state
12+- independently retained live room timelines and backwards pagination
13+- text, emote, reply, edit, redaction, reaction, receipt and typing operations
14+- raw encrypted or unencrypted media acquisition with configurable byte limits
15+- encryption state, recovery-key import and SAS verification
16+- password login and session restoration with caller-owned encrypted SQLite stores
17+
18+Matrix room, event, user, device and transaction identifiers remain strongly
19+typed. A toolkit timeline identity covers local echoes without pretending they
20+already have remote event IDs.
21+
22+## Consuming the engine
23+
24+Run `Engine::run` and drain its single `EventStream` independently of command
25+submission. `ClientHandle::send` returns an enqueue ID; match that ID against
26+`CommandSucceeded`, `CommandSucceededWithRoom`, or `CommandFailed` before
27+reporting completion. A successful message command only enters the SDK send
28+queue; the timeline item's send state reports subsequent network delivery.
29+
30+Start a timeline from `TimelineReset`, then apply `TimelineChanged` diffs with
31+`TimelineDiff::try_apply`. If a diff does not match the current snapshot, stop
32+applying diffs for that timeline and send `TimelineCommand::Resync`. Resume only
33+after its fresh `TimelineReset`; the reset precedes the command completion.
34+
35+With `sqlite`, persist the initial `Session::record()` separately from its
36+encrypted SQLite store. On `SessionTokensChanged`, obtain a new record from
37+`ClientHandle::session_record()` and replace the saved record atomically in
38+protected storage. Restore with the same store path and passphrase. Delete the
39+saved credentials after `LoggedOut` or `SessionAccessRejected`, but keep them
40+after `Stopped` so a restart can restore the session. The headless example is
41+login-only; it does not persist sessions across runs.
42+
43+## Dependency policy
44+
45+UI frameworks, image decoders, application-directory discovery and logging
46+subscribers do not belong in this crate. SQLite remains optional through the
47+`sqlite` and `bundled-sqlite` features. Media decoding belongs to the consumer.
48+
49+## Verification
50+
51+```sh
52+nix develop --command cargo fmt -- --check
53+nix develop --command cargo test --all-targets
54+nix develop --command cargo test --all-targets --features sqlite
55+nix develop --command cargo clippy --all-targets -- -D warnings
56+nix develop --command cargo clippy --all-targets --features sqlite -- -D warnings
57+nix flake check
58+```
Added file: docs/design-decisions.md+171 additions
@@ -0,0 +1,171 @@
1+# macltok design decisions
2+
3+This document describes the contract implemented in the working tree.
4+
5+## Engine execution
6+
7+The engine keeps a contextual task registry. Every spawned task is registered
8+with either its command id and kind, a watcher name, or a room id and
9+generation. The run loop continuously joins completed tasks and removes their
10+context. A command-task panic produces its correlated `CommandFailed` event.
11+A watcher or current room-actor panic is terminal to the engine. Shutdown aborts
12+and joins the remaining registered tasks.
13+
14+The engine emits `Authenticated`, then `ConnectionChanged(Connecting)`, before
15+starting the sync service. `ConnectionChanged` also represents the later SDK
16+states and the final `Stopped` transition. `Engine::run` returns an
17+`EngineError` for terminal failure and emits `Failed` before `Stopped` when the
18+event stream remains available.
19+
20+The driver command channel remains lossless and clients must drain
21+`EventStream` independently of command submission. `ClientHandle` also exposes
22+a `watch` receiver for the current connection state. `RoomsChanged` is an
23+invalidation event. Call `ClientHandle::rooms` for current, normalised room
24+summaries.
25+
26+`ClientHandle` sends `Command` values and receives a `CommandId`. Completion is
27+reported as `CommandSucceeded`, `CommandSucceededWithRoom`, or
28+`CommandFailed`. `CommandFailure` contains the id, outer `CommandKind`, typed
29+`CommandErrorKind`, and display message. Direct request and response methods
30+return `CommandResultError`, which is either a delivered `CommandFailure` or a
31+closed reply channel.
32+
33+For timeline sends, success means the SDK send queue accepted the event;
34+network delivery is reported later through timeline send state.
35+
36+`send` reports enqueueing, not completion. Consumers retain the returned id
37+until a terminal result with that id arrives; they match `CommandFailure::kind`
38+rather than parsing its display message. The event stream must be drained while
39+commands are submitted, even when several frontend clients share one engine.
40+
41+## Room lifetime and ordering
42+
43+An open room has an explicit generation and exactly one of three states.
44+
45+- `Opening` owns the generation and actor request sender
46+- `Open` owns the generation and actor request sender
47+- `Closing` owns the generation and a bounded FIFO of deferred open and close commands
48+
49+An `Open` command starts an actor immediately and returns its terminal result
50+only after the actor reports readiness. There is no separate readiness waiter or
51+readiness task. Repeated opens while opening are retained by the actor and each
52+gets one terminal result. A close changes the room to `Closing`, rejects work
53+that needs an open actor, and waits for that generation to exit. Opens and
54+closes during closing are queued in order; after exit, each deferred command is
55+replayed, with a fresh generation started when needed. Events from earlier
56+generations are ignored.
57+
58+Each room actor serialises its timeline work in FIFO order. Different rooms run
59+concurrently. Room mutations which do not require an open timeline use a
60+separate keyed FIFO scheduler. Jobs with the same room, target, or direct-chat
61+key run in submission order while unrelated keys run concurrently. Notification
62+commands and encryption commands each have one dedicated serial worker. The
63+encryption worker owns the verification controller and cancels it before
64+shutdown completes.
65+
66+Pagination has a per-timeline mutex gate and a session-owned completion path.
67+Pagination tasks are reaped as they finish. Closing a timeline aborts its
68+pagination work and emits one terminal result for every still-pending command.
69+There is no public pagination handle. The internal result channel is only the
70+session completion mechanism and is not an engine-wide routing design.
71+
72+`TimelineDiff::try_apply` rejects indices that do not match the consumer's
73+snapshot without modifying that snapshot. A consumer then stops applying diffs
74+for that timeline and sends `TimelineCommand::Resync`. The timeline session
75+aborts and joins its old watcher, atomically subscribes to a fresh SDK snapshot
76+and diff stream, and emits `TimelineReset` before the resync command succeeds.
77+This works for rooms and threads without closing their timelines or pagination.
78+
79+Timeline diffs and resets carry `TimelineId`, which distinguishes a room from a
80+thread by its root event. Thread actors and pagination state belong to the room
81+actor. Stateful timeline commands require the room actor. Standalone room
82+commands such as typing, receipts, send-queue control, membership actions, and
83+moderation use the SDK `Room` directly.
84+
85+## Account construction and secrets
86+
87+`Session::login_password`, `Session::begin_oauth`, and `Session::restore` use
88+one shared account client builder. It configures encrypted SQLite storage,
89+encryption settings, refresh-token handling, and enabled threading without
90+thread subscriptions. `Session::build` passes the authenticated client to the
91+engine.
92+
93+Secret-bearing input types do not derive `Clone`. The store passphrase is
94+borrowed by the shared builder, password login passes its password by reference,
95+and authentication records are cloned only where SDK ownership requires it.
96+
97+The caller owns durable authentication records. Save `Session::record()` after
98+login, then consume `SessionTokensChanged` and atomically replace that saved
99+record with `ClientHandle::session_record()`. A previously captured record may
100+hold an exhausted refresh token; failing to persist a rotation must be surfaced
101+rather than silently treating the stale record as safe to restore. Restore with
102+the same `StoreConfig` path and passphrase. Explicit logout and
103+`SessionAccessRejected` invalidate the saved record and store; `Stopped` alone
104+does not. Neither cookies nor application-specific secret storage belong in the
105+toolkit.
106+
107+## Notifications and room data
108+
109+The notification client is constructed lazily in an `OnceCell` and shares the
110+engine sync service. It is created only when notification resolution is first
111+requested. `PushNotification` exposes typed room and sender metadata and a
112+typed `PushNotificationEvent`. The event is `Raw<AnySyncTimelineEvent>` for a
113+timeline notification or `Raw<StrippedRoomMemberEvent>` for an invite. SDK UI
114+types do not cross the public boundary.
115+
116+Room summaries are normalised once per `rooms` query. `ClientHandle::rooms_filtered`
117+normalises its name search term once before filtering. Summary generation runs
118+display-name requests with bounded concurrency. Sorting is descending latest
119+event timestamp, then case-insensitive name, then room id as a deterministic tie
120+break.
121+
122+Global and room account data retain typed Matrix event-type parameters and raw
123+event content. The handle offers direct raw reads and engine-backed raw writes.
124+
125+## Commands and errors
126+
127+`Command` is grouped into `Account`, `Room`, `Timeline`, `Media`,
128+`Encryption`, `Notification`, and `Lifecycle` domains. `CommandKind` identifies
129+the outer domain in result events.
130+
131+`RoomCommand::InviteUser` contains only room and user ids. It deliberately does
132+not expose a reason because the SDK invite helper accepts none. Kick, ban, and
133+unban retain optional reasons where the protocol supports them.
134+
135+`TimelineCommand::Edit` accepts `MessageEditContent`. Its `Text` and `Emote`
136+variants carry `RichText`, so edited text and emotes preserve plain text,
137+optional Matrix HTML, and mentions. Empty edited bodies fail with `EmptyBody`.
138+
139+`VerificationAction` contains `Confirm`, `Reject`, and `Cancel`. Starting a
140+verification is a target-bearing encryption command rather than an action.
141+Verification progress uses `Waiting`, `Comparison`, `Confirmed`, `Done`,
142+`Cancelled`, and typed `Failed(VerificationFailure)`. Request acceptance, SAS
143+startup and acceptance failures retain their message, while stream termination
144+has dedicated variants. A cancelled verification can be started again.
145+The watcher and action paths claim terminal emission so a racing cancellation
146+cannot replace `Done` with a local `Cancelled` event.
147+
148+`CommandErrorKind` is the stable typed failure category. It includes room and
149+timeline availability, queue pressure, validation, media, verification,
150+authentication, network, server, store, timeline, encryption, and fallback
151+categories. The rendered message preserves operation context while internal
152+errors retain their source chains.
153+
154+## Power levels
155+
156+Power-level reads return the complete local `RoomPowerLevels` mapping.
157+`PowerLevelChanges` applies selected common and well-known event thresholds,
158+and `SetUserPowerLevel` changes one user mapping.
159+
160+The SDK trait that applies a power-level event is crate-private. macltok keeps
161+an atomic local mapping, applies all requested changes to it, and sends one
162+state event. It does not patch upstream and does not send separate events for
163+individual fields.
164+
165+## Public model boundaries
166+
167+The public model uses owned ruma identifiers and typed data wherever it has a
168+stable representation. Raw Matrix event payloads remain `Raw` where arbitrary
169+event content must be preserved. Public output models are non-exhaustive where
170+they are expected to grow. SDK clients remain available through the handle for
171+direct state queries that do not need engine coordination.
Added file: docs/review-2026-09-24.md+393 additions
@@ -0,0 +1,393 @@
1+# macltok review, 2026-09-24
2+
3+Second full review, taken after the restructure and Tier 0 landed. It covers
4+correctness, races, robustness, duplication, antipatterns and how easy the crate
5+is to build a client on, then folds in the API gaps reported by Malice, the
6+first real client built on macltok.
7+
8+Every finding carries an id so fixes can reference it. Status starts as OPEN.
9+SDK claims cite matrix-sdk 0.18 source under `~/.cargo/registry` and were
10+checked against the code rather than recalled.
11+
12+Baseline when written. `cargo fmt --check` clean, all default-feature tests
13+pass. The sqlite and clippy runs are recorded at the end.
14+
15+## C. Correctness
16+
17+### C1. `Verify(Reject)` never rejects. OPEN
18+
19+`VerificationController::reject` (`src/encryption.rs:339-389`) validates the
20+SAS state twice and returns `Ok(())` without ever calling `sas.mismatch()`. The
21+peer is never told the emoji did not match, the local flow stays in
22+comparison, and the command reports success. A user who presses "they don't
23+match" gets no protection from the one action meant to provide it. No test
24+exercises `Reject`.
25+
26+### C2. Sync dies permanently after `SyncState::Error`. OPEN
27+
28+The connection watcher maps `SyncState::Error` to `ConnectionState::Offline`
29+(`src/engine.rs:1338-1341`) and nothing ever calls `SyncService::start` again.
30+In the SDK the supervisor breaks out of its loop on error, and even offline
31+mode ends in `Error` when its reachability check fails
32+(`matrix-sdk-ui sync_service.rs:313-339`). `start()` is the only way back
33+(`sync_service.rs:629-649`). The client sees "Offline" forever while the engine
34+still reports itself healthy. `SyncState::Terminated` has the same problem, it
35+becomes `ConnectionState::Stopped` while the engine keeps running, so
36+`Stopped` now means two different things.
37+
38+Fix direction. Restart with backoff on `Error` and `Terminated`, and report
39+`Stopped` only from engine shutdown.
40+
41+### C3. A room's send queue stays disabled after any send failure. OPEN
42+
43+The SDK disables a room's queue after every send error, recoverable or not
44+(`matrix-sdk send_queue/mod.rs:1011-1019`), and nothing in matrix-sdk-ui turns
45+it back on. macltok re-enables only inside `RetrySend`
46+(`src/engine.rs:2178`), and does so globally. One network blip therefore
47+pauses every later message in that room until the user explicitly retries a
48+specific transaction. Element X re-enables queues when sync returns to
49+running.
50+
51+Fix direction. Re-enable on each transition to `Online`, and keep
52+`RetrySend` for unwedging a single item.
53+
54+### C4. Own encrypted uploads cannot be fetched, and the media cache is split between two formats. OPEN
55+
56+macltok writes ciphertext into the media store under the `Encrypted` source key
57+and decrypts on every read (`src/engine.rs:2799-2821`, `2864-2874`). The SDK
58+stores plaintext under that same key, both in `Media::get_media_content`
59+(`matrix-sdk media.rs:451-480`, `522-528`) and in the send queue. After an
60+upload the send queue renames its plaintext cache entry to the remote
61+`Encrypted` source (`send_queue/upload.rs:928-953`). So fetching your own sent
62+image in an encrypted room reads plaintext, runs it through
63+`AttachmentDecryptor`, fails the hash check and returns `MediaUnavailable`. The
64+reverse also holds. Any SDK path that reads a macltok-written entry gets
65+ciphertext as if it were the image. The media tests force plaintext rooms
66+(`tests/media.rs:766`), which is why this passed.
67+
68+Fix direction. Cache plaintext, matching the SDK, and keep the byte bound by
69+decrypting in a streaming fashion before the write.
70+
71+### C5. Media single-flight is poisoned by a leader panic. OPEN
72+
73+In `spawn_media_fetch` (`src/engine.rs:1870-1911`) followers push their id and
74+return at once, trusting the leader to answer for them. If `fetch_media`
75+panics, the leader's own id gets "command task panicked", the followers get
76+nothing, and the `(MediaId, MediaSize)` entry is never removed. Every later
77+fetch of that media then becomes a follower of a dead leader and never
78+completes. Largely superseded if media fetch becomes a direct query (see
79+M2), but the fix is the same either way. Use a drop guard or a shared future
80+so the entry always clears.
81+
82+### C6. Back pagination can stick at "start reached" forever. OPEN
83+
84+`reached_start` is a cached `AtomicBool` that is only ever set from a
85+pagination result (`src/engine.rs:4060-4071`, `4097`). A limited sync with a
86+gap makes the event cache shrink to the last chunk
87+(`event_cache/caches/room/state.rs:917-924`), which the timeline applies as
88+`VectorDiff::Clear`. History is unloaded again but the flag stays true, so
89+every later `Paginate` emits `StartReached` without fetching anything. The
90+room scrolls to a stub and stops.
91+
92+Fix direction. Drop the flag and follow `Timeline::live_back_pagination_status`
93+(`matrix-sdk-ui timeline/pagination.rs:130-138`), which is authoritative.
94+
95+### C7. Soft logout destroys the store. OPEN
96+
97+`SessionChange::UnknownToken` carries `soft_logout`
98+(`matrix-sdk client/mod.rs:166`), but the watcher collapses it into a
99+payload-free `SessionAccessRejected` (`src/engine.rs:1314`). The README and the
100+event's doc comment both tell the client to delete the saved record and the
101+store. On a soft logout the correct response is to re-authenticate and keep
102+the store, so following the documentation throws away every E2EE key for
103+nothing.
104+
105+### C8. Logout and rejected access leave the engine running. OPEN
106+
107+`spawn_logout` emits `LoggedOut` and returns (`src/engine.rs:825-852`). The
108+sync service, watchers and room actors keep running against a dead session,
109+and every later command fails with an authentication error. Neither logout nor
110+a hard `UnknownToken` shuts the engine down. The engine should stop itself,
111+emitting `LoggedOut` or the rejection before `Stopped`.
112+
113+### C9. `MarkRead` can move the read marker backwards and costs two requests. OPEN
114+
115+`mark_read` (`src/engine.rs:2072-2095`) calls `Room::send_single_receipt`
116+twice. That call does no ordering check
117+(`matrix-sdk room/mod.rs:2167-2201`), so a client that marks an older event
118+drags both the public receipt and the fully-read marker back. The timeline API
119+refuses stale receipts (`timeline/controller/mod.rs:1621-1690`), and
120+`Room::send_multiple_receipts` sets fully read and read in one
121+`/read_markers` call (`room/mod.rs:2213-2230`). Private read receipts are not
122+offered at all.
123+
124+Fix direction. When the room is open, route through the actor and use
125+`Timeline::send_multiple_receipts`. Otherwise use a single
126+`Room::send_multiple_receipts`.
127+
128+### C10. `create_direct` always creates a new room. OPEN
129+
130+`Client::create_dm` never reuses an existing DM
131+(`matrix-sdk client/mod.rs:1830-1849`). Starting a chat with someone you
132+already talk to creates a duplicate every time. `Client::get_dm_room` exists
133+for the lookup (`client/mod.rs:1852`).
134+
135+### C11. Unregistered tasks swallow panics, contradicting the design doc. OPEN
136+
137+The design doc says a watcher panic is terminal. But the timeline watcher
138+(`src/engine.rs:589`), pagination worker (`446`), pagination result task
139+(`472`) and verification watcher (`src/encryption.rs:276`) are bare
140+`tokio::spawn`s. A panic in a timeline watcher freezes that timeline in
141+silence while commands keep succeeding. The reverse mistake also exists. The
142+account-data writers (`src/engine.rs:1111`, `1141`) and the per-room media
143+upload watcher (`1945`) are registered as `TaskContext::Watcher`, so a panic
144+in a single write kills the whole engine.
145+
146+### C12. The room actor blocks on network requests. OPEN
147+
148+Each room actor runs its commands serially and awaits each one. Most sends
149+only enqueue, but `Timeline::redact` on a remote event is a direct HTTP call
150+(`timeline/mod.rs:630`), and so is removing a reaction
151+(`controller/mod.rs:595`). `Room::make_reply_event` can fetch the replied-to
152+event (`room/reply.rs:108`). A slow homeserver therefore stalls every later
153+send, paginate and thread command in that room behind one redaction.
154+
155+### C13. Media registry evicts media that is still on screen. OPEN
156+
157+`MediaRegistry::insert_all` (`src/engine.rs:358-370`) evicts in first-insertion
158+order and does not refresh position on re-registration. One registry of 2048
159+entries is shared by every room. In a long-lived session, the first messages
160+loaded in a room that is still open are the first evicted, and fetching them
161+then fails with `MediaUnknown`. Closed timelines never release their entries,
162+so they compete with open ones.
163+
164+### C14. Smaller correctness issues. OPEN
165+
166+- `CommandResultError::Failed` renders its `Display` through `Debug`
167+ (`src/transport.rs:840`), so users see a struct dump.
168+- `download_bounded` builds a fresh `reqwest::Client` per fetch just to build a
169+ request (`src/engine.rs:3181`). That reloads the TLS roots every time.
170+ `http_client.get(...)` is enough. On a 401 it refreshes the token and then
171+ fails rather than retrying once.
172+- On `Lagged`, the session watcher assumes the dropped message was a token
173+ refresh (`src/engine.rs:1315`). A dropped `UnknownToken` is lost.
174+- Leaving a room does not close its open actor, so its timeline, typing
175+ subscription and room subscription outlive the membership.
176+- `open_room` on a room that is already open succeeds with no `TimelineReset`
177+ (`src/engine.rs:1608`). A second frontend sharing the engine gets success and
178+ no snapshot.
179+- On shutdown, in-flight commands never get a terminal result. That is
180+ acceptable, but the docs should say that `Stopped` fails everything still
181+ outstanding.
182+- A store path reused after a login whose record was never persisted holds a
183+ crypto account for another device, and the SDK refuses it. Document one
184+ store directory per login.
185+
186+## A. API shape and client-builder friendliness
187+
188+### A1. Three completion mechanisms, and the useful one is rationed. OPEN
189+
190+A client must know which of three patterns each operation uses.
191+
192+- `send` returns a `CommandId`. The client correlates it by hand against
193+ `CommandSucceeded`, `CommandSucceededWithRoom` or `CommandFailed` on a
194+ shared stream.
195+- Five direct methods (`join_room`, `create_room`, `create_direct`,
196+ `delete_devices`, and the internal room-result path) await a `oneshot` and
197+ return `CommandResultError`. These emit nothing on the stream.
198+- The account-data writes await a `oneshot` but return `ClientQueryError`,
199+ with no `CommandId` at all.
200+
201+The headless example is mostly bookkeeping of ids. Malice gap 4 comes straight
202+from this. The one pattern a web backend can use, an awaited result per
203+request, is not offered for most operations.
204+
205+### A2. Pure reads modelled as commands, against the doc's own rule. OPEN
206+
207+The design doc says operations are split by whether they change state. Media
208+fetch changes no state, yet it is a `MediaCommand` whose bytes arrive as a
209+`MediaReady` broadcast on the shared stream. Every frontend then receives every
210+other frontend's image bytes, up to 20 MiB each, and the requester has to
211+filter by `MediaId`.
212+
213+### A3. Every timeline item is a `Message`, with presentation strings in `body`. OPEN
214+
215+`normalise_timeline_item` (`src/normalise/timeline.rs`) flattens every event
216+into `TimelineMessage`. The consequences for a client are these.
217+
218+- Membership changes, profile changes and room state changes arrive as
219+ messages whose body reads `[m.room.member]` or similar.
220+- Redacted and undecryptable events carry hard-coded English bodies,
221+ `<message redacted>` and `<unable to decrypt message>`. Only UTD has a flag.
222+ A redaction cannot be told apart from a user who typed that text.
223+- `msgtype` is dropped, so emotes and notices cannot be rendered differently
224+ from text.
225+- No sender avatar, though the SDK profile has one. `display_name_ambiguous`
226+ is ignored, so two "Alice"s look identical.
227+- Reply references hold no preview content, and reply details are never
228+ fetched. The SDK leaves them `Unavailable` until the app calls
229+ `Timeline::fetch_details_for_event` (`timeline/event_item/content/reply.rs:38`),
230+ so replies to anything outside the loaded window never get a sender.
231+- `TimelineItem` is not `#[non_exhaustive]`, so adding a state-event variant
232+ later is a break.
233+
234+This needs a content enum, for example `Message { msgtype, ... }`, `Redacted`,
235+`UnableToDecrypt`, `Membership`, `ProfileChange`, `RoomState`, `Poll` and
236+`Other { event_type }`. It is the largest single rendering gap.
237+
238+### A4. Timeline commands cannot target local echoes. OPEN
239+
240+`Edit`, `Redact` and `ToggleReaction` take an `OwnedEventId` and wrap it in
241+`TimelineEventItemId::EventId`. The SDK accepts transaction ids for all three,
242+but a client cannot edit, redact or react to a message it sent until the
243+server echo arrives. The commands should take the identifier that the model
244+already exposes.
245+
246+### A5. `RoomSummary` is too thin for a room list. OPEN
247+
248+Missing fields, all cheap SDK reads. Topic, canonical alias, joined and invited
249+member counts, heroes, tombstone and successor, inviter for invites, a latest
250+event preview, and space parents and children (Malice gap 3).
251+
252+### A6. No incoming verification. OPEN
253+
254+Only outgoing `StartVerification` exists. A request started from another
255+device, which is the normal "verify this new login" flow, is never observed.
256+macltok registers no handler for it, and the SDK has no stream, so the app
257+pattern is an event handler plus `Encryption::get_verification_request`
258+(`encryption/mod.rs:1060`).
259+
260+### A7. Missing `#[non_exhaustive]`. OPEN
261+
262+Adding the attribute later is itself a breaking change, so it belongs now. Output types that will
263+grow are `TimelineItem`, `CommandKind`, `RoomMembership`, `VerificationState`,
264+`HistoryState`, `KeyBackupState` and `RecoveryState`. Input structs that will
265+grow need a constructor plus `#[non_exhaustive]`. These are
266+`CreateRoomOptions`, `PasswordLogin`, `OAuthLogin` and `UploadThumbnail`.
267+
268+### A8. Smaller API issues. OPEN
269+
270+- `MediaCommand::Upload` duplicates `UploadAttachment`, and the handle exposes
271+ both `upload_media` and `upload_attachment`.
272+- Convenience methods cover room and account commands but not timeline send,
273+ edit, paginate, mark read, typing, recovery or verification, so a client uses
274+ two styles side by side.
275+- `HistoryState::Failed(String)` and `RoomPowerLevels::events: Vec<(String, i64)>`
276+ are stringly typed where typed values exist.
277+- `rooms_filtered` re-implements the search half of `RoomListFilter::matches`,
278+ which itself lowercases the needle once per room.
279+- `RoomsChanged` is sent once per notable room-info update with no coalescing
280+ at source. A sync touching fifty rooms queues fifty invalidations, each of
281+ which prompts a full `rooms()` rebuild in a naive client.
282+- The encryption defaults (`auto_enable_backups`, `OneShot` backup download)
283+ are hard-coded with no override.
284+
285+## D. Duplication and antipatterns
286+
287+Line references are from the mechanical inventory taken during this review.
288+
289+- `RoomActor::run` is about 700 lines. The "queue while the thread is opening,
290+ else QueueFull" block appears four times (`src/engine.rs:3367-3402`,
291+ `3518-3549`, `3625-3644`), and emit-then-`break 'actor` about twenty times.
292+ A thread registry type with one `route(id, root, command)` method removes
293+ most of it.
294+- Six `spawn_*` helpers share one shape of clone, spawn, run, emit
295+ (`src/engine.rs:825-960`). `spawn_rename_device` hand-rolls the same thing
296+ without `emit_command_result`. `CommandFailure` is built by hand in nine
297+ places. The reply-or-event branch appears nine times. One `spawn_command(id,
298+ kind, future)` plus one completion sink covers all of them, and A1 removes
299+ the branch entirely.
300+- `media_http_request` (`src/engine.rs:2991-3175`) is six near-identical
301+ branches that differ by endpoint type and token mode.
302+- Twenty-one `client.get_room(...)` lookups with hand-written `RoomUnknown`
303+ errors, ten of them consecutive match arms in `execute_room_command`.
304+- `media_from_message` and `media_registration` walk `MessageType` twice for
305+ the same item. The descriptor can be derived from the registration.
306+- The verification controller repeats its "done, then cancelled, then error"
307+ terminal check eight times. It also carries an unused `_client` parameter,
308+ and the encryption worker builds a throwaway `VerificationController` to run
309+ `Recover`. `EncryptionWork` is a single-variant enum.
310+- `catch_unwind` with a "... panicked" string appears six times.
311+- Test helpers `receive_until`, `event_messages`, `diff_items`, `start_engine`
312+ and `stop` are copied across up to five test files. A shared
313+ `tests/support/mod.rs` removes them.
314+- `model.rs` has its test module in the middle of the file, with types
315+ defined after it.
316+
317+## T. Test coverage
318+
319+No test touches the whole encryption domain (recovery, backups, verification),
320+all notification commands, invite, kick, ban and unban, power levels,
321+`MarkRead`, `SetTyping`, `Redact`, `ToggleReaction`, `CloseThread`, `Logout`,
322+`RenameDevice`, `DeleteDevices`, `resolve_push_notification` or
323+`rooms_filtered`. No test uses an encrypted room, which is how C4 got through.
324+
325+## M. Malice gaps, synthesised
326+
327+Malice reported four gaps. Each one traces back to a finding above, and fixing
328+the underlying finding closes the gap without special-casing it.
329+
330+| Malice gap | Root cause | Proposed shape |
331+| --- | --- | --- |
332+| Own profile | Not exposed at all | `ClientHandle::own_profile()`, a direct read via `Account::fetch_user_profile` (`account.rs:353`), returning a `Profile { user_id, display_name, avatar }`. Add `user_profile(user_id)` alongside. Setting the name and avatar become commands. |
333+| MXC avatar download | Media is reachable only through registered `MediaId`s (A2) | `ClientHandle::mxc_media(uri, MediaRequest)` as a bounded direct query, sharing the download, bound and cache code with timeline media. |
334+| Space children | `RoomSummary` gap (A5) | Wrap `matrix_sdk_ui::spaces::SpaceService` (`spaces/mod.rs`), which offers top-level joined spaces, `joined_parents_of_child` and a paginated `/hierarchy` `SpaceRoomList`. |
335+| Timeline media bytes per request | A1 and A2 | `ClientHandle::media(media_id, size)` as an awaited direct query. Remove `MediaCommand::Fetch` and `Event::MediaReady`. |
336+
337+Malice's rule, that no `matrix-sdk` use remains in the app, holds once these
338+land. None of the four needs an escape hatch.
339+
340+## Discussion items
341+
342+These are genuine trade-offs rather than fixes, each with a recommendation.
343+
344+### Q1. How far to take awaited results
345+
346+Option 1. Keep `send` and add `handle.execute(command).await -> Result<CommandOutput, CommandFailure>`
347+by carrying an optional `oneshot` in `CommandEnvelope`. Stream result events
348+continue for `send`. This is additive, but it leaves two mechanisms and every
349+engine path still branches on reply versus event.
350+
351+Option 2. Make awaited results the only completion path. Remove `CommandId`,
352+`CommandSucceeded*`, `CommandFailed` and `InteractiveAuthRequired` from the
353+stream, which then carries only state changes. Fire-and-forget callers spawn a
354+task. Every engine path produces one `Result` and the nine reply-or-event
355+branches disappear.
356+
357+The cost of option 2 is ordering. Today a `TimelineReset` is guaranteed to
358+precede the `CommandSucceeded` for its `Open` or `Resync` on the same stream.
359+Across a stream and a `oneshot` the guarantee weakens to "enqueued before", so
360+a client draining the stream in another task can see the future resolve first.
361+Either `Open` and `Resync` return the snapshot in their result and stop
362+emitting `TimelineReset`, which then needs an epoch on `TimelineChanged` so
363+stale diffs from the old watcher can be discarded, or the contract states that
364+the snapshot arrives on the stream at some point after success. A second
365+frontend sharing the engine also stops seeing the other's outcomes, though it
366+still sees every resulting state change.
367+
368+Recommendation. Option 2 with snapshot-in-result and an epoch. It is the
369+larger break, but it is the shape every client ends up wrapping the current API
370+into, and breakage is free now.
371+
372+### Q2. Space children source
373+
374+`SpaceService` gives the full graph, including unjoined children, over the
375+network via `/hierarchy`. Local `m.space.child` state is instant but lists only
376+what sync delivered and cannot show rooms the user has not joined.
377+Recommendation. Own a `SpaceService` in the engine, as with `SyncService`. Put
378+`parent_spaces` and `child_rooms` on `RoomSummary` from local state for the
379+sidebar, and add a paginated hierarchy query for browsing.
380+
381+### Q3. Timeline content model depth
382+
383+A3 can be closed with a coarse enum (message, redacted, UTD, state, other) or
384+with typed variants for membership and profile changes, room name and topic,
385+polls and calls. Coarse is quick but pushes raw event parsing onto every
386+client. Recommendation. Typed variants for membership, profile, name, topic,
387+avatar and encryption changes, which cover what Element shows inline, plus
388+`Other { event_type }` as the escape.
389+
390+## Verification results
391+
392+- `cargo fmt -- --check` clean.
393+- `cargo test --all-targets` 49 passed, none failed.
Added file: examples/headless.rs+92 additions
@@ -0,0 +1,92 @@
1+use macltok::{
2+ Command, CommandKind, EngineConfig, Event, LifecycleCommand, PasswordLogin, RoomCommand,
3+ RoomMembership, Session, StoreConfig, TimelineCommand,
4+};
5+
6+#[tokio::main]
7+async fn main() -> Result<(), Box<dyn std::error::Error>> {
8+ let homeserver = required("MATRIX_HOMESERVER")?;
9+ let username = required("MATRIX_USERNAME")?;
10+ let password = required("MATRIX_PASSWORD")?;
11+ let store_path = required("MATRIX_STORE_PATH")?;
12+ let store_passphrase = required("MATRIX_STORE_PASSPHRASE")?;
13+ let session = Session::login_password(
14+ PasswordLogin {
15+ homeserver,
16+ username,
17+ password,
18+ device_display_name: Some("macltok headless example".into()),
19+ },
20+ StoreConfig::new(store_path, store_passphrase),
21+ )
22+ .await?;
23+ let (engine, handle, mut events) = session.build(EngineConfig::default()).await?;
24+ let running = tokio::spawn(engine.run());
25+ let mut room_open_id = None;
26+ let mut resync_id = None;
27+ let mut timeline_items = Vec::new();
28+
29+ loop {
30+ tokio::select! {
31+ event = events.recv() => {
32+ let Some(event) = event else { break; };
33+ match event {
34+ Event::RoomsChanged if room_open_id.is_none() => {
35+ if let Some(room) = handle
36+ .rooms()
37+ .await
38+ .into_iter()
39+ .find(|room| room.membership == RoomMembership::Joined)
40+ {
41+ room_open_id = Some(handle
42+ .send(Command::Room(RoomCommand::Open(room.room_id)))
43+ .await?);
44+ }
45+ }
46+ Event::TimelineReset { items, .. } => {
47+ timeline_items = items;
48+ resync_id = None;
49+ }
50+ Event::TimelineChanged { timeline_id, diffs } if resync_id.is_none() => {
51+ for diff in diffs {
52+ if diff.try_apply(&mut timeline_items).is_err() {
53+ resync_id = Some(handle.send(Command::Timeline(
54+ TimelineCommand::Resync { timeline_id },
55+ )).await?);
56+ break;
57+ }
58+ }
59+ if resync_id.is_none() {
60+ for item in &timeline_items {
61+ if let macltok::TimelineItem::Message(message) = item {
62+ println!("{} {}", message.sender_name, message.body);
63+ }
64+ }
65+ }
66+ }
67+ Event::CommandSucceeded { id, command: CommandKind::Room }
68+ if room_open_id == Some(id) => println!("room ready"),
69+ Event::CommandFailed(error) if room_open_id == Some(error.id)
70+ || resync_id == Some(error.id) => return Err(error.message.into()),
71+ Event::CommandFailed(error) => {
72+ eprintln!("{:?} {}", error.command, error.message);
73+ }
74+ Event::Stopped => break,
75+ _ => {}
76+ }
77+ }
78+ result = tokio::signal::ctrl_c() => {
79+ result?;
80+ handle
81+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
82+ .await?;
83+ }
84+ }
85+ }
86+ running.await??;
87+ Ok(())
88+}
89+
90+fn required(name: &str) -> Result<String, Box<dyn std::error::Error>> {
91+ std::env::var(name).map_err(|_| format!("{name} is required").into())
92+}
Added file: flake.lock+27 additions
@@ -0,0 +1,27 @@
1+{
2+ "nodes": {
3+ "nixpkgs": {
4+ "locked": {
5+ "lastModified": 1788746152,
6+ "narHash": "sha256-RaKngusl5I8pNi8RfrVvsHq3NZe7eFWzDlb01+hEVqY=",
7+ "owner": "NixOS",
8+ "repo": "nixpkgs",
9+ "rev": "42f17a57f4f6e33b3de3dca0a2a5ea5233169d02",
10+ "type": "github"
11+ },
12+ "original": {
13+ "owner": "NixOS",
14+ "ref": "nixpkgs-unstable",
15+ "repo": "nixpkgs",
16+ "type": "github"
17+ }
18+ },
19+ "root": {
20+ "inputs": {
21+ "nixpkgs": "nixpkgs"
22+ }
23+ }
24+ },
25+ "root": "root",
26+ "version": 7
27+}
Added file: flake.nix+62 additions
@@ -0,0 +1,62 @@
1+{
2+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
3+
4+ outputs =
5+ { self, nixpkgs }:
6+ let
7+ forAllSystems = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
8+ in
9+ {
10+ packages = forAllSystems (
11+ system:
12+ let
13+ pkgs = nixpkgs.legacyPackages.${system};
14+ in
15+ {
16+ default = pkgs.rustPlatform.buildRustPackage {
17+ pname = "macltok";
18+ version = "0.1.0";
19+ src = self;
20+ cargoLock.lockFile = ./Cargo.lock;
21+ preBuild = "export NIX_BUILD_CORES=2";
22+ SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
23+ };
24+ }
25+ );
26+
27+ checks = forAllSystems (system: {
28+ default = self.packages.${system}.default;
29+ sqlite = nixpkgs.legacyPackages.${system}.rustPlatform.buildRustPackage {
30+ pname = "macltok-sqlite-check";
31+ version = "0.1.0";
32+ src = self;
33+ cargoLock.lockFile = ./Cargo.lock;
34+ preBuild = "export NIX_BUILD_CORES=2";
35+ buildFeatures = [ "sqlite" ];
36+ nativeBuildInputs = [ nixpkgs.legacyPackages.${system}.pkg-config ];
37+ buildInputs = [ nixpkgs.legacyPackages.${system}.sqlite ];
38+ SSL_CERT_FILE = "${nixpkgs.legacyPackages.${system}.cacert}/etc/ssl/certs/ca-bundle.crt";
39+ };
40+ });
41+
42+ devShells = forAllSystems (
43+ system:
44+ let
45+ pkgs = nixpkgs.legacyPackages.${system};
46+ in
47+ {
48+ default = pkgs.mkShell {
49+ packages = with pkgs; [
50+ cargo
51+ clippy
52+ pkg-config
53+ rust-analyzer
54+ rustc
55+ rustfmt
56+ sqlite
57+ ];
58+ };
59+ }
60+ );
61+ };
62+}
Added file: src/account.rs+321 additions
@@ -0,0 +1,321 @@
1+use std::{
2+ collections::HashMap,
3+ path::{Path, PathBuf},
4+};
5+
6+use matrix_sdk::{
7+ Client, ClientBuilder, ThreadingSupport,
8+ authentication::{
9+ AuthSession,
10+ matrix::MatrixSession,
11+ oauth::{
12+ ClientId, ClientRegistrationData, CsrfToken, OAuthSession, UserSession,
13+ registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},
14+ },
15+ },
16+ encryption::{BackupDownloadStrategy, EncryptionSettings},
17+ ruma::{OwnedDeviceId, serde::Raw},
18+};
19+use serde::{Deserialize, Serialize};
20+use thiserror::Error;
21+use url::Url;
22+
23+use crate::{ClientHandle, Engine, EngineConfig, EngineError, EventStream};
24+
25+pub struct Session {
26+ client: Client,
27+ record: SessionRecord,
28+}
29+
30+pub struct PasswordLogin {
31+ pub homeserver: String,
32+ pub username: String,
33+ pub password: String,
34+ pub device_display_name: Option<String>,
35+}
36+
37+pub struct OAuthLogin {
38+ pub homeserver: String,
39+ pub redirect_uri: String,
40+ pub client_uri: String,
41+ pub client_name: Option<String>,
42+ pub client_id: Option<String>,
43+ pub device_id: Option<OwnedDeviceId>,
44+ pub application_type: OAuthApplicationType,
45+}
46+
47+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48+pub enum OAuthApplicationType {
49+ Native,
50+ Web,
51+}
52+
53+pub struct PendingOAuthLogin {
54+ client: Client,
55+ authorization_url: String,
56+ state: Option<CsrfToken>,
57+}
58+
59+impl PendingOAuthLogin {
60+ #[must_use]
61+ pub fn authorization_url(&self) -> &str {
62+ &self.authorization_url
63+ }
64+
65+ #[must_use]
66+ pub fn registered_client_id(&self) -> String {
67+ self.client
68+ .oauth()
69+ .client_id()
70+ .expect("OAuth client was registered before authorization")
71+ .as_str()
72+ .to_owned()
73+ }
74+
75+ #[must_use]
76+ pub const fn is_pending(&self) -> bool {
77+ self.state.is_some()
78+ }
79+
80+ pub async fn finish(&mut self, callback_url: &str) -> Result<Session, AccountError> {
81+ let callback_url = Url::parse(callback_url)
82+ .map_err(|error| account_error("parsing the OAuth callback URL", error))?;
83+ let state = self
84+ .state
85+ .take()
86+ .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;
87+ let oauth = self.client.oauth();
88+ if let Err(error) = oauth.finish_login(callback_url.into()).await {
89+ oauth.abort_login(&state).await;
90+ return Err(account_error("OAuth login", error));
91+ }
92+ Session::from_client(self.client.clone())
93+ }
94+
95+ pub async fn abort(mut self) {
96+ if let Some(state) = self.state.take() {
97+ self.client.oauth().abort_login(&state).await;
98+ }
99+ }
100+}
101+
102+pub struct StoreConfig {
103+ path: PathBuf,
104+ passphrase: String,
105+}
106+
107+impl StoreConfig {
108+ #[must_use]
109+ pub fn new(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {
110+ Self {
111+ path: path.into(),
112+ passphrase: passphrase.into(),
113+ }
114+ }
115+
116+ #[must_use]
117+ pub fn path(&self) -> &Path {
118+ &self.path
119+ }
120+}
121+
122+/// Serializable authentication secrets. Store this separately from the
123+/// encrypted SQLite store in protected, preferably encrypted storage.
124+/// A saved record must be replaced when `Event::SessionTokensChanged` arrives.
125+#[derive(Clone, Deserialize, Serialize)]
126+pub struct SessionRecord {
127+ homeserver: String,
128+ authentication: AuthenticationRecord,
129+}
130+
131+#[derive(Clone, Deserialize, Serialize)]
132+#[serde(tag = "type", content = "session", rename_all = "snake_case")]
133+enum AuthenticationRecord {
134+ Matrix(MatrixSession),
135+ OAuth {
136+ client_id: ClientId,
137+ user: UserSession,
138+ },
139+}
140+
141+impl SessionRecord {
142+ #[must_use]
143+ pub fn homeserver(&self) -> &str {
144+ &self.homeserver
145+ }
146+}
147+
148+#[derive(Debug, Error)]
149+pub enum AccountError {
150+ #[error("{context} failed")]
151+ Operation {
152+ context: &'static str,
153+ #[source]
154+ source: Box<dyn std::error::Error + Send + Sync>,
155+ },
156+ #[error("the homeserver returned no authenticated session")]
157+ MissingSession,
158+ #[error("the SDK returned an unsupported authentication session")]
159+ UnsupportedAuthentication,
160+ #[error("the OAuth callback was already submitted")]
161+ OAuthCallbackAlreadySubmitted,
162+ #[error(transparent)]
163+ Engine(#[from] EngineError),
164+}
165+
166+impl Session {
167+ pub async fn login_password(
168+ login: PasswordLogin,
169+ store: StoreConfig,
170+ ) -> Result<Self, AccountError> {
171+ let client = matrix_client_builder(&store)
172+ .server_name_or_homeserver_url(login.homeserver.trim())
173+ .build()
174+ .await
175+ .map_err(|error| account_error("building the Matrix client", error))?;
176+ let mut request = client
177+ .matrix_auth()
178+ .login_username(login.username.trim(), &login.password)
179+ .request_refresh_token();
180+ if let Some(name) = login.device_display_name.as_deref() {
181+ request = request.initial_device_display_name(name);
182+ }
183+ request
184+ .send()
185+ .await
186+ .map_err(|error| account_error("password login", error))?;
187+ Self::from_client(client)
188+ }
189+
190+ pub async fn begin_oauth(
191+ login: OAuthLogin,
192+ store: StoreConfig,
193+ ) -> Result<PendingOAuthLogin, AccountError> {
194+ let redirect_uri = Url::parse(login.redirect_uri.trim())
195+ .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;
196+ let client_uri = Url::parse(login.client_uri.trim())
197+ .map_err(|error| account_error("parsing the OAuth client URI", error))?;
198+ let client = matrix_client_builder(&store)
199+ .server_name_or_homeserver_url(login.homeserver.trim())
200+ .build()
201+ .await
202+ .map_err(|error| account_error("building the Matrix client", error))?;
203+ let application_type = match login.application_type {
204+ OAuthApplicationType::Native => ApplicationType::Native,
205+ OAuthApplicationType::Web => ApplicationType::Web,
206+ };
207+ let mut metadata = ClientMetadata::new(
208+ application_type,
209+ vec![OAuthGrantType::AuthorizationCode {
210+ redirect_uris: vec![redirect_uri.clone()],
211+ }],
212+ Localized::new(client_uri, []),
213+ );
214+ metadata.client_name = login.client_name.map(|name| Localized::new(name, []));
215+ let metadata = Raw::new(&metadata)
216+ .map_err(|error| account_error("serializing OAuth client metadata", error))?;
217+ let mut registration = ClientRegistrationData::new(metadata);
218+ if let Some(client_id) = login.client_id {
219+ registration.static_registrations = Some(HashMap::from([(
220+ client.homeserver(),
221+ ClientId::new(client_id),
222+ )]));
223+ }
224+ let authorization = client
225+ .oauth()
226+ .login(redirect_uri, login.device_id, Some(registration), None)
227+ .build()
228+ .await
229+ .map_err(|error| account_error("starting OAuth login", error))?;
230+ Ok(PendingOAuthLogin {
231+ client,
232+ authorization_url: authorization.url.to_string(),
233+ state: Some(authorization.state),
234+ })
235+ }
236+
237+ /// Restore a saved record with the same store location and passphrase.
238+ /// The caller must also handle refresh, rejection, and explicit logout.
239+ pub async fn restore(record: SessionRecord, store: StoreConfig) -> Result<Self, AccountError> {
240+ let client = matrix_client_builder(&store)
241+ .homeserver_url(&record.homeserver)
242+ .build()
243+ .await
244+ .map_err(|error| account_error("reopening the Matrix store", error))?;
245+ let authentication = match record.authentication.clone() {
246+ AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),
247+ AuthenticationRecord::OAuth { client_id, user } => {
248+ OAuthSession { client_id, user }.into()
249+ }
250+ };
251+ client
252+ .restore_session(authentication)
253+ .await
254+ .map_err(|error| account_error("restoring the authenticated session", error))?;
255+ Ok(Self { client, record })
256+ }
257+
258+ /// Initial login credentials only. This snapshot does not track later
259+ /// token rotations; use `ClientHandle::session_record` after building.
260+ #[must_use]
261+ pub fn record(&self) -> SessionRecord {
262+ self.record.clone()
263+ }
264+
265+ pub async fn build(
266+ self,
267+ config: EngineConfig,
268+ ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {
269+ Engine::from_client(self.client, config)
270+ .await
271+ .map_err(AccountError::from)
272+ }
273+
274+ fn from_client(client: Client) -> Result<Self, AccountError> {
275+ let record = session_record(&client)?;
276+ Ok(Self { client, record })
277+ }
278+}
279+
280+pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {
281+ let authentication = match client.session().ok_or(AccountError::MissingSession)? {
282+ AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),
283+ AuthSession::OAuth(session) => {
284+ let OAuthSession { client_id, user } = *session;
285+ AuthenticationRecord::OAuth { client_id, user }
286+ }
287+ _ => return Err(AccountError::UnsupportedAuthentication),
288+ };
289+ Ok(SessionRecord {
290+ homeserver: client.homeserver().to_string(),
291+ authentication,
292+ })
293+}
294+
295+fn encryption_settings() -> EncryptionSettings {
296+ EncryptionSettings {
297+ auto_enable_cross_signing: true,
298+ backup_download_strategy: BackupDownloadStrategy::OneShot,
299+ auto_enable_backups: true,
300+ }
301+}
302+
303+fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {
304+ Client::builder()
305+ .with_encryption_settings(encryption_settings())
306+ .with_threading_support(ThreadingSupport::Enabled {
307+ with_subscriptions: false,
308+ })
309+ .handle_refresh_tokens()
310+ .sqlite_store(&store.path, Some(&store.passphrase))
311+}
312+
313+fn account_error(
314+ context: &'static str,
315+ source: impl std::error::Error + Send + Sync + 'static,
316+) -> AccountError {
317+ AccountError::Operation {
318+ context,
319+ source: Box::new(source),
320+ }
321+}
Added file: src/encryption.rs+863 additions
@@ -0,0 +1,863 @@
1+use std::sync::Arc;
2+
3+use futures_util::StreamExt;
4+use matrix_sdk::{
5+ Client,
6+ encryption::{
7+ VerificationState,
8+ backups::BackupState,
9+ recovery::RecoveryState as SdkRecoveryState,
10+ verification::{
11+ SasState, SasVerification, Verification, VerificationRequest, VerificationRequestState,
12+ },
13+ },
14+ ruma::events::key::verification::VerificationMethod,
15+};
16+use ruma::{OwnedDeviceId, UserId};
17+use tokio::{sync::Mutex, task::JoinHandle};
18+
19+use crate::{
20+ CommandErrorKind, EncryptionState, Event, KeyBackupState, RecoveryState, VerificationAction,
21+ VerificationState as UiState, VerificationTarget, error::CommandError,
22+ model::VerificationFailure, transport::EventSender,
23+};
24+
25+pub(crate) fn encryption_state(
26+ device_id: OwnedDeviceId,
27+ recovery: SdkRecoveryState,
28+ verification: VerificationState,
29+ backup: BackupState,
30+) -> EncryptionState {
31+ EncryptionState {
32+ device_id,
33+ device_verified: matches!(verification, VerificationState::Verified),
34+ recovery: match recovery {
35+ SdkRecoveryState::Unknown => RecoveryState::Unknown,
36+ SdkRecoveryState::Disabled => RecoveryState::Disabled,
37+ SdkRecoveryState::Incomplete => RecoveryState::Incomplete,
38+ SdkRecoveryState::Enabled => RecoveryState::Enabled,
39+ },
40+ key_backup: match backup {
41+ BackupState::Unknown => KeyBackupState::Unknown,
42+ BackupState::Creating => KeyBackupState::Creating,
43+ BackupState::Enabling => KeyBackupState::Enabling,
44+ BackupState::Resuming => KeyBackupState::Resuming,
45+ BackupState::Enabled => KeyBackupState::Enabled,
46+ BackupState::Downloading => KeyBackupState::Downloading,
47+ BackupState::Disabling => KeyBackupState::Disabling,
48+ },
49+ }
50+}
51+
52+#[derive(Default)]
53+pub(crate) struct VerificationController {
54+ active: Arc<Mutex<VerificationSession>>,
55+ task: Option<JoinHandle<()>>,
56+}
57+
58+#[derive(Default)]
59+struct VerificationSession {
60+ request: Option<VerificationRequest>,
61+ sas: Option<SasVerification>,
62+}
63+
64+impl VerificationController {
65+ pub(crate) async fn apply(
66+ &mut self,
67+ _client: &Client,
68+ sender: EventSender,
69+ action: VerificationAction,
70+ ) -> Result<(), CommandError> {
71+ match action {
72+ VerificationAction::Confirm => {
73+ // Retain a handle across the operation: the watcher clears
74+ // `active` when it publishes a terminal event, but this clone
75+ // still observes the shared SDK state afterwards.
76+ let sas = self.active.lock().await.sas.clone();
77+ match self.confirm().await {
78+ Ok(()) => Ok(()),
79+ Err(error) if error.kind == CommandErrorKind::VerificationInactive => {
80+ Err(error)
81+ }
82+ // The confirm advanced local SDK state before its send failed,
83+ // so the retained session could report success on retry without
84+ // network traffic. Best-effort cancel (no lock held across the
85+ // network), clear for restart, then report the terminal state.
86+ Err(error) => {
87+ // Stop the watcher first so it cannot emit a stale
88+ // Confirmed from the local transition we are unwinding.
89+ self.stop_task().await;
90+ // The peer can complete or cancel the verification
91+ // while our own Done send fails. The stopped watcher may
92+ // already have published that terminal outcome (losing the
93+ // claim below); otherwise this path owns it and must emit
94+ // it instead of regressing to Cancelled.
95+ if sas.as_ref().is_some_and(|sas| sas.is_done()) {
96+ if take_verification(&self.active).await {
97+ let _ =
98+ sender.send(Event::VerificationChanged(UiState::Done)).await;
99+ }
100+ return Err(error);
101+ }
102+ if let Some(reason) = sas
103+ .as_ref()
104+ .and_then(|sas| sas.cancel_info())
105+ .map(|info| info.reason().to_owned())
106+ {
107+ if take_verification(&self.active).await {
108+ let _ = sender
109+ .send(Event::VerificationChanged(UiState::Cancelled(reason)))
110+ .await;
111+ }
112+ return Err(error);
113+ }
114+ if let Some(sas) = sas {
115+ if sas.cancel().await.is_err() {
116+ // The best-effort unwind already advanced
117+ // local state; clearing here keeps the failed
118+ // cancellation from looking retryable. The
119+ // send is owned only if the watcher has not
120+ // already claimed the session for its own
121+ // terminal outcome.
122+ if take_verification(&self.active).await {
123+ let _ = sender
124+ .send(Event::VerificationChanged(UiState::Cancelled(
125+ error.to_string(),
126+ )))
127+ .await;
128+ }
129+ return Err(error);
130+ }
131+ // The peer can complete while the unwind cancel is in
132+ // flight and the SDK call no-ops; the Done then owns the
133+ // terminal outcome, so report it instead of attributing
134+ // it to the send failure.
135+ if sas.is_done() {
136+ if take_verification(&self.active).await {
137+ let _ = sender
138+ .send(Event::VerificationChanged(UiState::Done))
139+ .await;
140+ }
141+ return Err(error);
142+ }
143+ if let Some(info) = sas.cancel_info() {
144+ // Our own unwind cancel just marked the flow; it does
145+ // not own the outcome yet, so fall through and report
146+ // the send failure as Cancelled below. Only a peer
147+ // cancellation racing the unwind owns it instead.
148+ if !info.cancelled_by_us() {
149+ if take_verification(&self.active).await {
150+ let _ = sender
151+ .send(Event::VerificationChanged(UiState::Cancelled(
152+ info.reason().to_owned(),
153+ )))
154+ .await;
155+ }
156+ return Err(CommandError::new(
157+ CommandErrorKind::VerificationInactive,
158+ info.reason().to_owned(),
159+ ));
160+ }
161+ }
162+ }
163+ if take_verification(&self.active).await {
164+ let _ = sender
165+ .send(Event::VerificationChanged(UiState::Cancelled(
166+ error.to_string(),
167+ )))
168+ .await;
169+ }
170+ Err(error)
171+ }
172+ }
173+ }
174+ VerificationAction::Reject => self.reject().await,
175+ VerificationAction::Cancel => {
176+ // The SDK no-ops a cancel of an already-settled flow, so a
177+ // failed claim inside `cancel` already reported the true
178+ // outcome as an error and owns no event here. Only the claim
179+ // winner emits the local Cancelled.
180+ let claimed = self.cancel_claimed(Some(&sender)).await?;
181+ if claimed {
182+ sender
183+ .send(Event::VerificationChanged(UiState::Cancelled(
184+ "cancelled by this client".to_owned(),
185+ )))
186+ .await
187+ .map_err(|source| {
188+ CommandError::with_source(
189+ CommandErrorKind::EventReceiverClosed,
190+ "event receiver has closed",
191+ source,
192+ )
193+ })?;
194+ }
195+ Ok(())
196+ }
197+ }
198+ }
199+
200+ pub(crate) async fn start(
201+ &mut self,
202+ client: &Client,
203+ sender: EventSender,
204+ target: VerificationTarget,
205+ ) -> Result<(), CommandError> {
206+ client
207+ .encryption()
208+ .wait_for_e2ee_initialization_tasks()
209+ .await;
210+ let own_user_id = client.user_id().ok_or_else(|| {
211+ CommandError::new(
212+ CommandErrorKind::Unauthenticated,
213+ "the Matrix client is not authenticated",
214+ )
215+ })?;
216+ if self.has_active_verification().await {
217+ self.cancel().await?;
218+ } else {
219+ self.stop_task().await;
220+ }
221+ let request = match &target {
222+ VerificationTarget::OwnIdentity => {
223+ request_identity_verification(client, own_user_id).await?
224+ }
225+ VerificationTarget::User(user_id) => {
226+ request_identity_verification(client, user_id).await?
227+ }
228+ VerificationTarget::Device { user_id, device_id } => {
229+ let device = client
230+ .encryption()
231+ .get_device(user_id, device_id)
232+ .await
233+ .map_err(|source| {
234+ CommandError::with_source(
235+ CommandErrorKind::Encryption,
236+ "reading the verification device failed",
237+ source,
238+ )
239+ })?
240+ .ok_or_else(|| {
241+ CommandError::new(
242+ CommandErrorKind::VerificationUnsupported,
243+ "the verification device is unavailable",
244+ )
245+ })?;
246+ device
247+ .request_verification_with_methods(vec![VerificationMethod::SasV1])
248+ .await
249+ .map_err(|source| {
250+ CommandError::from_sdk("starting device verification failed", source)
251+ })?
252+ }
253+ };
254+ self.active.lock().await.request = Some(request.clone());
255+ sender
256+ .send(Event::VerificationTargetChanged(target))
257+ .await
258+ .map_err(|source| {
259+ CommandError::with_source(
260+ CommandErrorKind::EventReceiverClosed,
261+ "event receiver has closed",
262+ source,
263+ )
264+ })?;
265+ sender
266+ .send(Event::VerificationChanged(UiState::Waiting))
267+ .await
268+ .map_err(|source| {
269+ CommandError::with_source(
270+ CommandErrorKind::EventReceiverClosed,
271+ "event receiver has closed",
272+ source,
273+ )
274+ })?;
275+ let active = self.active.clone();
276+ self.task = Some(tokio::spawn(async move {
277+ follow_request(request, active, sender).await;
278+ }));
279+ Ok(())
280+ }
281+
282+ async fn confirm(&self) -> Result<(), CommandError> {
283+ let sas = self.active.lock().await.sas.clone().ok_or_else(|| {
284+ CommandError::new(
285+ CommandErrorKind::VerificationInactive,
286+ "verification has not reached comparison",
287+ )
288+ })?;
289+ // Report a settled flow truthfully instead of as "not reached
290+ // comparison": the SDK would no-op the confirm with success.
291+ if sas.is_done() {
292+ return Err(CommandError::new(
293+ CommandErrorKind::VerificationInactive,
294+ "verification is already complete",
295+ ));
296+ }
297+ if let Some(info) = sas.cancel_info() {
298+ return Err(CommandError::new(
299+ CommandErrorKind::VerificationInactive,
300+ info.reason().to_owned(),
301+ ));
302+ }
303+ // Only the short-auth-string stage is confirmable. The SDK reports the
304+ // remote-MAC-arrived state through the same presentable flag, while an
305+ // early confirm would locally no-op and falsely report success.
306+ if !sas.can_be_presented() {
307+ return Err(CommandError::new(
308+ CommandErrorKind::VerificationInactive,
309+ "verification has not reached comparison",
310+ ));
311+ }
312+ // Our own confirm can move the flow to Done, so only Done that predates
313+ // the send means the SDK no-op'd on a stale session; a cancellation
314+ // racing the send must not report success.
315+ let was_done = sas.is_done();
316+ sas.confirm()
317+ .await
318+ .map_err(|source| CommandError::from_sdk("confirming verification failed", source))?;
319+ if sas.is_done() {
320+ if was_done {
321+ return Err(CommandError::new(
322+ CommandErrorKind::VerificationInactive,
323+ "verification is already complete",
324+ ));
325+ }
326+ } else if sas.is_cancelled() {
327+ let reason = sas
328+ .cancel_info()
329+ .map(|info| info.reason().to_owned())
330+ .unwrap_or_else(|| "verification was cancelled".to_owned());
331+ return Err(CommandError::new(
332+ CommandErrorKind::VerificationInactive,
333+ reason,
334+ ));
335+ }
336+ Ok(())
337+ }
338+
339+ async fn reject(&self) -> Result<(), CommandError> {
340+ let sas = self.active.lock().await.sas.clone().ok_or_else(|| {
341+ CommandError::new(
342+ CommandErrorKind::VerificationInactive,
343+ "verification has not reached comparison",
344+ )
345+ })?;
346+ // Report a settled flow truthfully instead of as "not reached
347+ // comparison": the SDK would no-op the mismatch with success.
348+ if sas.is_done() {
349+ return Err(CommandError::new(
350+ CommandErrorKind::VerificationInactive,
351+ "verification is already complete",
352+ ));
353+ }
354+ if let Some(info) = sas.cancel_info() {
355+ return Err(CommandError::new(
356+ CommandErrorKind::VerificationInactive,
357+ info.reason().to_owned(),
358+ ));
359+ }
360+ // Rejecting before the comparison stage would cancel a flow the user
361+ // never compared, so it requires the same presentable state.
362+ if !sas.can_be_presented() {
363+ return Err(CommandError::new(
364+ CommandErrorKind::VerificationInactive,
365+ "verification has not reached comparison",
366+ ));
367+ }
368+ // A peer completing the flow concurrently must not be reported as
369+ // rejected. A remote cancellation racing the send turns the SDK call
370+ // into a no-op success, so report it instead of a false success.
371+ if sas.is_done() {
372+ return Err(CommandError::new(
373+ CommandErrorKind::VerificationInactive,
374+ "verification is already complete",
375+ ));
376+ }
377+ if let Some(info) = sas.cancel_info() {
378+ // Our own mismatch just marked the flow cancelled-by-us; that is the
379+ // expected reject path and stays Ok. Only a peer cancel reports an
380+ // error.
381+ if !info.cancelled_by_us() {
382+ return Err(CommandError::new(
383+ CommandErrorKind::VerificationInactive,
384+ info.reason().to_owned(),
385+ ));
386+ }
387+ }
388+ Ok(())
389+ }
390+ pub(crate) async fn cancel(&mut self) -> Result<(), CommandError> {
391+ self.cancel_claimed(None).await.map(|_| ())
392+ }
393+
394+ /// Return whether this action won the claim for a local cancellation.
395+ /// The optional sender reports a peer terminal outcome when it races after
396+ /// the claim; starting a replacement verification does not publish it.
397+ async fn cancel_claimed(&mut self, sender: Option<&EventSender>) -> Result<bool, CommandError> {
398+ let (sas, request) = {
399+ let active = self.active.lock().await;
400+ (active.sas.clone(), active.request.clone())
401+ };
402+ if sas.is_none() && request.is_none() {
403+ return Err(CommandError::new(
404+ CommandErrorKind::VerificationInactive,
405+ "no verification is active",
406+ ));
407+ }
408+ // The watcher owns terminal emission and claims the session when it
409+ // publishes. If the SDK is already terminal, the watcher either already
410+ // reported it or is about to: leave session and watcher alone so the
411+ // outcome is emitted exactly once, and report it instead of letting the
412+ // SDK no-op success regress Done to "cancelled by this client".
413+ if let Some(sas) = sas.as_ref() {
414+ if sas.is_done() {
415+ return Err(CommandError::new(
416+ CommandErrorKind::VerificationInactive,
417+ "verification is already complete",
418+ ));
419+ }
420+ if let Some(info) = sas.cancel_info() {
421+ return Err(CommandError::new(
422+ CommandErrorKind::VerificationInactive,
423+ info.reason().to_owned(),
424+ ));
425+ }
426+ } else if let Some(request) = request.as_ref() {
427+ if request.is_done() {
428+ return Err(CommandError::new(
429+ CommandErrorKind::VerificationInactive,
430+ "verification is already complete",
431+ ));
432+ }
433+ if let Some(info) = request.cancel_info() {
434+ return Err(CommandError::new(
435+ CommandErrorKind::VerificationInactive,
436+ info.reason().to_owned(),
437+ ));
438+ }
439+ }
440+ // Own the terminal outcome from here: claim the session before
441+ // stopping the watcher so a watcher that already claimed (and will
442+ // emit its own terminal event) turns this into a truthful error
443+ // instead of a duplicate local cancel; no lock is held across sends.
444+ if !take_verification(&self.active).await {
445+ // Re-check the SDK: the claim loss may be a stale session after a
446+ // terminal outcome, or a concurrent start of a new flow. Only the
447+ // terminal case reports here; a live session belongs to the new
448+ // flow and must not be disturbed.
449+ if let Some(sas) = sas.as_ref() {
450+ if sas.is_done() {
451+ return Err(CommandError::new(
452+ CommandErrorKind::VerificationInactive,
453+ "verification is already complete",
454+ ));
455+ }
456+ if let Some(info) = sas.cancel_info() {
457+ return Err(CommandError::new(
458+ CommandErrorKind::VerificationInactive,
459+ info.reason().to_owned(),
460+ ));
461+ }
462+ } else if let Some(request) = request.as_ref() {
463+ if request.is_done() {
464+ return Err(CommandError::new(
465+ CommandErrorKind::VerificationInactive,
466+ "verification is already complete",
467+ ));
468+ }
469+ if let Some(info) = request.cancel_info() {
470+ return Err(CommandError::new(
471+ CommandErrorKind::VerificationInactive,
472+ info.reason().to_owned(),
473+ ));
474+ }
475+ }
476+ return Err(CommandError::new(
477+ CommandErrorKind::VerificationInactive,
478+ "no verification is active",
479+ ));
480+ }
481+ self.stop_task().await;
482+ if let Some(sas) = sas {
483+ let result = sas.cancel().await;
484+ if sas.is_done() {
485+ report_claimed_terminal(sender, UiState::Done).await;
486+ return Err(CommandError::new(
487+ CommandErrorKind::VerificationInactive,
488+ "verification is already complete",
489+ ));
490+ }
491+ if let Some(info) = sas.cancel_info()
492+ && !info.cancelled_by_us()
493+ {
494+ let reason = info.reason().to_owned();
495+ report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;
496+ return Err(CommandError::new(
497+ CommandErrorKind::VerificationInactive,
498+ reason,
499+ ));
500+ }
501+ if let Err(source) = result {
502+ let error = CommandError::from_sdk("cancelling verification failed", source);
503+ report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;
504+ return Err(error);
505+ }
506+ Ok(true)
507+ } else if let Some(request) = request {
508+ let result = request.cancel().await;
509+ if request.is_done() {
510+ report_claimed_terminal(sender, UiState::Done).await;
511+ return Err(CommandError::new(
512+ CommandErrorKind::VerificationInactive,
513+ "verification is already complete",
514+ ));
515+ }
516+ if let Some(info) = request.cancel_info()
517+ && !info.cancelled_by_us()
518+ {
519+ let reason = info.reason().to_owned();
520+ report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;
521+ return Err(CommandError::new(
522+ CommandErrorKind::VerificationInactive,
523+ reason,
524+ ));
525+ }
526+ if let Err(source) = result {
527+ let error = CommandError::from_sdk("cancelling verification failed", source);
528+ report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;
529+ return Err(error);
530+ }
531+ Ok(true)
532+ } else {
533+ Ok(false)
534+ }
535+ }
536+
537+ async fn has_active_verification(&self) -> bool {
538+ let active = self.active.lock().await;
539+ active.request.is_some() || active.sas.is_some()
540+ }
541+
542+ async fn stop_task(&mut self) {
543+ if let Some(task) = self.task.take() {
544+ task.abort();
545+ let _ = task.await;
546+ }
547+ }
548+
549+ pub(crate) async fn stop(&mut self) {
550+ // Shutdown path: terminate the watcher without touching the SDK so a
551+ // shutdown under lock cannot hang on network sends. No terminal event
552+ // is emitted; the session is simply forgotten locally.
553+ self.stop_task().await;
554+ clear_verification(&self.active).await;
555+ }
556+}
557+
558+async fn request_identity_verification(
559+ client: &Client,
560+ user_id: &UserId,
561+) -> Result<VerificationRequest, CommandError> {
562+ let identity = client
563+ .encryption()
564+ .request_user_identity(user_id)
565+ .await
566+ .map_err(|source| CommandError::from_sdk("requesting the user identity failed", source))?
567+ .ok_or_else(|| {
568+ CommandError::new(
569+ CommandErrorKind::NoCrossSigningIdentity,
570+ "the user has no cross-signing identity",
571+ )
572+ })?;
573+ identity
574+ .request_verification_with_methods(vec![VerificationMethod::SasV1])
575+ .await
576+ .map_err(|source| {
577+ CommandError::from_verification_request("starting verification failed", source)
578+ })
579+}
580+
581+async fn follow_request(
582+ request: VerificationRequest,
583+ active: Arc<Mutex<VerificationSession>>,
584+ sender: EventSender,
585+) {
586+ let mut changes = request.changes();
587+ while let Some(state) = changes.next().await {
588+ match state {
589+ VerificationRequestState::Created { .. } => {}
590+ VerificationRequestState::Requested { .. } => {
591+ if let Err(error) = request
592+ .accept_with_methods(vec![VerificationMethod::SasV1])
593+ .await
594+ {
595+ tracing::warn!(error = %error, "accepting verification request failed");
596+ // Claim the session so this terminal outcome is emitted exactly
597+ // once; a lost claim means an action path already owns it.
598+ report_watcher_terminal(
599+ &active,
600+ &sender,
601+ UiState::Failed(VerificationFailure::RequestAcceptance(error.to_string())),
602+ "request acceptance failure",
603+ )
604+ .await;
605+ return;
606+ }
607+ }
608+ VerificationRequestState::Ready { .. } => match request.start_sas().await {
609+ Ok(Some(sas)) => {
610+ follow_sas(sas, active.clone(), sender.clone()).await;
611+ return;
612+ }
613+ Ok(None) => {}
614+ Err(error) => {
615+ tracing::warn!(error = %error, "starting SAS verification failed");
616+ // Claim the session so this terminal outcome is emitted exactly
617+ // once; a lost claim means an action path already owns it.
618+ report_watcher_terminal(
619+ &active,
620+ &sender,
621+ UiState::Failed(VerificationFailure::SasStartup(error.to_string())),
622+ "SAS startup failure",
623+ )
624+ .await;
625+ return;
626+ }
627+ },
628+ VerificationRequestState::Transitioned { verification } => {
629+ if let Verification::SasV1(sas) = verification {
630+ follow_sas(sas, active.clone(), sender.clone()).await;
631+ } else {
632+ report_watcher_terminal(
633+ &active,
634+ &sender,
635+ UiState::Cancelled(
636+ "the other device selected an unsupported method".into(),
637+ ),
638+ "unsupported verification method",
639+ )
640+ .await;
641+ }
642+ return;
643+ }
644+ VerificationRequestState::Done => {
645+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;
646+ return;
647+ }
648+ VerificationRequestState::Cancelled(info) => {
649+ report_watcher_terminal(
650+ &active,
651+ &sender,
652+ UiState::Cancelled(info.reason().to_owned()),
653+ "cancellation",
654+ )
655+ .await;
656+ return;
657+ }
658+ }
659+ }
660+ tracing::debug!("verification request watcher ended unexpectedly");
661+ // The stream may end after the SDK already settled without delivering the
662+ // terminal update; report the settled outcome instead of StreamEnded, and
663+ // stay silent if an action path already claimed the session.
664+ if request.is_done() {
665+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;
666+ } else if let Some(info) = request.cancel_info() {
667+ let reason = info.reason().to_owned();
668+ report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;
669+ } else {
670+ report_watcher_terminal(
671+ &active,
672+ &sender,
673+ UiState::Failed(VerificationFailure::RequestStreamEnded),
674+ "request stream termination",
675+ )
676+ .await;
677+ }
678+}
679+
680+async fn follow_sas(
681+ sas: SasVerification,
682+ active: Arc<Mutex<VerificationSession>>,
683+ sender: EventSender,
684+) {
685+ active.lock().await.sas = Some(sas.clone());
686+ let mut changes = sas.changes();
687+ while let Some(state) = changes.next().await {
688+ let update = match state {
689+ SasState::Created { .. } | SasState::Accepted { .. } => continue,
690+ SasState::Started { .. } => {
691+ if let Err(error) = sas.accept().await {
692+ tracing::warn!(error = %error, "accepting SAS verification failed");
693+ // Claim the session so this terminal outcome is emitted exactly
694+ // once; a lost claim means an action path already owns it.
695+ report_watcher_terminal(
696+ &active,
697+ &sender,
698+ UiState::Failed(VerificationFailure::SasAcceptance(error.to_string())),
699+ "SAS acceptance failure",
700+ )
701+ .await;
702+ return;
703+ }
704+ continue;
705+ }
706+ SasState::KeysExchanged { emojis, decimals } => UiState::Comparison {
707+ emojis: emojis
708+ .map(|short_auth| {
709+ short_auth
710+ .emojis
711+ .into_iter()
712+ .map(|emoji| (emoji.symbol.to_owned(), emoji.description.to_owned()))
713+ .collect()
714+ })
715+ .unwrap_or_default(),
716+ decimals,
717+ },
718+ SasState::Confirmed => UiState::Confirmed,
719+ SasState::Done { .. } => UiState::Done,
720+ SasState::Cancelled(info) => UiState::Cancelled(info.reason().to_owned()),
721+ };
722+ let terminal = matches!(update, UiState::Done | UiState::Cancelled(_));
723+ // Claim the session before the terminal send so a racing Cancel observes
724+ // no active session and the SDK no-op cannot regress Done back to
725+ // "cancelled by this client". A lost claim means an action path already
726+ // owns the terminal outcome, so stay silent instead of duplicating it.
727+ if terminal && !take_verification(&active).await {
728+ return;
729+ }
730+ if sender
731+ .send(Event::VerificationChanged(update))
732+ .await
733+ .is_err()
734+ {
735+ tracing::debug!("SAS watcher stopped because the event receiver closed");
736+ return;
737+ }
738+ if terminal {
739+ return;
740+ }
741+ }
742+ tracing::debug!("SAS watcher ended unexpectedly");
743+ // The stream may end after the SDK already settled without delivering the
744+ // terminal update; report the settled outcome instead of StreamEnded, and
745+ // stay silent if an action path already claimed the session.
746+ if sas.is_done() {
747+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;
748+ } else if let Some(info) = sas.cancel_info() {
749+ let reason = info.reason().to_owned();
750+ report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;
751+ } else {
752+ report_watcher_terminal(
753+ &active,
754+ &sender,
755+ UiState::Failed(VerificationFailure::SasStreamEnded),
756+ "SAS stream termination",
757+ )
758+ .await;
759+ }
760+}
761+
762+async fn report_watcher_terminal(
763+ active: &Arc<Mutex<VerificationSession>>,
764+ sender: &EventSender,
765+ state: UiState,
766+ context: &'static str,
767+) {
768+ if take_verification(active).await
769+ && sender
770+ .send(Event::VerificationChanged(state))
771+ .await
772+ .is_err()
773+ {
774+ tracing::debug!(
775+ context,
776+ "verification watcher could not report terminal outcome"
777+ );
778+ }
779+}
780+
781+async fn report_claimed_terminal(sender: Option<&EventSender>, state: UiState) {
782+ if let Some(sender) = sender {
783+ let _ = sender.send(Event::VerificationChanged(state)).await;
784+ }
785+}
786+
787+async fn clear_verification(active: &Arc<Mutex<VerificationSession>>) {
788+ let mut active = active.lock().await;
789+ active.sas.take();
790+ active.request.take();
791+}
792+
793+/// Claim the active session for terminal emission, if one is still present.
794+///
795+/// Exactly one party (watcher or action path) wins the claim and may emit the
796+/// terminal event; the loser stays silent so Done is never overwritten by a
797+/// later local Cancelled/Failed.
798+async fn take_verification(active: &Arc<Mutex<VerificationSession>>) -> bool {
799+ let mut active = active.lock().await;
800+ let had = active.sas.is_some() || active.request.is_some();
801+ active.sas.take();
802+ active.request.take();
803+ had
804+}
805+
806+#[cfg(test)]
807+mod tests {
808+ use matrix_sdk::encryption::VerificationState;
809+ use matrix_sdk::encryption::backups::BackupState;
810+ use ruma::owned_device_id;
811+
812+ use super::*;
813+
814+ #[test]
815+ fn sdk_security_state_is_normalized() {
816+ assert_eq!(
817+ encryption_state(
818+ owned_device_id!("DEVICE"),
819+ SdkRecoveryState::Incomplete,
820+ VerificationState::Unverified,
821+ BackupState::Unknown,
822+ ),
823+ EncryptionState {
824+ device_id: owned_device_id!("DEVICE"),
825+ device_verified: false,
826+ recovery: RecoveryState::Incomplete,
827+ key_backup: KeyBackupState::Unknown,
828+ }
829+ );
830+ assert_eq!(
831+ encryption_state(
832+ owned_device_id!("DEVICE"),
833+ SdkRecoveryState::Enabled,
834+ VerificationState::Verified,
835+ BackupState::Enabled,
836+ ),
837+ EncryptionState {
838+ device_id: owned_device_id!("DEVICE"),
839+ device_verified: true,
840+ recovery: RecoveryState::Enabled,
841+ key_backup: KeyBackupState::Enabled,
842+ }
843+ );
844+ }
845+
846+ #[tokio::test]
847+ async fn comparison_actions_require_an_active_sas() {
848+ let mut controller = VerificationController::default();
849+
850+ assert_eq!(
851+ controller.confirm().await.unwrap_err().to_string(),
852+ "verification has not reached comparison"
853+ );
854+ assert_eq!(
855+ controller.reject().await.unwrap_err().to_string(),
856+ "verification has not reached comparison"
857+ );
858+ assert_eq!(
859+ controller.cancel().await.unwrap_err().to_string(),
860+ "no verification is active"
861+ );
862+ }
863+}
Added file: src/engine.rs+4175 additions
@@ -0,0 +1,4175 @@
1+use std::{
2+ borrow::Cow,
3+ collections::{HashMap, HashSet, VecDeque},
4+ future::Future,
5+ io::Read as _,
6+ panic::AssertUnwindSafe,
7+ sync::{
8+ Arc,
9+ atomic::{AtomicBool, Ordering},
10+ },
11+};
12+
13+use futures_util::{FutureExt, StreamExt};
14+use matrix_sdk::{
15+ Client, Room, SessionChange,
16+ media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings},
17+ room::edit::EditedContent,
18+ room::reply::{EnforceThread, Reply as SdkReply},
19+ ruma::{
20+ OwnedDeviceId, OwnedEventId, OwnedRoomId, RoomId,
21+ api::client::receipt::create_receipt::v3::ReceiptType,
22+ events::{
23+ AnyGlobalAccountDataEventContent, AnyMessageLikeEventContent,
24+ GlobalAccountDataEventType, Mentions, RoomAccountDataEventType,
25+ receipt::ReceiptThread,
26+ room::message::{
27+ AddMentions, MessageType, Relation, ReplyWithinThread,
28+ RoomMessageEventContentWithoutRelation,
29+ },
30+ },
31+ serde::Raw,
32+ },
33+ send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendHandle},
34+};
35+use matrix_sdk_ui::{
36+ sync_service::{State as SyncState, SyncService},
37+ timeline::{
38+ RoomExt, Timeline, TimelineEventItemId, TimelineFocus, TimelineReadReceiptTracking,
39+ },
40+};
41+use thiserror::Error;
42+use tokio::{
43+ sync::{Mutex, mpsc, oneshot, watch},
44+ task::{AbortHandle, Id as TaskId, JoinHandle, JoinSet},
45+};
46+
47+use crate::{
48+ AccountCommand, AttachmentUpload, ClientHandle, Command, CommandErrorKind, CommandFailure,
49+ CommandId, CommandKind, ConnectionState, CreateRoomOptions, EncryptionCommand, EngineErrorKind,
50+ EngineFailure, Event, EventStream, HistoryState, InteractiveAuth, InteractiveAuthChallenge,
51+ InteractiveAuthOutcome, LifecycleCommand, MediaCommand, MediaId, MediaSize, MessageContent,
52+ MessageEditContent, MessageMentions, NotificationCommand, PowerLevelChanges, RichText,
53+ RoomCommand, RoomNotificationMode, RoomPreset, RoomVisibility, TimelineCommand, TimelineId,
54+ TypingState,
55+ encryption::{VerificationController, encryption_state},
56+ error::{CommandError, classify_sdk_error},
57+ normalise::{MediaRegistration, MediaScope, normalise_timeline, normalise_timeline_diffs},
58+ transport::{
59+ ClientQueryError, CommandEnvelope, CommandResultError, DriverPort, DriverRequest,
60+ EventSender, SendEventError, transport_pair,
61+ },
62+ upload::prepare_upload,
63+};
64+use matrix_sdk::ruma::api::{Metadata, OutgoingRequest, auth_scheme::SendAccessToken};
65+use matrix_sdk_crypto::{AttachmentDecryptor, MediaEncryptionInfo};
66+
67+#[derive(Clone, Debug, Eq, PartialEq)]
68+#[non_exhaustive]
69+pub struct EngineConfig {
70+ pub command_capacity: usize,
71+ pub event_capacity: usize,
72+ pub initial_history_limit: u16,
73+ pub max_media_bytes: usize,
74+ pub max_media_sources: usize,
75+}
76+
77+impl EngineConfig {
78+ #[must_use]
79+ pub const fn with_command_capacity(mut self, capacity: usize) -> Self {
80+ self.command_capacity = capacity;
81+ self
82+ }
83+
84+ #[must_use]
85+ pub const fn with_event_capacity(mut self, capacity: usize) -> Self {
86+ self.event_capacity = capacity;
87+ self
88+ }
89+
90+ #[must_use]
91+ pub const fn with_initial_history_limit(mut self, limit: u16) -> Self {
92+ self.initial_history_limit = limit;
93+ self
94+ }
95+
96+ #[must_use]
97+ pub const fn with_max_media_bytes(mut self, bytes: usize) -> Self {
98+ self.max_media_bytes = bytes;
99+ self
100+ }
101+
102+ #[must_use]
103+ pub const fn with_max_media_sources(mut self, sources: usize) -> Self {
104+ self.max_media_sources = sources;
105+ self
106+ }
107+}
108+
109+impl Default for EngineConfig {
110+ fn default() -> Self {
111+ Self {
112+ command_capacity: 64,
113+ event_capacity: 128,
114+ initial_history_limit: 30,
115+ max_media_bytes: 20 * 1024 * 1024,
116+ max_media_sources: 2_048,
117+ }
118+ }
119+}
120+
121+#[derive(Debug, Error)]
122+pub enum EngineError {
123+ #[error("the Matrix client is not authenticated")]
124+ Unauthenticated,
125+ #[error("command, event, and media source capacities must be nonzero")]
126+ ZeroCapacity,
127+ #[error("could not build the sync service")]
128+ SyncService(#[source] matrix_sdk_ui::sync_service::Error),
129+ #[error("the event receiver has closed")]
130+ EventReceiverClosed,
131+ #[error("engine task {task} panicked")]
132+ TaskPanicked { task: String },
133+}
134+
135+impl EngineError {
136+ fn failure(&self) -> EngineFailure {
137+ EngineFailure {
138+ kind: match self {
139+ Self::EventReceiverClosed => EngineErrorKind::EventReceiverClosed,
140+ Self::Unauthenticated
141+ | Self::ZeroCapacity
142+ | Self::SyncService(_)
143+ | Self::TaskPanicked { .. } => EngineErrorKind::Other,
144+ },
145+ message: self.to_string(),
146+ }
147+ }
148+}
149+
150+type UploadWatchers = HashMap<
151+ OwnedRoomId,
152+ (
153+ AbortHandle,
154+ watch::Receiver<Option<Result<(), CommandErrorKind>>>,
155+ ),
156+>;
157+type MediaFlights = Arc<Mutex<HashMap<(MediaId, MediaSize), Vec<CommandId>>>>;
158+
159+pub struct Engine {
160+ client: Client,
161+ sync: Arc<SyncService>,
162+ port: DriverPort,
163+ rooms: HashMap<OwnedRoomId, RoomActorState>,
164+ next_room_generation: u64,
165+ room_actor_events: mpsc::UnboundedReceiver<RoomActorEvent>,
166+ room_actor_event_sender: mpsc::UnboundedSender<RoomActorEvent>,
167+ upload_watchers: UploadWatchers,
168+ tasks: TaskRegistry,
169+ session_changes: Option<tokio::sync::broadcast::Receiver<SessionChange>>,
170+ initial_history_limit: u16,
171+ room_command_capacity: usize,
172+ max_media_bytes: usize,
173+ media: Arc<Mutex<MediaRegistry>>,
174+ notification_commands: mpsc::Sender<CommandJob<NotificationCommand>>,
175+ notification_command_receiver: Option<mpsc::Receiver<CommandJob<NotificationCommand>>>,
176+ encryption_commands: mpsc::Sender<EncryptionWork>,
177+ encryption_command_receiver: Option<mpsc::Receiver<EncryptionWork>>,
178+ verification: Arc<Mutex<VerificationController>>,
179+ room_commands: mpsc::Sender<RoomJob>,
180+ room_command_receiver: Option<mpsc::Receiver<RoomJob>>,
181+ global_account_gates: HashMap<String, oneshot::Receiver<()>>,
182+ room_account_gates: HashMap<(OwnedRoomId, String), oneshot::Receiver<()>>,
183+ media_flights: MediaFlights,
184+}
185+
186+struct CommandJob<T> {
187+ id: CommandId,
188+ command: T,
189+}
190+
191+enum EncryptionWork {
192+ Command(CommandJob<EncryptionCommand>),
193+}
194+
195+struct RoomJob {
196+ id: CommandId,
197+ command: RoomCommand,
198+ reply: Option<oneshot::Sender<Result<OwnedRoomId, CommandResultError>>>,
199+}
200+
201+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
202+enum RoomMutationKey {
203+ Room(OwnedRoomId),
204+ Target(String),
205+ Direct(ruma::OwnedUserId),
206+ Independent(CommandId),
207+}
208+
209+struct RoomJobCompletion {
210+ key: RoomMutationKey,
211+ id: CommandId,
212+ reply: Option<oneshot::Sender<Result<OwnedRoomId, CommandResultError>>>,
213+ result: Result<Option<OwnedRoomId>, CommandError>,
214+}
215+
216+#[derive(Clone)]
217+enum TaskContext {
218+ Command {
219+ id: CommandId,
220+ kind: CommandKind,
221+ },
222+ Watcher(&'static str),
223+ RoomActor {
224+ room_id: OwnedRoomId,
225+ generation: u64,
226+ },
227+}
228+
229+impl TaskContext {
230+ fn label(&self) -> String {
231+ match self {
232+ Self::Command { id, kind } => format!("{kind:?} command {}", id.get()),
233+ Self::Watcher(name) => (*name).to_owned(),
234+ Self::RoomActor {
235+ room_id,
236+ generation,
237+ } => format!("room actor {room_id} generation {generation}"),
238+ }
239+ }
240+}
241+
242+struct TaskRegistry {
243+ tasks: JoinSet<()>,
244+ contexts: HashMap<TaskId, TaskContext>,
245+}
246+
247+impl TaskRegistry {
248+ fn new() -> Self {
249+ Self {
250+ tasks: JoinSet::new(),
251+ contexts: HashMap::new(),
252+ }
253+ }
254+
255+ fn is_empty(&self) -> bool {
256+ self.tasks.is_empty()
257+ }
258+
259+ fn spawn(
260+ &mut self,
261+ context: TaskContext,
262+ future: impl Future<Output = ()> + Send + 'static,
263+ ) -> AbortHandle {
264+ let handle = self.tasks.spawn(future);
265+ self.contexts.insert(handle.id(), context);
266+ handle
267+ }
268+
269+ async fn join_next(&mut self) -> Option<(TaskContext, Result<(), tokio::task::JoinError>)> {
270+ let result = self.tasks.join_next_with_id().await?;
271+ let id = match &result {
272+ Ok((id, ())) => *id,
273+ Err(error) => error.id(),
274+ };
275+ let context = self
276+ .contexts
277+ .remove(&id)
278+ .expect("every registered task has context");
279+ Some((context, result.map(|(_, ())| ())))
280+ }
281+
282+ async fn shutdown(&mut self) {
283+ self.tasks.abort_all();
284+ while self.tasks.join_next().await.is_some() {}
285+ self.contexts.clear();
286+ }
287+}
288+
289+enum RoomActorState {
290+ Opening {
291+ generation: u64,
292+ sender: mpsc::Sender<RoomActorRequest>,
293+ },
294+ Open {
295+ generation: u64,
296+ sender: mpsc::Sender<RoomActorRequest>,
297+ },
298+ Closing {
299+ generation: u64,
300+ pending: VecDeque<DeferredRoomLifecycle>,
301+ },
302+}
303+
304+enum DeferredRoomLifecycle {
305+ Open(CommandId),
306+ Close(CommandId),
307+}
308+
309+impl RoomActorState {
310+ const fn generation(&self) -> u64 {
311+ match self {
312+ Self::Opening { generation, .. }
313+ | Self::Open { generation, .. }
314+ | Self::Closing { generation, .. } => *generation,
315+ }
316+ }
317+
318+ fn sender(&self) -> Option<&mpsc::Sender<RoomActorRequest>> {
319+ match self {
320+ Self::Opening { sender, .. } | Self::Open { sender, .. } => Some(sender),
321+ Self::Closing { .. } => None,
322+ }
323+ }
324+}
325+
326+enum RoomActorEvent {
327+ Ready {
328+ room_id: OwnedRoomId,
329+ generation: u64,
330+ },
331+ Exited {
332+ room_id: OwnedRoomId,
333+ generation: u64,
334+ },
335+}
336+
337+enum EngineActivity {
338+ Request(Option<Box<DriverRequest>>),
339+ Task(Option<(TaskContext, Result<(), tokio::task::JoinError>)>),
340+ RoomActor(Option<RoomActorEvent>),
341+}
342+
343+struct MediaRegistry {
344+ entries: HashMap<MediaId, MediaRegistration>,
345+ order: VecDeque<MediaId>,
346+ limit: usize,
347+}
348+
349+impl MediaRegistry {
350+ fn new(limit: usize) -> Self {
351+ Self {
352+ entries: HashMap::new(),
353+ order: VecDeque::new(),
354+ limit,
355+ }
356+ }
357+
358+ fn insert_all(&mut self, media: Vec<MediaRegistration>) {
359+ for media in media {
360+ if !self.entries.contains_key(&media.id) {
361+ self.order.push_back(media.id.clone());
362+ }
363+ self.entries.insert(media.id.clone(), media);
364+ }
365+ while self.entries.len() > self.limit {
366+ if let Some(id) = self.order.pop_front() {
367+ self.entries.remove(&id);
368+ }
369+ }
370+ }
371+}
372+
373+enum RoomActorRequest {
374+ Opened {
375+ id: CommandId,
376+ },
377+ Execute {
378+ id: CommandId,
379+ command: TimelineCommand,
380+ },
381+ Close {
382+ id: CommandId,
383+ },
384+}
385+
386+struct RoomActor {
387+ room_id: OwnedRoomId,
388+ generation: u64,
389+ room: Room,
390+ receiver: mpsc::Receiver<RoomActorRequest>,
391+ events: EventSender,
392+ engine_events: mpsc::UnboundedSender<RoomActorEvent>,
393+ media: Arc<Mutex<MediaRegistry>>,
394+ initial_history_limit: u16,
395+ open_ids: Vec<CommandId>,
396+}
397+
398+struct TimelineSession {
399+ id: TimelineId,
400+ timeline: Arc<Timeline>,
401+ events: EventSender,
402+ watcher: JoinHandle<()>,
403+ media: Arc<Mutex<MediaRegistry>>,
404+ current_user_id: Option<ruma::OwnedUserId>,
405+ scope: MediaScope,
406+ pagination_ids: Arc<Mutex<HashSet<CommandId>>>,
407+ pagination_sender: mpsc::Sender<PaginationRequest>,
408+ pagination_worker: JoinHandle<()>,
409+ pagination_results: Option<mpsc::UnboundedSender<(CommandId, Result<(), CommandError>)>>,
410+ pagination_result_task: JoinHandle<()>,
411+ reached_start: Arc<AtomicBool>,
412+}
413+
414+struct PaginationRequest {
415+ timeline: Arc<Timeline>,
416+ events: EventSender,
417+ timeline_id: TimelineId,
418+ reached_start: Arc<AtomicBool>,
419+ limit: u16,
420+ command_id: Option<CommandId>,
421+ results: mpsc::UnboundedSender<(CommandId, Result<(), CommandError>)>,
422+}
423+
424+impl TimelineSession {
425+ async fn start(
426+ id: TimelineId,
427+ timeline: Timeline,
428+ events: EventSender,
429+ media: Arc<Mutex<MediaRegistry>>,
430+ current_user_id: Option<ruma::OwnedUserId>,
431+ initial_history_limit: u16,
432+ ) -> Result<Self, SendEventError> {
433+ let timeline = Arc::new(timeline);
434+ let scope = MediaScope::new();
435+ let watcher = start_timeline_watcher(
436+ id.clone(),
437+ timeline.clone(),
438+ events.clone(),
439+ media.clone(),
440+ current_user_id.clone(),
441+ scope.clone(),
442+ )
443+ .await?;
444+
445+ let (pagination_sender, mut pagination_receiver) = mpsc::channel::<PaginationRequest>(32);
446+ let pagination_worker = tokio::spawn(async move {
447+ while let Some(request) = pagination_receiver.recv().await {
448+ let command_id = request.command_id;
449+ let results = request.results.clone();
450+ if AssertUnwindSafe(run_pagination(request))
451+ .catch_unwind()
452+ .await
453+ .is_err()
454+ {
455+ tracing::warn!(?command_id, "pagination task panicked");
456+ if let Some(id) = command_id {
457+ let _ = results.send((
458+ id,
459+ Err(CommandError::new(
460+ CommandErrorKind::Timeline,
461+ "pagination failed",
462+ )),
463+ ));
464+ }
465+ }
466+ }
467+ });
468+ let pagination_ids = Arc::new(Mutex::new(HashSet::new()));
469+ let (pagination_results, mut result_receiver) = mpsc::unbounded_channel();
470+ let result_events = events.clone();
471+ let result_ids = pagination_ids.clone();
472+ let pagination_result_task = tokio::spawn(async move {
473+ while let Some((id, result)) = result_receiver.recv().await {
474+ if result_ids.lock().await.remove(&id) {
475+ let _ = emit_command_result(&result_events, id, CommandKind::Timeline, result)
476+ .await;
477+ }
478+ }
479+ });
480+
481+ let mut session = Self {
482+ id,
483+ timeline,
484+ events,
485+ watcher,
486+ media,
487+ current_user_id,
488+ scope,
489+ pagination_sender,
490+ pagination_worker,
491+ pagination_ids,
492+ pagination_results: Some(pagination_results),
493+ pagination_result_task,
494+ reached_start: Arc::new(AtomicBool::new(false)),
495+ };
496+ if initial_history_limit > 0 {
497+ session.paginate(initial_history_limit, None).await;
498+ }
499+ Ok(session)
500+ }
501+
502+ async fn resync(&mut self) -> Result<(), SendEventError> {
503+ self.watcher.abort();
504+ let _ = (&mut self.watcher).await;
505+ self.watcher = start_timeline_watcher(
506+ self.id.clone(),
507+ self.timeline.clone(),
508+ self.events.clone(),
509+ self.media.clone(),
510+ self.current_user_id.clone(),
511+ self.scope.clone(),
512+ )
513+ .await?;
514+ Ok(())
515+ }
516+
517+ async fn paginate(&mut self, limit: u16, command_id: Option<CommandId>) {
518+ if let Some(id) = command_id {
519+ self.pagination_ids.lock().await.insert(id);
520+ }
521+ let request = PaginationRequest {
522+ timeline: self.timeline.clone(),
523+ events: self.events.clone(),
524+ timeline_id: self.id.clone(),
525+ reached_start: self.reached_start.clone(),
526+ limit,
527+ command_id,
528+ results: self
529+ .pagination_results
530+ .as_ref()
531+ .expect("pagination session is open")
532+ .clone(),
533+ };
534+ if let Err(error) = self.pagination_sender.try_send(request) {
535+ let kind = match error {
536+ mpsc::error::TrySendError::Full(_) => CommandErrorKind::QueueFull,
537+ mpsc::error::TrySendError::Closed(_) => CommandErrorKind::TimelineNotOpen,
538+ };
539+ if let Some(id) = command_id {
540+ self.pagination_ids.lock().await.remove(&id);
541+ let _ = emit_command_result(
542+ &self.events,
543+ id,
544+ CommandKind::Timeline,
545+ Err(CommandError::new(kind, "pagination queue is unavailable")),
546+ )
547+ .await;
548+ }
549+ }
550+ }
551+
552+ async fn close(mut self, kind: CommandErrorKind, message: &'static str) {
553+ self.pagination_worker.abort();
554+ let _ = (&mut self.pagination_worker).await;
555+ drop(self.pagination_results.take());
556+ let _ = (&mut self.pagination_result_task).await;
557+ let pending = self.pagination_ids.lock().await.drain().collect::<Vec<_>>();
558+ for id in pending {
559+ let _ = emit_command_result(
560+ &self.events,
561+ id,
562+ CommandKind::Timeline,
563+ Err(CommandError::new(kind, message)),
564+ )
565+ .await;
566+ }
567+ self.watcher.abort();
568+ let _ = (&mut self.watcher).await;
569+ }
570+}
571+
572+async fn start_timeline_watcher(
573+ id: TimelineId,
574+ timeline: Arc<Timeline>,
575+ events: EventSender,
576+ media: Arc<Mutex<MediaRegistry>>,
577+ current_user_id: Option<ruma::OwnedUserId>,
578+ scope: MediaScope,
579+) -> Result<JoinHandle<()>, SendEventError> {
580+ let (items, mut stream) = timeline.subscribe().await;
581+ let normalised = normalise_timeline(items.iter(), current_user_id.as_deref(), &scope);
582+ media.lock().await.insert_all(normalised.media);
583+ events
584+ .send(Event::TimelineReset {
585+ timeline_id: id.clone(),
586+ items: normalised.items,
587+ })
588+ .await?;
589+ Ok(tokio::spawn(async move {
590+ while let Some(diffs) = stream.next().await {
591+ let normalised = normalise_timeline_diffs(diffs, current_user_id.as_deref(), &scope);
592+ media.lock().await.insert_all(normalised.media);
593+ if events
594+ .send(Event::TimelineChanged {
595+ timeline_id: id.clone(),
596+ diffs: normalised.diffs,
597+ })
598+ .await
599+ .is_err()
600+ {
601+ tracing::debug!(
602+ ?id,
603+ "timeline watcher stopped because the event receiver closed"
604+ );
605+ return;
606+ }
607+ }
608+ tracing::debug!(?id, "timeline watcher stopped");
609+ }))
610+}
611+
612+impl Drop for TimelineSession {
613+ fn drop(&mut self) {
614+ self.watcher.abort();
615+ self.pagination_worker.abort();
616+ self.pagination_result_task.abort();
617+ }
618+}
619+
620+impl Engine {
621+ pub async fn from_client(
622+ client: Client,
623+ config: EngineConfig,
624+ ) -> Result<(Self, ClientHandle, EventStream), EngineError> {
625+ if client.user_id().is_none() {
626+ return Err(EngineError::Unauthenticated);
627+ }
628+ if config.command_capacity == 0
629+ || config.event_capacity == 0
630+ || config.max_media_sources == 0
631+ {
632+ return Err(EngineError::ZeroCapacity);
633+ }
634+ let session_changes = client.subscribe_to_session_changes();
635+ let sync = Arc::new(
636+ SyncService::builder(client.clone())
637+ .with_offline_mode()
638+ .build()
639+ .await
640+ .map_err(EngineError::SyncService)?,
641+ );
642+ let (handle, port, events) = transport_pair(
643+ client.clone(),
644+ sync.clone(),
645+ config.command_capacity,
646+ config.event_capacity,
647+ );
648+ client.send_queue().enable_upload_progress(true);
649+ let (room_actor_event_sender, room_actor_events) = mpsc::unbounded_channel();
650+ let (notification_commands, notification_command_receiver) =
651+ mpsc::channel(config.command_capacity);
652+ let (encryption_commands, encryption_command_receiver) =
653+ mpsc::channel(config.command_capacity);
654+ let (room_commands, room_command_receiver) = mpsc::channel(config.command_capacity);
655+ Ok((
656+ Self {
657+ client,
658+ sync,
659+ port,
660+ rooms: HashMap::new(),
661+ next_room_generation: 0,
662+ room_actor_events,
663+ room_actor_event_sender,
664+ upload_watchers: HashMap::new(),
665+ tasks: TaskRegistry::new(),
666+ session_changes: Some(session_changes),
667+ initial_history_limit: config.initial_history_limit,
668+ room_command_capacity: config.command_capacity,
669+ max_media_bytes: config.max_media_bytes,
670+ media: Arc::new(Mutex::new(MediaRegistry::new(config.max_media_sources))),
671+ notification_commands,
672+ notification_command_receiver: Some(notification_command_receiver),
673+ encryption_commands,
674+ encryption_command_receiver: Some(encryption_command_receiver),
675+ verification: Arc::new(Mutex::new(VerificationController::default())),
676+ room_commands,
677+ room_command_receiver: Some(room_command_receiver),
678+ global_account_gates: HashMap::new(),
679+ room_account_gates: HashMap::new(),
680+ media_flights: Arc::new(Mutex::new(HashMap::new())),
681+ },
682+ handle,
683+ events,
684+ ))
685+ }
686+
687+ pub async fn run(mut self) -> Result<(), EngineError> {
688+ let sender = self.port.event_sender();
689+ let user_id = self
690+ .client
691+ .user_id()
692+ .expect("authentication was checked during construction")
693+ .to_owned();
694+ if sender.send(Event::Authenticated { user_id }).await.is_err() {
695+ self.shutdown().await;
696+ return Err(EngineError::EventReceiverClosed);
697+ }
698+ self.port
699+ .connection_sender()
700+ .send_replace(ConnectionState::Connecting);
701+ if sender
702+ .send(Event::ConnectionChanged(ConnectionState::Connecting))
703+ .await
704+ .is_err()
705+ {
706+ self.shutdown().await;
707+ return Err(EngineError::EventReceiverClosed);
708+ }
709+ self.start_watchers();
710+ self.start_domain_workers();
711+ self.sync.start().await;
712+ let mut terminal_error = None;
713+
714+ loop {
715+ let activity = tokio::select! {
716+ biased;
717+ _ = sender.closed() => {
718+ terminal_error = Some(EngineError::EventReceiverClosed);
719+ break;
720+ }
721+ request = self.port.recv() => EngineActivity::Request(request.map(Box::new)),
722+ completed = self.tasks.join_next(), if !self.tasks.is_empty() => {
723+ EngineActivity::Task(completed)
724+ }
725+ event = self.room_actor_events.recv() => EngineActivity::RoomActor(event),
726+ };
727+ match activity {
728+ EngineActivity::Request(Some(request)) => {
729+ let Some(envelope) = self.dispatch_request(*request).await else {
730+ continue;
731+ };
732+ let id = envelope.id;
733+ let command = envelope.command;
734+ if matches!(command, Command::Lifecycle(LifecycleCommand::Shutdown)) {
735+ let _ =
736+ emit_command_result(&sender, id, CommandKind::Lifecycle, Ok(())).await;
737+ break;
738+ }
739+ let kind = command.kind();
740+ tracing::debug!(?id, ?kind, "dispatching command");
741+ let result = self.execute(id, command).await;
742+ if Self::emit_sync_result(&sender, id, kind, result)
743+ .await
744+ .is_err()
745+ {
746+ terminal_error = Some(EngineError::EventReceiverClosed);
747+ break;
748+ }
749+ }
750+ EngineActivity::Request(None) => break,
751+ EngineActivity::Task(Some((context, result))) => {
752+ if let Err(error) = result
753+ && error.is_panic()
754+ {
755+ match context {
756+ TaskContext::Command { id, kind } => {
757+ if sender
758+ .send(Event::CommandFailed(CommandFailure {
759+ id,
760+ command: kind,
761+ kind: CommandErrorKind::Other,
762+ message: "command task panicked".to_owned(),
763+ }))
764+ .await
765+ .is_err()
766+ {
767+ terminal_error = Some(EngineError::EventReceiverClosed);
768+ break;
769+ }
770+ }
771+ context => {
772+ terminal_error = Some(EngineError::TaskPanicked {
773+ task: context.label(),
774+ });
775+ break;
776+ }
777+ }
778+ }
779+ }
780+ EngineActivity::Task(None) => {}
781+ EngineActivity::RoomActor(Some(event)) => {
782+ if let Err(error) = self.handle_room_actor_event(event).await {
783+ terminal_error = Some(error);
784+ break;
785+ }
786+ }
787+ EngineActivity::RoomActor(None) => {
788+ terminal_error = Some(EngineError::TaskPanicked {
789+ task: "room actor event channel".to_owned(),
790+ });
791+ break;
792+ }
793+ }
794+ }
795+
796+ if let Some(error) = terminal_error.as_ref() {
797+ let _ = sender.send(Event::Failed(error.failure())).await;
798+ }
799+ self.shutdown().await;
800+ let _ = sender.send(Event::Stopped).await;
801+ if let Some(error) = terminal_error {
802+ Err(error)
803+ } else {
804+ Ok(())
805+ }
806+ }
807+
808+ async fn emit_sync_result(
809+ sender: &EventSender,
810+ id: CommandId,
811+ kind: CommandKind,
812+ result: Result<bool, CommandError>,
813+ ) -> Result<(), SendEventError> {
814+ match result {
815+ Ok(true) => Ok(()),
816+ Ok(false) => emit_command_result(sender, id, kind, Ok(())).await,
817+ Err(error) => {
818+ tracing::warn!(?id, ?kind, error = ?error, "command failed");
819+ emit_command_result(sender, id, kind, Err(error)).await?;
820+ Ok(())
821+ }
822+ }
823+ }
824+
825+ fn spawn_logout(&mut self, id: CommandId) {
826+ let events = self.port.event_sender();
827+ let client = self.client.clone();
828+ self.tasks.spawn(
829+ TaskContext::Command {
830+ id,
831+ kind: CommandKind::Account,
832+ },
833+ async move {
834+ match client
835+ .logout()
836+ .await
837+ .map(|_| ())
838+ .map_err(|source| CommandError::from_sdk("logout failed", source))
839+ {
840+ Ok(()) => {
841+ let _ =
842+ emit_command_result(&events, id, CommandKind::Account, Ok(())).await;
843+ let _ = events.send(Event::LoggedOut).await;
844+ }
845+ Err(error) => {
846+ let _ = emit_command_result(&events, id, CommandKind::Account, Err(error))
847+ .await;
848+ }
849+ }
850+ },
851+ );
852+ }
853+
854+ async fn dispatch_request(&mut self, request: DriverRequest) -> Option<CommandEnvelope> {
855+ match request {
856+ DriverRequest::Command(command) => Some(command),
857+ DriverRequest::DeleteDevices {
858+ id,
859+ device_ids,
860+ auth,
861+ reply,
862+ } => {
863+ self.spawn_delete_devices(id, device_ids, auth, Some(reply));
864+ None
865+ }
866+ DriverRequest::RoomResult { id, command, reply } => {
867+ if let Err(error) = self.spawn_room_command_with_reply(id, command, Some(reply)) {
868+ tracing::warn!(?id, error = ?error, "room result request failed");
869+ }
870+ None
871+ }
872+ DriverRequest::SetGlobalAccountData {
873+ event_type,
874+ content,
875+ reply,
876+ } => {
877+ self.spawn_global_account_data_write(event_type, content, reply);
878+ None
879+ }
880+ DriverRequest::SetRoomAccountData {
881+ room_id,
882+ event_type,
883+ content,
884+ reply,
885+ } => {
886+ self.spawn_room_account_data_write(room_id, event_type, content, reply);
887+ None
888+ }
889+ }
890+ }
891+
892+ fn spawn_mark_read(&mut self, id: CommandId, timeline_id: TimelineId, event_id: OwnedEventId) {
893+ let events = self.port.event_sender();
894+ let client = self.client.clone();
895+ self.tasks.spawn(
896+ TaskContext::Command {
897+ id,
898+ kind: CommandKind::Room,
899+ },
900+ async move {
901+ let result = mark_read(&client, &timeline_id, event_id).await;
902+ let _ = emit_command_result(&events, id, CommandKind::Room, result).await;
903+ },
904+ );
905+ }
906+
907+ fn spawn_typing(&mut self, id: CommandId, room_id: OwnedRoomId, typing: bool) {
908+ let events = self.port.event_sender();
909+ let client = self.client.clone();
910+ self.tasks.spawn(
911+ TaskContext::Command {
912+ id,
913+ kind: CommandKind::Room,
914+ },
915+ async move {
916+ let result = set_typing(&client, &room_id, typing).await;
917+ let _ = emit_command_result(&events, id, CommandKind::Room, result).await;
918+ },
919+ );
920+ }
921+
922+ fn spawn_cancel_send(
923+ &mut self,
924+ id: CommandId,
925+ room_id: OwnedRoomId,
926+ transaction_id: matrix_sdk::ruma::OwnedTransactionId,
927+ ) {
928+ let events = self.port.event_sender();
929+ let client = self.client.clone();
930+ self.tasks.spawn(
931+ TaskContext::Command {
932+ id,
933+ kind: CommandKind::Room,
934+ },
935+ async move {
936+ let result = cancel_send(&client, &room_id, &transaction_id).await;
937+ let _ = emit_command_result(&events, id, CommandKind::Room, result).await;
938+ },
939+ );
940+ }
941+
942+ fn spawn_retry_send(
943+ &mut self,
944+ id: CommandId,
945+ room_id: OwnedRoomId,
946+ transaction_id: matrix_sdk::ruma::OwnedTransactionId,
947+ ) {
948+ let events = self.port.event_sender();
949+ let client = self.client.clone();
950+ self.tasks.spawn(
951+ TaskContext::Command {
952+ id,
953+ kind: CommandKind::Room,
954+ },
955+ async move {
956+ let result = retry_send(&client, &room_id, &transaction_id).await;
957+ let _ = emit_command_result(&events, id, CommandKind::Room, result).await;
958+ },
959+ );
960+ }
961+
962+ fn spawn_delete_devices(
963+ &mut self,
964+ id: CommandId,
965+ device_ids: Vec<OwnedDeviceId>,
966+ auth: Option<InteractiveAuth>,
967+ reply: Option<oneshot::Sender<Result<InteractiveAuthOutcome, CommandResultError>>>,
968+ ) {
969+ let events = self.port.event_sender();
970+ let client = self.client.clone();
971+ self.tasks.spawn(
972+ TaskContext::Command {
973+ id,
974+ kind: CommandKind::Account,
975+ },
976+ async move {
977+ let auth = match auth {
978+ Some(auth) => match auth.into_sdk(
979+ client
980+ .user_id()
981+ .expect("authentication was checked during construction"),
982+ ) {
983+ Ok(auth) => Some(auth),
984+ Err(message) => {
985+ let failure = CommandFailure {
986+ id,
987+ command: CommandKind::Account,
988+ kind: CommandErrorKind::Other,
989+ message: format!(
990+ "interactive authentication data is invalid, {message}"
991+ ),
992+ };
993+ if let Some(reply) = reply {
994+ let _ =
995+ reply.send(Err(CommandResultError::Failed(failure.clone())));
996+ } else {
997+ let _ = events.send(Event::CommandFailed(failure)).await;
998+ }
999+ return;
1000+ }
1001+ },
1002+ None => None,
1003+ };
1004+ let result = client.delete_devices(&device_ids, auth).await;
1005+
1006+ match result {
1007+ Ok(_) => {
1008+ if let Some(reply) = reply {
1009+ let _ = reply.send(Ok(InteractiveAuthOutcome::Completed));
1010+ } else {
1011+ let _ = emit_command_result(&events, id, CommandKind::Account, Ok(()))
1012+ .await;
1013+ }
1014+ }
1015+ Err(source) => {
1016+ if let Some(info) = source.as_uiaa_response() {
1017+ let challenge = InteractiveAuthChallenge::from(info);
1018+ if let Some(reply) = reply {
1019+ let _ =
1020+ reply.send(Ok(InteractiveAuthOutcome::Challenge(challenge)));
1021+ } else {
1022+ let _ = events
1023+ .send(Event::InteractiveAuthRequired {
1024+ id,
1025+ command: CommandKind::Account,
1026+ challenge: challenge.clone(),
1027+ })
1028+ .await;
1029+ let _ = events
1030+ .send(Event::CommandFailed(CommandFailure {
1031+ id,
1032+ command: CommandKind::Account,
1033+ kind: CommandErrorKind::InteractiveAuthRequired,
1034+ message: "interactive authentication is required"
1035+ .to_owned(),
1036+ }))
1037+ .await;
1038+ }
1039+ } else {
1040+ let error = CommandError::from_http("deleting devices failed", source);
1041+ let failure = CommandFailure {
1042+ id,
1043+ command: CommandKind::Account,
1044+ kind: error.kind,
1045+ message: error.to_string(),
1046+ };
1047+ if let Some(reply) = reply {
1048+ let _ =
1049+ reply.send(Err(CommandResultError::Failed(failure.clone())));
1050+ } else {
1051+ let _ = events.send(Event::CommandFailed(failure)).await;
1052+ }
1053+ }
1054+ }
1055+ }
1056+ },
1057+ );
1058+ }
1059+
1060+ fn spawn_rename_device(
1061+ &mut self,
1062+ id: CommandId,
1063+ device_id: OwnedDeviceId,
1064+ display_name: String,
1065+ ) {
1066+ let events = self.port.event_sender();
1067+ let client = self.client.clone();
1068+ self.tasks.spawn(
1069+ TaskContext::Command {
1070+ id,
1071+ kind: CommandKind::Account,
1072+ },
1073+ async move {
1074+ match client.rename_device(&device_id, &display_name).await {
1075+ Ok(_) => {
1076+ let _ = events
1077+ .send(Event::CommandSucceeded {
1078+ id,
1079+ command: CommandKind::Account,
1080+ })
1081+ .await;
1082+ }
1083+ Err(source) => {
1084+ let error = CommandError::from_http("renaming device failed", source);
1085+ let _ = events
1086+ .send(Event::CommandFailed(CommandFailure {
1087+ id,
1088+ command: CommandKind::Account,
1089+ kind: error.kind,
1090+ message: error.to_string(),
1091+ }))
1092+ .await;
1093+ }
1094+ }
1095+ },
1096+ );
1097+ }
1098+
1099+ fn spawn_global_account_data_write(
1100+ &mut self,
1101+ event_type: GlobalAccountDataEventType,
1102+ content: Raw<AnyGlobalAccountDataEventContent>,
1103+ reply: oneshot::Sender<Result<(), ClientQueryError>>,
1104+ ) {
1105+ let (done, next) = oneshot::channel();
1106+ let previous = self
1107+ .global_account_gates
1108+ .insert(event_type.to_string(), next);
1109+ let client = self.client.clone();
1110+ self.tasks.spawn(
1111+ TaskContext::Watcher("global account data writer"),
1112+ async move {
1113+ if let Some(previous) = previous {
1114+ let _ = previous.await;
1115+ }
1116+ let result = client
1117+ .account()
1118+ .set_account_data_raw(event_type, content)
1119+ .await
1120+ .map(|_| ())
1121+ .map_err(ClientQueryError::Sdk);
1122+ let _ = reply.send(result);
1123+ let _ = done.send(());
1124+ },
1125+ );
1126+ }
1127+
1128+ fn spawn_room_account_data_write(
1129+ &mut self,
1130+ room_id: OwnedRoomId,
1131+ event_type: RoomAccountDataEventType,
1132+ content: Raw<matrix_sdk::ruma::events::AnyRoomAccountDataEventContent>,
1133+ reply: oneshot::Sender<Result<(), ClientQueryError>>,
1134+ ) {
1135+ let (done, next) = oneshot::channel();
1136+ let previous = self
1137+ .room_account_gates
1138+ .insert((room_id.clone(), event_type.to_string()), next);
1139+ let client = self.client.clone();
1140+ self.tasks.spawn(
1141+ TaskContext::Watcher("room account data writer"),
1142+ async move {
1143+ if let Some(previous) = previous {
1144+ let _ = previous.await;
1145+ }
1146+ let result = match client.get_room(&room_id) {
1147+ Some(room) => room
1148+ .set_account_data_raw(event_type, content)
1149+ .await
1150+ .map(|_| ())
1151+ .map_err(ClientQueryError::Sdk),
1152+ None => Err(ClientQueryError::RoomUnknown(room_id)),
1153+ };
1154+ let _ = reply.send(result);
1155+ let _ = done.send(());
1156+ },
1157+ );
1158+ }
1159+
1160+ fn start_domain_workers(&mut self) {
1161+ let mut notification_commands = self
1162+ .notification_command_receiver
1163+ .take()
1164+ .expect("notification worker has not started");
1165+ let notification_client = self.client.clone();
1166+ let notification_events = self.port.event_sender();
1167+ self.tasks.spawn(
1168+ TaskContext::Watcher("notification command worker"),
1169+ async move {
1170+ while let Some(CommandJob { id, command }) = notification_commands.recv().await {
1171+ let result = AssertUnwindSafe(execute_notification_command(
1172+ &notification_client,
1173+ command,
1174+ ))
1175+ .catch_unwind()
1176+ .await
1177+ .unwrap_or_else(|_| {
1178+ Err(CommandError::new(
1179+ CommandErrorKind::Other,
1180+ "notification command panicked",
1181+ ))
1182+ });
1183+ let _ = emit_command_result(
1184+ &notification_events,
1185+ id,
1186+ CommandKind::Notification,
1187+ result,
1188+ )
1189+ .await;
1190+ }
1191+ },
1192+ );
1193+
1194+ let mut encryption_commands = self
1195+ .encryption_command_receiver
1196+ .take()
1197+ .expect("encryption worker has not started");
1198+ let encryption_client = self.client.clone();
1199+ let encryption_events = self.port.event_sender();
1200+ let verification = self.verification.clone();
1201+ self.tasks.spawn(
1202+ TaskContext::Watcher("encryption command worker"),
1203+ async move {
1204+ while let Some(work) = encryption_commands.recv().await {
1205+ match work {
1206+ EncryptionWork::Command(CommandJob { id, command }) => {
1207+ if matches!(
1208+ command,
1209+ EncryptionCommand::Recover(_)
1210+ | EncryptionCommand::RecoverKeyBackup(_)
1211+ ) {
1212+ let result = AssertUnwindSafe(execute_encryption_command(
1213+ &encryption_client,
1214+ &encryption_events,
1215+ &mut VerificationController::default(),
1216+ command,
1217+ ))
1218+ .catch_unwind()
1219+ .await
1220+ .unwrap_or_else(|_| {
1221+ Err(CommandError::new(
1222+ CommandErrorKind::Other,
1223+ "encryption command panicked",
1224+ ))
1225+ });
1226+ let _ = emit_command_result(
1227+ &encryption_events,
1228+ id,
1229+ CommandKind::Encryption,
1230+ result,
1231+ )
1232+ .await;
1233+ continue;
1234+ }
1235+ let is_key_backup = matches!(
1236+ command,
1237+ EncryptionCommand::CreateKeyBackup
1238+ | EncryptionCommand::EnableKeyBackup
1239+ | EncryptionCommand::DisableKeyBackup { .. }
1240+ );
1241+ let result = if is_key_backup {
1242+ AssertUnwindSafe(execute_key_backup_command(
1243+ &encryption_client,
1244+ command,
1245+ ))
1246+ .catch_unwind()
1247+ .await
1248+ .unwrap_or_else(|_| {
1249+ Err(CommandError::new(
1250+ CommandErrorKind::Other,
1251+ "encryption command panicked",
1252+ ))
1253+ })
1254+ } else {
1255+ let mut verification = verification.lock().await;
1256+ let result = AssertUnwindSafe(execute_encryption_command(
1257+ &encryption_client,
1258+ &encryption_events,
1259+ &mut verification,
1260+ command,
1261+ ))
1262+ .catch_unwind()
1263+ .await
1264+ .unwrap_or_else(|_| {
1265+ Err(CommandError::new(
1266+ CommandErrorKind::Other,
1267+ "encryption command panicked",
1268+ ))
1269+ });
1270+ drop(verification);
1271+ result
1272+ };
1273+ let _ = emit_command_result(
1274+ &encryption_events,
1275+ id,
1276+ CommandKind::Encryption,
1277+ result,
1278+ )
1279+ .await;
1280+ }
1281+ }
1282+ }
1283+ },
1284+ );
1285+
1286+ let room_commands = self
1287+ .room_command_receiver
1288+ .take()
1289+ .expect("room command worker has not started");
1290+ let room_client = self.client.clone();
1291+ let room_events = self.port.event_sender();
1292+ self.tasks.spawn(
1293+ TaskContext::Watcher("room command worker"),
1294+ run_room_command_worker(
1295+ room_client,
1296+ room_events,
1297+ room_commands,
1298+ self.room_command_capacity,
1299+ ),
1300+ );
1301+ }
1302+
1303+ fn start_watchers(&mut self) {
1304+ let mut session_changes = self
1305+ .session_changes
1306+ .take()
1307+ .expect("session watcher has not started");
1308+ let session_sender = self.port.event_sender();
1309+ self.tasks
1310+ .spawn(TaskContext::Watcher("session change watcher"), async move {
1311+ loop {
1312+ let event = match session_changes.recv().await {
1313+ Ok(SessionChange::TokensRefreshed) => Event::SessionTokensChanged,
1314+ Ok(SessionChange::UnknownToken(_)) => Event::SessionAccessRejected,
1315+ Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
1316+ Event::SessionTokensChanged
1317+ }
1318+ Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1319+ };
1320+ if session_sender.send(event).await.is_err() {
1321+ break;
1322+ }
1323+ }
1324+ });
1325+
1326+ let mut sync_states = self.sync.state();
1327+ let state_sender = self.port.event_sender();
1328+ let connection_sender = self.port.connection_sender();
1329+ self.tasks.spawn(
1330+ TaskContext::Watcher("connection state watcher"),
1331+ async move {
1332+ while let Some(state) = sync_states.next().await {
1333+ let state = match state {
1334+ SyncState::Idle => ConnectionState::Idle,
1335+ SyncState::Running => ConnectionState::Online,
1336+ SyncState::Offline => ConnectionState::Offline,
1337+ SyncState::Terminated => ConnectionState::Stopped,
1338+ SyncState::Error(error) => {
1339+ tracing::warn!(error = %error, "sync service reported an error");
1340+ ConnectionState::Offline
1341+ }
1342+ };
1343+ connection_sender.send_replace(state);
1344+ if state_sender
1345+ .send(Event::ConnectionChanged(state))
1346+ .await
1347+ .is_err()
1348+ {
1349+ tracing::debug!(
1350+ "connection watcher stopped because the event receiver closed"
1351+ );
1352+ break;
1353+ }
1354+ }
1355+ tracing::debug!("connection watcher stopped because the sync state stream ended");
1356+ },
1357+ );
1358+
1359+ let client = self.client.clone();
1360+ let encryption_sender = self.port.event_sender();
1361+ self.tasks.spawn(
1362+ TaskContext::Watcher("encryption state watcher"),
1363+ async move {
1364+ let Some(device_id) = client.device_id().map(ToOwned::to_owned) else {
1365+ tracing::warn!("encryption watcher stopped because the client has no device id");
1366+ return;
1367+ };
1368+ let recovery = client.encryption().recovery();
1369+ let backups = client.encryption().backups();
1370+ let mut recovery_stream = recovery.state_stream();
1371+ let mut backup_stream = backups.state_stream();
1372+ let mut verification_stream = client.encryption().verification_state();
1373+ let mut recovery_state = recovery.state();
1374+ let mut backup_state = backups.state();
1375+ let mut verification_state = verification_stream.get();
1376+ if encryption_sender
1377+ .send(Event::EncryptionChanged(encryption_state(
1378+ device_id.clone(),
1379+ recovery_state,
1380+ verification_state,
1381+ backup_state,
1382+ )))
1383+ .await
1384+ .is_err()
1385+ {
1386+ tracing::debug!(
1387+ "encryption watcher stopped because the event receiver closed"
1388+ );
1389+ return;
1390+ }
1391+ loop {
1392+ tokio::select! {
1393+ update = recovery_stream.next() => {
1394+ let Some(update) = update else {
1395+ tracing::debug!("encryption watcher stopped because the recovery stream ended");
1396+ break;
1397+ };
1398+ recovery_state = update;
1399+ }
1400+ update = verification_stream.next() => {
1401+ let Some(update) = update else {
1402+ tracing::debug!("encryption watcher stopped because the verification stream ended");
1403+ break;
1404+ };
1405+ verification_state = update;
1406+ }
1407+ update = backup_stream.next() => {
1408+ let Some(update) = update else {
1409+ tracing::debug!("backup watcher stopped because the backup state stream ended");
1410+ break;
1411+ };
1412+ backup_state = update.unwrap_or_else(|_| backups.state());
1413+ }
1414+ }
1415+ if encryption_sender
1416+ .send(Event::EncryptionChanged(encryption_state(
1417+ device_id.clone(),
1418+ recovery_state,
1419+ verification_state,
1420+ backup_state,
1421+ )))
1422+ .await
1423+ .is_err()
1424+ {
1425+ tracing::debug!(
1426+ "encryption watcher stopped because the event receiver closed"
1427+ );
1428+ break;
1429+ }
1430+ }
1431+ },
1432+ );
1433+
1434+ let client = self.client.clone();
1435+ let room_sender = self.port.event_sender();
1436+ self.tasks.spawn(
1437+ TaskContext::Watcher("room list watcher"),
1438+ async move {
1439+ let (_, mut room_stream) = client.rooms_stream();
1440+ let mut info_stream = client.room_info_notable_update_receiver();
1441+ if room_sender.send(Event::RoomsChanged).await.is_err() {
1442+ tracing::debug!("room watcher stopped because the event receiver closed");
1443+ return;
1444+ }
1445+ loop {
1446+ tokio::select! {
1447+ update = room_stream.next() => {
1448+ if update.is_none() {
1449+ tracing::debug!("room watcher stopped because the room stream ended");
1450+ break;
1451+ }
1452+ }
1453+ update = info_stream.recv() => {
1454+ if update.is_err() && info_stream.is_closed() {
1455+ tracing::debug!("room watcher stopped because the room info stream ended");
1456+ break;
1457+ }
1458+ }
1459+ }
1460+ if room_sender.send(Event::RoomsChanged).await.is_err() {
1461+ tracing::debug!("room watcher stopped because the event receiver closed");
1462+ break;
1463+ }
1464+ }
1465+ },
1466+ );
1467+
1468+ let client = self.client.clone();
1469+ let notification_sender = self.port.event_sender();
1470+ self.tasks.spawn(
1471+ TaskContext::Watcher("notification settings watcher"),
1472+ async move {
1473+ let settings = client.notification_settings().await;
1474+ let mut changes = settings.subscribe_to_changes();
1475+ while let Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) =
1476+ changes.recv().await
1477+ {
1478+ if notification_sender
1479+ .send(Event::NotificationSettingsChanged)
1480+ .await
1481+ .is_err()
1482+ {
1483+ break;
1484+ }
1485+ }
1486+ },
1487+ );
1488+ }
1489+
1490+ async fn execute(&mut self, id: CommandId, command: Command) -> Result<bool, CommandError> {
1491+ match command {
1492+ Command::Account(AccountCommand::Logout) => {
1493+ self.spawn_logout(id);
1494+ Ok(true)
1495+ }
1496+ Command::Account(AccountCommand::RenameDevice {
1497+ device_id,
1498+ display_name,
1499+ }) => {
1500+ self.spawn_rename_device(id, device_id, display_name);
1501+ Ok(true)
1502+ }
1503+ Command::Account(AccountCommand::DeleteDevices { device_ids, auth }) => {
1504+ self.spawn_delete_devices(id, device_ids, auth, None);
1505+ Ok(true)
1506+ }
1507+ Command::Room(RoomCommand::Open(room_id)) => self.open_room(id, room_id).await,
1508+ Command::Room(RoomCommand::Close(room_id)) => self.close_room(id, &room_id).await,
1509+ Command::Room(RoomCommand::MarkRead {
1510+ timeline_id,
1511+ event_id,
1512+ }) => {
1513+ self.spawn_mark_read(id, timeline_id, event_id);
1514+ Ok(true)
1515+ }
1516+ Command::Room(RoomCommand::SetTyping { room_id, typing }) => {
1517+ self.spawn_typing(id, room_id, typing);
1518+ Ok(true)
1519+ }
1520+ Command::Room(RoomCommand::Join { room, via }) => {
1521+ self.spawn_room_command(id, RoomCommand::Join { room, via })
1522+ }
1523+ Command::Room(RoomCommand::Leave(room_id)) => {
1524+ self.spawn_room_command(id, RoomCommand::Leave(room_id))
1525+ }
1526+ Command::Room(RoomCommand::Forget(room_id)) => {
1527+ self.spawn_room_command(id, RoomCommand::Forget(room_id))
1528+ }
1529+ Command::Room(RoomCommand::AcceptInvite(room_id)) => {
1530+ self.spawn_room_command(id, RoomCommand::AcceptInvite(room_id))
1531+ }
1532+ Command::Room(RoomCommand::RejectInvite(room_id)) => {
1533+ self.spawn_room_command(id, RoomCommand::RejectInvite(room_id))
1534+ }
1535+ Command::Room(command @ RoomCommand::InviteUser { .. })
1536+ | Command::Room(command @ RoomCommand::KickUser { .. })
1537+ | Command::Room(command @ RoomCommand::BanUser { .. })
1538+ | Command::Room(command @ RoomCommand::UnbanUser { .. })
1539+ | Command::Room(command @ RoomCommand::SetPowerLevels { .. })
1540+ | Command::Room(command @ RoomCommand::SetUserPowerLevel { .. }) => {
1541+ self.spawn_room_command(id, command)
1542+ }
1543+ Command::Room(RoomCommand::Create(options)) => {
1544+ self.spawn_room_command(id, RoomCommand::Create(options))
1545+ }
1546+ Command::Room(RoomCommand::CreateDirect(user_id)) => {
1547+ self.spawn_room_command(id, RoomCommand::CreateDirect(user_id))
1548+ }
1549+ Command::Room(RoomCommand::CancelSend {
1550+ room_id,
1551+ transaction_id,
1552+ }) => {
1553+ self.spawn_cancel_send(id, room_id, transaction_id);
1554+ Ok(true)
1555+ }
1556+ Command::Room(RoomCommand::RetrySend {
1557+ room_id,
1558+ transaction_id,
1559+ }) => {
1560+ self.spawn_retry_send(id, room_id, transaction_id);
1561+ Ok(true)
1562+ }
1563+ Command::Timeline(command) => self.route_timeline(id, command),
1564+ Command::Media(MediaCommand::Fetch { id: media_id, size }) => {
1565+ self.spawn_media_fetch(id, media_id, size);
1566+ Ok(true)
1567+ }
1568+ Command::Media(MediaCommand::Upload {
1569+ timeline_id,
1570+ filename,
1571+ mimetype,
1572+ bytes,
1573+ caption,
1574+ }) => {
1575+ let mut upload = AttachmentUpload::new(timeline_id, filename, mimetype, bytes);
1576+ upload.caption = caption;
1577+ self.spawn_media_upload(id, upload).await.map(|()| true)
1578+ }
1579+ Command::Media(MediaCommand::UploadAttachment(upload)) => {
1580+ self.spawn_media_upload(id, upload).await.map(|()| true)
1581+ }
1582+ Command::Encryption(command) => {
1583+ self.encryption_commands
1584+ .try_send(EncryptionWork::Command(CommandJob { id, command }))
1585+ .map_err(|error| domain_queue_error(error, "encryption"))?;
1586+ Ok(true)
1587+ }
1588+ Command::Notification(command) => {
1589+ self.notification_commands
1590+ .try_send(CommandJob { id, command })
1591+ .map_err(|error| domain_queue_error(error, "notification"))?;
1592+ Ok(true)
1593+ }
1594+ Command::Lifecycle(LifecycleCommand::Shutdown) => Ok(false),
1595+ }
1596+ }
1597+
1598+ async fn open_room(
1599+ &mut self,
1600+ id: CommandId,
1601+ room_id: OwnedRoomId,
1602+ ) -> Result<bool, CommandError> {
1603+ match self.rooms.get_mut(&room_id) {
1604+ Some(RoomActorState::Opening { sender, .. }) => {
1605+ send_room_actor_request(sender, RoomActorRequest::Opened { id })?;
1606+ return Ok(true);
1607+ }
1608+ Some(RoomActorState::Open { .. }) => return Ok(false),
1609+ Some(RoomActorState::Closing { pending, .. }) => {
1610+ if pending.len() >= self.room_command_capacity {
1611+ return Err(CommandError::new(
1612+ CommandErrorKind::RoomBusy,
1613+ "room lifecycle queue is full",
1614+ ));
1615+ }
1616+ pending.push_back(DeferredRoomLifecycle::Open(id));
1617+ return Ok(true);
1618+ }
1619+ None => {}
1620+ }
1621+ self.start_room_actor(room_id, vec![id]).await?;
1622+ Ok(true)
1623+ }
1624+
1625+ async fn start_room_actor(
1626+ &mut self,
1627+ room_id: OwnedRoomId,
1628+ open_ids: Vec<CommandId>,
1629+ ) -> Result<(), CommandError> {
1630+ let room = self.client.get_room(&room_id).ok_or_else(|| {
1631+ CommandError::new(
1632+ CommandErrorKind::RoomUnknown,
1633+ "room is not available in the local store",
1634+ )
1635+ })?;
1636+ self.next_room_generation = self.next_room_generation.wrapping_add(1);
1637+ let generation = self.next_room_generation;
1638+ let (sender, receiver) = mpsc::channel(self.room_command_capacity);
1639+ let actor = RoomActor {
1640+ room_id: room_id.clone(),
1641+ generation,
1642+ room,
1643+ receiver,
1644+ events: self.port.event_sender(),
1645+ engine_events: self.room_actor_event_sender.clone(),
1646+ media: self.media.clone(),
1647+ initial_history_limit: self.initial_history_limit,
1648+ open_ids,
1649+ };
1650+ let current_user_id = self.client.user_id().map(ToOwned::to_owned);
1651+ self.rooms.insert(
1652+ room_id.clone(),
1653+ RoomActorState::Opening { generation, sender },
1654+ );
1655+ let actor_room_id = room_id.clone();
1656+ let engine_events = self.room_actor_event_sender.clone();
1657+ self.tasks.spawn(
1658+ TaskContext::RoomActor {
1659+ room_id: room_id.clone(),
1660+ generation,
1661+ },
1662+ async move {
1663+ actor.run(current_user_id).await;
1664+ let _ = engine_events.send(RoomActorEvent::Exited {
1665+ room_id: actor_room_id,
1666+ generation,
1667+ });
1668+ },
1669+ );
1670+ self.update_room_subscriptions().await;
1671+ Ok(())
1672+ }
1673+
1674+ fn spawn_room_command(
1675+ &mut self,
1676+ id: CommandId,
1677+ command: RoomCommand,
1678+ ) -> Result<bool, CommandError> {
1679+ self.spawn_room_command_with_reply(id, command, None)?;
1680+ Ok(true)
1681+ }
1682+
1683+ fn spawn_room_command_with_reply(
1684+ &mut self,
1685+ id: CommandId,
1686+ command: RoomCommand,
1687+ reply: Option<oneshot::Sender<Result<OwnedRoomId, CommandResultError>>>,
1688+ ) -> Result<(), CommandError> {
1689+ let job = RoomJob { id, command, reply };
1690+ self.room_commands.try_send(job).map_err(|error| {
1691+ let (job, kind, message) = match error {
1692+ mpsc::error::TrySendError::Full(job) => (
1693+ job,
1694+ CommandErrorKind::QueueFull,
1695+ "room command queue is full",
1696+ ),
1697+ mpsc::error::TrySendError::Closed(job) => (
1698+ job,
1699+ CommandErrorKind::Other,
1700+ "room command worker has stopped",
1701+ ),
1702+ };
1703+ let error = CommandError::new(kind, message);
1704+ if let Some(reply) = job.reply {
1705+ let _ = reply.send(Err(CommandResultError::Failed(CommandFailure {
1706+ id: job.id,
1707+ command: CommandKind::Room,
1708+ kind: error.kind,
1709+ message: error.to_string(),
1710+ })));
1711+ }
1712+ error
1713+ })
1714+ }
1715+
1716+ fn route_timeline(
1717+ &mut self,
1718+ id: CommandId,
1719+ command: TimelineCommand,
1720+ ) -> Result<bool, CommandError> {
1721+ let room_id = command.room_id();
1722+ let sender = self
1723+ .rooms
1724+ .get(room_id)
1725+ .and_then(RoomActorState::sender)
1726+ .ok_or_else(|| CommandError::new(CommandErrorKind::RoomNotOpen, "room is not open"))?;
1727+ send_room_actor_request(sender, RoomActorRequest::Execute { id, command })?;
1728+ Ok(true)
1729+ }
1730+
1731+ async fn close_room(&mut self, id: CommandId, room_id: &RoomId) -> Result<bool, CommandError> {
1732+ let Some(state) = self.rooms.get_mut(room_id) else {
1733+ return Ok(false);
1734+ };
1735+ if let RoomActorState::Closing { pending, .. } = state {
1736+ if pending.len() >= self.room_command_capacity {
1737+ return Err(CommandError::new(
1738+ CommandErrorKind::RoomBusy,
1739+ "room lifecycle queue is full",
1740+ ));
1741+ }
1742+ pending.push_back(DeferredRoomLifecycle::Close(id));
1743+ return Ok(true);
1744+ }
1745+ let Some(sender) = state.sender().cloned() else {
1746+ return Ok(false);
1747+ };
1748+ let generation = state.generation();
1749+ send_room_actor_request(&sender, RoomActorRequest::Close { id })?;
1750+ self.rooms.insert(
1751+ room_id.to_owned(),
1752+ RoomActorState::Closing {
1753+ generation,
1754+ pending: VecDeque::new(),
1755+ },
1756+ );
1757+ self.update_room_subscriptions().await;
1758+ Ok(true)
1759+ }
1760+
1761+ async fn handle_room_actor_event(&mut self, event: RoomActorEvent) -> Result<(), EngineError> {
1762+ match event {
1763+ RoomActorEvent::Ready {
1764+ room_id,
1765+ generation,
1766+ } => {
1767+ let stale = self
1768+ .rooms
1769+ .get(&room_id)
1770+ .is_none_or(|state| state.generation() != generation);
1771+ if stale {
1772+ return Ok(());
1773+ }
1774+ let Some(state) = self.rooms.remove(&room_id) else {
1775+ return Ok(());
1776+ };
1777+ match state {
1778+ RoomActorState::Opening {
1779+ generation: current,
1780+ sender,
1781+ } if current == generation => {
1782+ self.rooms
1783+ .insert(room_id, RoomActorState::Open { generation, sender });
1784+ }
1785+ state => {
1786+ self.rooms.insert(room_id, state);
1787+ }
1788+ }
1789+ }
1790+ RoomActorEvent::Exited {
1791+ room_id,
1792+ generation,
1793+ } => {
1794+ if self
1795+ .rooms
1796+ .get(&room_id)
1797+ .is_none_or(|state| state.generation() != generation)
1798+ {
1799+ return Ok(());
1800+ }
1801+ let state = self
1802+ .rooms
1803+ .remove(&room_id)
1804+ .expect("room generation was checked");
1805+ let pending = match state {
1806+ RoomActorState::Closing { pending, .. } => pending,
1807+ RoomActorState::Opening { .. } | RoomActorState::Open { .. } => VecDeque::new(),
1808+ };
1809+ for operation in pending {
1810+ let (id, result) = match operation {
1811+ DeferredRoomLifecycle::Open(id) => {
1812+ (id, self.open_room(id, room_id.clone()).await)
1813+ }
1814+ DeferredRoomLifecycle::Close(id) => {
1815+ (id, self.close_room(id, &room_id).await)
1816+ }
1817+ };
1818+ match result {
1819+ Ok(true) => {}
1820+ Ok(false) => emit_command_result(
1821+ &self.port.event_sender(),
1822+ id,
1823+ CommandKind::Room,
1824+ Ok(()),
1825+ )
1826+ .await
1827+ .map_err(|_| EngineError::EventReceiverClosed)?,
1828+ Err(error) => emit_command_result(
1829+ &self.port.event_sender(),
1830+ id,
1831+ CommandKind::Room,
1832+ Err(error),
1833+ )
1834+ .await
1835+ .map_err(|_| EngineError::EventReceiverClosed)?,
1836+ }
1837+ }
1838+ self.update_room_subscriptions().await;
1839+ }
1840+ }
1841+ Ok(())
1842+ }
1843+
1844+ async fn update_room_subscriptions(&self) {
1845+ let room_ids = self
1846+ .rooms
1847+ .iter()
1848+ .filter(|(_, state)| !matches!(state, RoomActorState::Closing { .. }))
1849+ .map(|(room_id, _)| room_id.clone())
1850+ .collect::<Vec<_>>();
1851+ let room_refs = room_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>();
1852+ self.sync
1853+ .room_list_service()
1854+ .subscribe_to_rooms(&room_refs)
1855+ .await;
1856+ }
1857+
1858+ fn spawn_media_fetch(&mut self, command_id: CommandId, media_id: MediaId, size: MediaSize) {
1859+ let client = self.client.clone();
1860+ let media = self.media.clone();
1861+ let events = self.port.event_sender();
1862+ let max_media_bytes = self.max_media_bytes;
1863+ let flights = self.media_flights.clone();
1864+ self.tasks.spawn(
1865+ TaskContext::Command {
1866+ id: command_id,
1867+ kind: CommandKind::Media,
1868+ },
1869+ async move {
1870+ let leader = {
1871+ let mut flights = flights.lock().await;
1872+ let waiters = flights.entry((media_id.clone(), size)).or_default();
1873+ waiters.push(command_id);
1874+ waiters.len() == 1
1875+ };
1876+ if !leader {
1877+ return;
1878+ }
1879+ let result = fetch_media(
1880+ client,
1881+ media,
1882+ events.clone(),
1883+ max_media_bytes,
1884+ media_id.clone(),
1885+ size,
1886+ )
1887+ .await;
1888+ let waiters = {
1889+ let mut flights = flights.lock().await;
1890+ flights.remove(&(media_id, size)).unwrap_or_default()
1891+ };
1892+ let result = match result {
1893+ Ok(()) => Ok(()),
1894+ Err(error) => {
1895+ let kind = error.kind;
1896+ let message = error.to_string();
1897+ Err((kind, message))
1898+ }
1899+ };
1900+ for waiter in waiters {
1901+ let waiter_result = match &result {
1902+ Ok(()) => Ok(()),
1903+ Err((kind, message)) => Err(CommandError::new(*kind, message.clone())),
1904+ };
1905+ if emit_command_result(&events, waiter, CommandKind::Media, waiter_result)
1906+ .await
1907+ .is_err()
1908+ {
1909+ tracing::debug!(?waiter, "media fetch result was not delivered");
1910+ }
1911+ }
1912+ },
1913+ );
1914+ }
1915+
1916+ async fn spawn_media_upload(
1917+ &mut self,
1918+ command_id: CommandId,
1919+ upload: AttachmentUpload,
1920+ ) -> Result<(), CommandError> {
1921+ let room_id = upload.timeline_id.room_id().clone();
1922+ let room = self.client.get_room(&room_id).ok_or_else(|| {
1923+ CommandError::new(
1924+ CommandErrorKind::RoomUnknown,
1925+ "room is not available in the local store",
1926+ )
1927+ })?;
1928+ if self
1929+ .upload_watchers
1930+ .get(&room_id)
1931+ .is_some_and(|(watcher, _)| watcher.is_finished())
1932+ {
1933+ self.upload_watchers.remove(&room_id);
1934+ }
1935+ let ready = if let Some((_, ready)) = self.upload_watchers.get(&room_id) {
1936+ ready.clone()
1937+ } else {
1938+ let (ready_sender, ready) = watch::channel(None);
1939+ let watched_room_id = room_id.clone();
1940+ let progress_room_id = room_id.clone();
1941+ let queued_room = room.clone();
1942+ let events = self.port.event_sender();
1943+ let watcher =
1944+ self.tasks
1945+ .spawn(TaskContext::Watcher("media upload watcher"), async move {
1946+ let (_, mut updates) = match queued_room.send_queue().subscribe().await {
1947+ Ok(subscription) => subscription,
1948+ Err(source) => {
1949+ tracing::warn!(room_id = %progress_room_id, error = %source, "reading media send queue failed");
1950+ ready_sender.send_replace(Some(Err(CommandErrorKind::Store)));
1951+ return;
1952+ }
1953+ };
1954+ ready_sender.send_replace(Some(Ok(())));
1955+ loop {
1956+ match updates.recv().await {
1957+ Ok(RoomSendQueueUpdate::MediaUpload {
1958+ related_to,
1959+ file,
1960+ progress,
1961+ ..
1962+ }) => {
1963+ if events
1964+ .send(Event::MediaUploadProgress {
1965+ room_id: progress_room_id.clone(),
1966+ transaction_id: related_to,
1967+ uploaded_bytes: progress.current as u64,
1968+ total_bytes: progress.total as u64,
1969+ complete: file.is_some()
1970+ && progress.current == progress.total,
1971+ })
1972+ .await
1973+ .is_err()
1974+ {
1975+ break;
1976+ }
1977+ }
1978+ Ok(RoomSendQueueUpdate::SendError {
1979+ transaction_id,
1980+ error,
1981+ is_recoverable,
1982+ }) => {
1983+ let is_attachment = queued_room
1984+ .send_queue()
1985+ .subscribe()
1986+ .await
1987+ .is_ok_and(|(echoes, _)| {
1988+ echoes.iter().any(|echo| {
1989+ echo.transaction_id == transaction_id
1990+ && is_attachment_echo(&echo.content)
1991+ })
1992+ });
1993+ if is_attachment
1994+ && events
1995+ .send(Event::MediaUploadFailed {
1996+ room_id: progress_room_id.clone(),
1997+ transaction_id,
1998+ kind: classify_sdk_error(&error),
1999+ recoverable: is_recoverable,
2000+ })
2001+ .await
2002+ .is_err()
2003+ {
2004+ break;
2005+ }
2006+ }
2007+ Ok(_)
2008+ | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
2009+ Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
2010+ }
2011+ }
2012+ });
2013+ self.upload_watchers
2014+ .insert(watched_room_id, (watcher, ready.clone()));
2015+ ready
2016+ };
2017+ let max_media_bytes = self.max_media_bytes;
2018+ let events = self.port.event_sender();
2019+ self.tasks.spawn(
2020+ TaskContext::Command {
2021+ id: command_id,
2022+ kind: CommandKind::Media,
2023+ },
2024+ async move {
2025+ let mut ready = ready;
2026+ let result = async {
2027+ loop {
2028+ if let Some(result) = *ready.borrow_and_update() {
2029+ result.map_err(|kind| {
2030+ CommandError::new(kind, "reading media send queue failed")
2031+ })?;
2032+ break;
2033+ }
2034+ ready.changed().await.map_err(|_| {
2035+ CommandError::new(
2036+ CommandErrorKind::Store,
2037+ "media send queue watcher stopped",
2038+ )
2039+ })?;
2040+ }
2041+ queue_media_upload(room, upload, max_media_bytes).await
2042+ }
2043+ .await;
2044+ if emit_command_result(&events, command_id, CommandKind::Media, result)
2045+ .await
2046+ .is_err()
2047+ {
2048+ tracing::debug!(?command_id, "media upload result was not delivered");
2049+ }
2050+ },
2051+ );
2052+ Ok(())
2053+ }
2054+
2055+ async fn shutdown(&mut self) {
2056+ self.tasks.shutdown().await;
2057+ self.verification.lock().await.stop().await;
2058+ self.rooms.clear();
2059+ self.upload_watchers.clear();
2060+ self.media.lock().await.entries.clear();
2061+ self.sync.stop().await;
2062+ self.port
2063+ .connection_sender()
2064+ .send_replace(ConnectionState::Stopped);
2065+ let _ = self
2066+ .port
2067+ .send(Event::ConnectionChanged(ConnectionState::Stopped))
2068+ .await;
2069+ }
2070+}
2071+
2072+async fn mark_read(
2073+ client: &Client,
2074+ timeline_id: &TimelineId,
2075+ event_id: OwnedEventId,
2076+) -> Result<(), CommandError> {
2077+ let room = client
2078+ .get_room(timeline_id.room_id())
2079+ .ok_or_else(room_unknown)?;
2080+ let receipt_thread = match timeline_id {
2081+ TimelineId::Room(_) => ReceiptThread::Main,
2082+ TimelineId::Thread { root_event_id, .. } => ReceiptThread::Thread(root_event_id.clone()),
2083+ };
2084+ room.send_single_receipt(ReceiptType::Read, receipt_thread, event_id.clone())
2085+ .await
2086+ .map_err(|source| CommandError::from_sdk("sending read receipt failed", source))?;
2087+ if matches!(timeline_id, TimelineId::Room(_)) {
2088+ room.send_single_receipt(ReceiptType::FullyRead, ReceiptThread::Unthreaded, event_id)
2089+ .await
2090+ .map_err(|source| {
2091+ CommandError::from_sdk("sending fully read receipt failed", source)
2092+ })?;
2093+ }
2094+ Ok(())
2095+}
2096+
2097+async fn set_typing(client: &Client, room_id: &RoomId, typing: bool) -> Result<(), CommandError> {
2098+ let room = client.get_room(room_id).ok_or_else(room_unknown)?;
2099+ room.typing_notice(typing)
2100+ .await
2101+ .map_err(|source| CommandError::from_sdk("typing update failed", source))
2102+}
2103+
2104+fn is_attachment_echo(content: &LocalEchoContent) -> bool {
2105+ let LocalEchoContent::Event {
2106+ serialized_event, ..
2107+ } = content
2108+ else {
2109+ return false;
2110+ };
2111+ matches!(
2112+ serialized_event.deserialize(),
2113+ Ok(AnyMessageLikeEventContent::RoomMessage(content))
2114+ if matches!(
2115+ content.msgtype,
2116+ MessageType::Audio(_) | MessageType::File(_) | MessageType::Image(_) | MessageType::Video(_)
2117+ )
2118+ )
2119+}
2120+
2121+async fn send_handle(
2122+ client: &Client,
2123+ room_id: &RoomId,
2124+ transaction_id: &matrix_sdk::ruma::TransactionId,
2125+) -> Result<SendHandle, CommandError> {
2126+ let room = client.get_room(room_id).ok_or_else(room_unknown)?;
2127+ let (echoes, _) = room.send_queue().subscribe().await.map_err(|source| {
2128+ CommandError::with_source(CommandErrorKind::Store, "reading send queue failed", source)
2129+ })?;
2130+ echoes
2131+ .into_iter()
2132+ .find_map(|echo| {
2133+ if echo.transaction_id.as_str() != transaction_id.as_str() {
2134+ return None;
2135+ }
2136+ match echo.content {
2137+ LocalEchoContent::Event { send_handle, .. } => Some(send_handle),
2138+ _ => None,
2139+ }
2140+ })
2141+ .ok_or_else(|| {
2142+ CommandError::new(
2143+ CommandErrorKind::Other,
2144+ "send is no longer pending or is not a message",
2145+ )
2146+ })
2147+}
2148+
2149+async fn cancel_send(
2150+ client: &Client,
2151+ room_id: &RoomId,
2152+ transaction_id: &matrix_sdk::ruma::TransactionId,
2153+) -> Result<(), CommandError> {
2154+ let handle = send_handle(client, room_id, transaction_id).await?;
2155+ match handle.abort().await {
2156+ Ok(true) => Ok(()),
2157+ Ok(false) => Err(CommandError::new(
2158+ CommandErrorKind::Other,
2159+ "send could not be cancelled because it has already been sent",
2160+ )),
2161+ Err(source) => Err(CommandError::with_source(
2162+ CommandErrorKind::Store,
2163+ "cancelling send failed",
2164+ source,
2165+ )),
2166+ }
2167+}
2168+
2169+async fn retry_send(
2170+ client: &Client,
2171+ room_id: &RoomId,
2172+ transaction_id: &matrix_sdk::ruma::TransactionId,
2173+) -> Result<(), CommandError> {
2174+ let handle = send_handle(client, room_id, transaction_id).await?;
2175+ handle.unwedge().await.map_err(|source| {
2176+ CommandError::with_source(CommandErrorKind::Store, "retrying send failed", source)
2177+ })?;
2178+ client.send_queue().set_enabled(true).await;
2179+ Ok(())
2180+}
2181+
2182+fn send_room_actor_request(
2183+ sender: &mpsc::Sender<RoomActorRequest>,
2184+ request: RoomActorRequest,
2185+) -> Result<(), CommandError> {
2186+ sender.try_send(request).map_err(|error| match error {
2187+ mpsc::error::TrySendError::Full(_) => {
2188+ CommandError::new(CommandErrorKind::RoomBusy, "room command queue is full")
2189+ }
2190+ mpsc::error::TrySendError::Closed(_) => {
2191+ CommandError::new(CommandErrorKind::RoomNotOpen, "room is not open")
2192+ }
2193+ })
2194+}
2195+
2196+fn domain_queue_error<T>(error: mpsc::error::TrySendError<T>, domain: &str) -> CommandError {
2197+ match error {
2198+ mpsc::error::TrySendError::Full(_) => CommandError::new(
2199+ CommandErrorKind::QueueFull,
2200+ format!("{domain} command queue is full"),
2201+ ),
2202+ mpsc::error::TrySendError::Closed(_) => CommandError::new(
2203+ CommandErrorKind::Other,
2204+ format!("{domain} command worker has stopped"),
2205+ ),
2206+ }
2207+}
2208+
2209+async fn run_room_command_worker(
2210+ client: Client,
2211+ events: EventSender,
2212+ mut commands: mpsc::Receiver<RoomJob>,
2213+ queue_limit: usize,
2214+) {
2215+ let mut queues = HashMap::<RoomMutationKey, VecDeque<RoomJob>>::new();
2216+ let mut running = HashSet::<RoomMutationKey>::new();
2217+ let mut tasks = JoinSet::<RoomJobCompletion>::new();
2218+ let mut receiving = true;
2219+ let mut queued = 0_usize;
2220+ while receiving || !tasks.is_empty() {
2221+ tokio::select! {
2222+ biased;
2223+ completed = tasks.join_next(), if !tasks.is_empty() => {
2224+ let completion = match completed {
2225+ Some(Ok(completion)) => completion,
2226+ Some(Err(error)) => panic!("room command task failed: {error}"),
2227+ None => continue,
2228+ };
2229+ let key = completion.key.clone();
2230+ emit_room_job_result(&events, completion).await;
2231+ let next = queues.get_mut(&key).and_then(VecDeque::pop_front);
2232+ if next.is_some() {
2233+ queued -= 1;
2234+ }
2235+ if queues.get(&key).is_some_and(VecDeque::is_empty) {
2236+ queues.remove(&key);
2237+ }
2238+ if let Some(job) = next {
2239+ spawn_room_job(&mut tasks, client.clone(), key, job);
2240+ } else {
2241+ running.remove(&key);
2242+ }
2243+ }
2244+ job = commands.recv(), if receiving => {
2245+ match job {
2246+ Some(job) => {
2247+ let key = room_mutation_key(job.id, &job.command);
2248+ if running.insert(key.clone()) {
2249+ spawn_room_job(&mut tasks, client.clone(), key, job);
2250+ } else if queued < queue_limit {
2251+ queues.entry(key).or_default().push_back(job);
2252+ queued += 1;
2253+ } else {
2254+ let failure = CommandFailure {
2255+ id: job.id,
2256+ command: CommandKind::Room,
2257+ kind: CommandErrorKind::QueueFull,
2258+ message: "room command queue is full".to_owned(),
2259+ };
2260+ if let Some(reply) = job.reply {
2261+ let _ = reply.send(Err(CommandResultError::Failed(failure)));
2262+ } else {
2263+ let _ = events.send(Event::CommandFailed(failure)).await;
2264+ }
2265+ }
2266+ }
2267+ None => receiving = false,
2268+ }
2269+ }
2270+ }
2271+ }
2272+}
2273+
2274+fn spawn_room_job(
2275+ tasks: &mut JoinSet<RoomJobCompletion>,
2276+ client: Client,
2277+ key: RoomMutationKey,
2278+ job: RoomJob,
2279+) {
2280+ tasks.spawn(async move {
2281+ let RoomJob { id, command, reply } = job;
2282+ let result = AssertUnwindSafe(execute_room_command(&client, command))
2283+ .catch_unwind()
2284+ .await
2285+ .unwrap_or_else(|_| {
2286+ Err(CommandError::new(
2287+ CommandErrorKind::Other,
2288+ "room command panicked",
2289+ ))
2290+ });
2291+ RoomJobCompletion {
2292+ key,
2293+ id,
2294+ reply,
2295+ result,
2296+ }
2297+ });
2298+}
2299+
2300+fn room_mutation_key(id: CommandId, command: &RoomCommand) -> RoomMutationKey {
2301+ match command {
2302+ RoomCommand::Join { room, .. } if room.is_room_id() => RoomMutationKey::Room(
2303+ OwnedRoomId::try_from(room.clone()).expect("room ID variant was checked"),
2304+ ),
2305+ RoomCommand::Join { room, .. } => RoomMutationKey::Target(room.to_string()),
2306+ RoomCommand::Leave(room_id)
2307+ | RoomCommand::Forget(room_id)
2308+ | RoomCommand::AcceptInvite(room_id)
2309+ | RoomCommand::RejectInvite(room_id)
2310+ | RoomCommand::InviteUser { room_id, .. }
2311+ | RoomCommand::KickUser { room_id, .. }
2312+ | RoomCommand::BanUser { room_id, .. }
2313+ | RoomCommand::UnbanUser { room_id, .. }
2314+ | RoomCommand::SetPowerLevels { room_id, .. }
2315+ | RoomCommand::SetUserPowerLevel { room_id, .. } => RoomMutationKey::Room(room_id.clone()),
2316+ RoomCommand::CreateDirect(user_id) => RoomMutationKey::Direct(user_id.clone()),
2317+ _ => RoomMutationKey::Independent(id),
2318+ }
2319+}
2320+
2321+async fn emit_room_job_result(events: &EventSender, completion: RoomJobCompletion) {
2322+ let RoomJobCompletion {
2323+ id, reply, result, ..
2324+ } = completion;
2325+ match result {
2326+ Ok(Some(room_id)) => {
2327+ if let Some(reply) = reply {
2328+ let _ = reply.send(Ok(room_id.clone()));
2329+ } else {
2330+ let _ = emit_command_result_with_room(events, id, room_id).await;
2331+ }
2332+ }
2333+ Ok(None) => {
2334+ if let Some(reply) = reply {
2335+ let _ = reply.send(Err(CommandResultError::Failed(CommandFailure {
2336+ id,
2337+ command: CommandKind::Room,
2338+ kind: CommandErrorKind::Other,
2339+ message: "room operation did not return a room ID".to_owned(),
2340+ })));
2341+ } else {
2342+ let _ = emit_command_result(events, id, CommandKind::Room, Ok(())).await;
2343+ }
2344+ }
2345+ Err(error) => {
2346+ let failure = CommandFailure {
2347+ id,
2348+ command: CommandKind::Room,
2349+ kind: error.kind,
2350+ message: error.to_string(),
2351+ };
2352+ if let Some(reply) = reply {
2353+ let _ = reply.send(Err(CommandResultError::Failed(failure)));
2354+ } else {
2355+ let _ = events.send(Event::CommandFailed(failure)).await;
2356+ }
2357+ }
2358+ }
2359+}
2360+
2361+async fn execute_room_command(
2362+ client: &Client,
2363+ command: RoomCommand,
2364+) -> Result<Option<OwnedRoomId>, CommandError> {
2365+ match command {
2366+ RoomCommand::Join { room, via } => client
2367+ .join_room_by_id_or_alias(&room, &via)
2368+ .await
2369+ .map(|room| Some(room.room_id().to_owned()))
2370+ .map_err(|source| CommandError::from_sdk("joining room failed", source)),
2371+ RoomCommand::Leave(room_id) => match client.get_room(&room_id) {
2372+ Some(room) => room
2373+ .leave()
2374+ .await
2375+ .map(|_| None)
2376+ .map_err(|source| CommandError::from_sdk("leaving room failed", source)),
2377+ None => Err(room_unknown()),
2378+ },
2379+ RoomCommand::Forget(room_id) => match client.get_room(&room_id) {
2380+ Some(room) => room
2381+ .forget()
2382+ .await
2383+ .map(|_| None)
2384+ .map_err(|source| CommandError::from_sdk("forgetting room failed", source)),
2385+ None => Err(room_unknown()),
2386+ },
2387+ RoomCommand::AcceptInvite(room_id) => match client.get_room(&room_id) {
2388+ Some(room) => room
2389+ .join()
2390+ .await
2391+ .map(|_| None)
2392+ .map_err(|source| CommandError::from_sdk("accepting invite failed", source)),
2393+ None => Err(room_unknown()),
2394+ },
2395+ RoomCommand::RejectInvite(room_id) => match client.get_room(&room_id) {
2396+ Some(room) => room
2397+ .leave()
2398+ .await
2399+ .map(|_| None)
2400+ .map_err(|source| CommandError::from_sdk("rejecting invite failed", source)),
2401+ None => Err(room_unknown()),
2402+ },
2403+ RoomCommand::InviteUser { room_id, user_id } => match client.get_room(&room_id) {
2404+ Some(room) => room
2405+ .invite_user_by_id(&user_id)
2406+ .await
2407+ .map(|()| None)
2408+ .map_err(|source| CommandError::from_sdk("inviting user failed", source)),
2409+ None => Err(room_unknown()),
2410+ },
2411+ RoomCommand::KickUser {
2412+ room_id,
2413+ user_id,
2414+ reason,
2415+ } => match client.get_room(&room_id) {
2416+ Some(room) => room
2417+ .kick_user(&user_id, reason.as_deref())
2418+ .await
2419+ .map(|_| None)
2420+ .map_err(|source| CommandError::from_sdk("kicking user failed", source)),
2421+ None => Err(room_unknown()),
2422+ },
2423+ RoomCommand::BanUser {
2424+ room_id,
2425+ user_id,
2426+ reason,
2427+ } => match client.get_room(&room_id) {
2428+ Some(room) => room
2429+ .ban_user(&user_id, reason.as_deref())
2430+ .await
2431+ .map(|_| None)
2432+ .map_err(|source| CommandError::from_sdk("banning user failed", source)),
2433+ None => Err(room_unknown()),
2434+ },
2435+ RoomCommand::UnbanUser {
2436+ room_id,
2437+ user_id,
2438+ reason,
2439+ } => match client.get_room(&room_id) {
2440+ Some(room) => room
2441+ .unban_user(&user_id, reason.as_deref())
2442+ .await
2443+ .map(|_| None)
2444+ .map_err(|source| CommandError::from_sdk("unbanning user failed", source)),
2445+ None => Err(room_unknown()),
2446+ },
2447+ RoomCommand::SetPowerLevels { room_id, changes } => match client.get_room(&room_id) {
2448+ Some(room) => apply_power_level_changes(&room, changes)
2449+ .await
2450+ .map(|_| None),
2451+ None => Err(room_unknown()),
2452+ },
2453+ RoomCommand::SetUserPowerLevel {
2454+ room_id,
2455+ user_id,
2456+ level,
2457+ } => match client.get_room(&room_id) {
2458+ Some(room) => match matrix_sdk::ruma::Int::try_from(level) {
2459+ Ok(level) => room
2460+ .update_power_levels(vec![(&user_id, level)])
2461+ .await
2462+ .map(|_| None)
2463+ .map_err(|source| {
2464+ CommandError::from_sdk("updating user power level failed", source)
2465+ }),
2466+ Err(_) => Err(CommandError::new(
2467+ CommandErrorKind::Other,
2468+ "power level is outside the Matrix integer range",
2469+ )),
2470+ },
2471+ None => Err(room_unknown()),
2472+ },
2473+ RoomCommand::Create(options) => client
2474+ .create_room(create_room_request(options))
2475+ .await
2476+ .map(|room| Some(room.room_id().to_owned()))
2477+ .map_err(|source| CommandError::from_sdk("creating room failed", source)),
2478+ RoomCommand::CreateDirect(user_id) => client
2479+ .create_dm(&user_id)
2480+ .await
2481+ .map(|room| Some(room.room_id().to_owned()))
2482+ .map_err(|source| CommandError::from_sdk("creating direct room failed", source)),
2483+ _ => Err(CommandError::new(
2484+ CommandErrorKind::Other,
2485+ "unsupported room command",
2486+ )),
2487+ }
2488+}
2489+
2490+fn room_unknown() -> CommandError {
2491+ CommandError::new(
2492+ CommandErrorKind::RoomUnknown,
2493+ "room is not available in the local store",
2494+ )
2495+}
2496+
2497+const fn sdk_notification_mode(
2498+ mode: RoomNotificationMode,
2499+) -> matrix_sdk::notification_settings::RoomNotificationMode {
2500+ match mode {
2501+ RoomNotificationMode::AllMessages => {
2502+ matrix_sdk::notification_settings::RoomNotificationMode::AllMessages
2503+ }
2504+ RoomNotificationMode::MentionsAndKeywordsOnly => {
2505+ matrix_sdk::notification_settings::RoomNotificationMode::MentionsAndKeywordsOnly
2506+ }
2507+ RoomNotificationMode::Mute => matrix_sdk::notification_settings::RoomNotificationMode::Mute,
2508+ }
2509+}
2510+
2511+async fn execute_notification_command(
2512+ client: &Client,
2513+ command: NotificationCommand,
2514+) -> Result<(), CommandError> {
2515+ use matrix_sdk::notification_settings::{IsEncrypted, IsOneToOne};
2516+
2517+ let settings = client.notification_settings().await;
2518+ let result = match command {
2519+ NotificationCommand::SetRoomMode { room_id, mode } => {
2520+ settings
2521+ .set_room_notification_mode(&room_id, sdk_notification_mode(mode))
2522+ .await
2523+ }
2524+ NotificationCommand::ResetRoomMode(room_id) => {
2525+ settings.delete_user_defined_room_rules(&room_id).await
2526+ }
2527+ NotificationCommand::SetDefaultMode {
2528+ encrypted,
2529+ one_to_one,
2530+ mode,
2531+ } => {
2532+ settings
2533+ .set_default_room_notification_mode(
2534+ IsEncrypted::from(encrypted),
2535+ IsOneToOne::from(one_to_one),
2536+ sdk_notification_mode(mode),
2537+ )
2538+ .await
2539+ }
2540+ NotificationCommand::AddKeyword(keyword) => settings.add_keyword(keyword).await,
2541+ NotificationCommand::RemoveKeyword(keyword) => settings.remove_keyword(&keyword).await,
2542+ };
2543+ result.map_err(|source| {
2544+ CommandError::with_source(
2545+ CommandErrorKind::Server,
2546+ "updating notification settings failed",
2547+ source,
2548+ )
2549+ })
2550+}
2551+
2552+async fn execute_encryption_command(
2553+ client: &Client,
2554+ events: &EventSender,
2555+ verification: &mut VerificationController,
2556+ command: EncryptionCommand,
2557+) -> Result<(), CommandError> {
2558+ match command {
2559+ EncryptionCommand::Recover(key) => client
2560+ .encryption()
2561+ .recovery()
2562+ .recover(&key)
2563+ .await
2564+ .map_err(|source| CommandError::from_recovery("account recovery failed", source)),
2565+ EncryptionCommand::StartVerification(target) => {
2566+ verification.start(client, events.clone(), target).await
2567+ }
2568+ EncryptionCommand::Verify(action) => {
2569+ verification.apply(client, events.clone(), action).await
2570+ }
2571+ command => execute_key_backup_command(client, command).await,
2572+ }
2573+}
2574+
2575+async fn execute_key_backup_command(
2576+ client: &Client,
2577+ command: EncryptionCommand,
2578+) -> Result<(), CommandError> {
2579+ match command {
2580+ EncryptionCommand::CreateKeyBackup => client
2581+ .encryption()
2582+ .backups()
2583+ .create()
2584+ .await
2585+ .map_err(|source| CommandError::from_sdk("creating key backup failed", source)),
2586+ EncryptionCommand::EnableKeyBackup => client
2587+ .encryption()
2588+ .recovery()
2589+ .enable_backup()
2590+ .await
2591+ .map_err(|source| CommandError::from_recovery("enabling key backup failed", source)),
2592+ EncryptionCommand::DisableKeyBackup { delete_from_server } => {
2593+ let backups = client.encryption().backups();
2594+ if delete_from_server {
2595+ backups.disable_and_delete().await
2596+ } else {
2597+ backups.disable().await
2598+ }
2599+ .map_err(|source| CommandError::from_sdk("disabling key backup failed", source))
2600+ }
2601+ EncryptionCommand::RecoverKeyBackup(key) => client
2602+ .encryption()
2603+ .recovery()
2604+ .recover_and_fix_backup(&key)
2605+ .await
2606+ .map_err(|source| CommandError::from_recovery("recovering key backup failed", source)),
2607+ _ => unreachable!("only key backup commands are spawned here"),
2608+ }
2609+}
2610+
2611+async fn apply_power_level_changes(
2612+ room: &Room,
2613+ changes: PowerLevelChanges,
2614+) -> Result<(), CommandError> {
2615+ use matrix_sdk::ruma::events::{
2616+ MessageLikeEventType, StateEventType, room::power_levels::RoomPowerLevelsEventContent,
2617+ };
2618+
2619+ let mut levels = room
2620+ .power_levels()
2621+ .await
2622+ .map_err(|source| CommandError::from_sdk("reading power levels failed", source.into()))?;
2623+ if let Some(value) = changes.ban {
2624+ levels.ban = matrix_power_level(value)?;
2625+ }
2626+ if let Some(value) = changes.invite {
2627+ levels.invite = matrix_power_level(value)?;
2628+ }
2629+ if let Some(value) = changes.kick {
2630+ levels.kick = matrix_power_level(value)?;
2631+ }
2632+ if let Some(value) = changes.redact {
2633+ levels.redact = matrix_power_level(value)?;
2634+ }
2635+ if let Some(value) = changes.events_default {
2636+ levels.events_default = matrix_power_level(value)?;
2637+ }
2638+ if let Some(value) = changes.state_default {
2639+ levels.state_default = matrix_power_level(value)?;
2640+ }
2641+ if let Some(value) = changes.users_default {
2642+ levels.users_default = matrix_power_level(value)?;
2643+ }
2644+ if let Some(value) = changes.room_notification {
2645+ levels.notifications.room = matrix_power_level(value)?;
2646+ }
2647+ for (event_type, value) in [
2648+ (StateEventType::RoomName.into(), changes.room_name),
2649+ (StateEventType::RoomAvatar.into(), changes.room_avatar),
2650+ (StateEventType::RoomTopic.into(), changes.room_topic),
2651+ (StateEventType::SpaceChild.into(), changes.space_child),
2652+ (MessageLikeEventType::Beacon.into(), changes.beacon),
2653+ (StateEventType::BeaconInfo.into(), changes.beacon_info),
2654+ ] {
2655+ if let Some(value) = value {
2656+ levels.events.insert(event_type, matrix_power_level(value)?);
2657+ }
2658+ }
2659+ let content = RoomPowerLevelsEventContent::try_from(levels).map_err(|source| {
2660+ CommandError::with_source(
2661+ CommandErrorKind::Other,
2662+ "normalizing power levels failed",
2663+ source,
2664+ )
2665+ })?;
2666+ room.send_state_event(content)
2667+ .await
2668+ .map(|_| ())
2669+ .map_err(|source| CommandError::from_sdk("updating power levels failed", source))
2670+}
2671+
2672+fn matrix_power_level(value: i64) -> Result<matrix_sdk::ruma::Int, CommandError> {
2673+ value.try_into().map_err(|source| {
2674+ CommandError::with_source(
2675+ CommandErrorKind::Other,
2676+ "power level is outside the Matrix integer range",
2677+ source,
2678+ )
2679+ })
2680+}
2681+
2682+fn create_room_request(
2683+ options: CreateRoomOptions,
2684+) -> matrix_sdk::ruma::api::client::room::create_room::v3::Request {
2685+ use matrix_sdk::ruma::api::client::room::{
2686+ Visibility as SdkVisibility,
2687+ create_room::v3::{Request, RoomPreset as SdkPreset},
2688+ };
2689+ let mut request = Request::new();
2690+ request.name = options.name;
2691+ request.topic = options.topic;
2692+ request.room_alias_name = options.alias_localpart;
2693+ request.invite = options.invite;
2694+ request.is_direct = options.direct;
2695+ request.visibility = match options.visibility {
2696+ RoomVisibility::Private => SdkVisibility::Private,
2697+ RoomVisibility::Public => SdkVisibility::Public,
2698+ };
2699+ request.preset = options.preset.map(|preset| match preset {
2700+ RoomPreset::PrivateChat => SdkPreset::PrivateChat,
2701+ RoomPreset::TrustedPrivateChat => SdkPreset::TrustedPrivateChat,
2702+ RoomPreset::PublicChat => SdkPreset::PublicChat,
2703+ });
2704+ request
2705+}
2706+
2707+async fn queue_media_upload(
2708+ room: Room,
2709+ upload: AttachmentUpload,
2710+ max_media_bytes: usize,
2711+) -> Result<(), CommandError> {
2712+ let (upload, mime, mut config) = prepare_upload(upload, max_media_bytes)?;
2713+ if let TimelineId::Thread { root_event_id, .. } = &upload.timeline_id {
2714+ config.reply = Some(SdkReply {
2715+ event_id: root_event_id.clone(),
2716+ enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),
2717+ add_mentions: AddMentions::No,
2718+ });
2719+ }
2720+ room.send_queue()
2721+ .send_attachment(upload.filename, mime, upload.bytes, config)
2722+ .await
2723+ .map(|_| ())
2724+ .map_err(|source| {
2725+ CommandError::with_source(
2726+ CommandErrorKind::MediaUnavailable,
2727+ "media upload could not be queued",
2728+ source,
2729+ )
2730+ })
2731+}
2732+
2733+async fn fetch_media(
2734+ client: Client,
2735+ registry: Arc<Mutex<MediaRegistry>>,
2736+ events: EventSender,
2737+ max_media_bytes: usize,
2738+ media_id: MediaId,
2739+ size: MediaSize,
2740+) -> Result<(), CommandError> {
2741+ let media = registry
2742+ .lock()
2743+ .await
2744+ .entries
2745+ .get(&media_id)
2746+ .cloned()
2747+ .ok_or_else(|| {
2748+ CommandError::new(
2749+ CommandErrorKind::MediaUnknown,
2750+ "media is not registered in a retained timeline",
2751+ )
2752+ })?;
2753+ let (source, format, mimetype, declared_size) = match size {
2754+ MediaSize::Full => (
2755+ media.full.source,
2756+ MediaFormat::File,
2757+ media.full.mimetype,
2758+ media.full.declared_size,
2759+ ),
2760+ MediaSize::Thumbnail => {
2761+ if let Some(thumbnail) = media.thumbnail {
2762+ (
2763+ thumbnail.source,
2764+ MediaFormat::File,
2765+ thumbnail.mimetype,
2766+ thumbnail.declared_size,
2767+ )
2768+ } else if matches!(
2769+ media.kind,
2770+ crate::MediaKind::Image | crate::MediaKind::Video
2771+ ) && matches!(
2772+ &media.full.source,
2773+ matrix_sdk::ruma::events::room::MediaSource::Plain(_)
2774+ ) {
2775+ (
2776+ media.full.source,
2777+ MediaFormat::Thumbnail(MediaThumbnailSettings::new(
2778+ 800_u32.into(),
2779+ 800_u32.into(),
2780+ )),
2781+ media.full.mimetype,
2782+ None,
2783+ )
2784+ } else {
2785+ return Err(CommandError::new(
2786+ CommandErrorKind::MediaUnavailable,
2787+ "the requested media size is unavailable",
2788+ ));
2789+ }
2790+ }
2791+ };
2792+ if declared_size.is_some_and(|size| size > max_media_bytes as u64) {
2793+ return Err(CommandError::new(
2794+ CommandErrorKind::MediaTooLarge,
2795+ "media exceeds the configured byte limit",
2796+ ));
2797+ }
2798+ let request = MediaRequestParameters { source, format };
2799+ if let Some(cached) = cached_media(&client, &request).await? {
2800+ if cached.len() > max_media_bytes {
2801+ remove_oversize_media(&client, &request).await;
2802+ return Err(CommandError::new(
2803+ CommandErrorKind::MediaTooLarge,
2804+ "cached media exceeds the configured byte limit",
2805+ ));
2806+ }
2807+ let bytes = maybe_decrypt_media(cached, &request, max_media_bytes)?;
2808+ events
2809+ .send(Event::MediaReady {
2810+ media_id,
2811+ size,
2812+ bytes: Arc::from(bytes),
2813+ mimetype: mimetype.unwrap_or_else(|| "application/octet-stream".into()),
2814+ })
2815+ .await
2816+ .map_err(send_error)?;
2817+ return Ok(());
2818+ }
2819+ let bytes = download_bounded(&client, &request, max_media_bytes).await?;
2820+ cache_media(&client, &request, &bytes).await;
2821+ let bytes = maybe_decrypt_media(bytes, &request, max_media_bytes)?;
2822+ events
2823+ .send(Event::MediaReady {
2824+ media_id,
2825+ size,
2826+ bytes: Arc::from(bytes),
2827+ mimetype: mimetype.unwrap_or_else(|| "application/octet-stream".into()),
2828+ })
2829+ .await
2830+ .map_err(send_error)
2831+}
2832+async fn cached_media(
2833+ client: &Client,
2834+ request: &MediaRequestParameters,
2835+) -> Result<Option<Vec<u8>>, CommandError> {
2836+ client
2837+ .media_store()
2838+ .lock()
2839+ .await
2840+ .map_err(|source| {
2841+ CommandError::with_source(
2842+ CommandErrorKind::Store,
2843+ "reading media cache failed",
2844+ source,
2845+ )
2846+ })?
2847+ .get_media_content(request)
2848+ .await
2849+ .map_err(|source| {
2850+ CommandError::with_source(
2851+ CommandErrorKind::Store,
2852+ "reading media cache failed",
2853+ source,
2854+ )
2855+ })
2856+}
2857+
2858+async fn remove_oversize_media(client: &Client, request: &MediaRequestParameters) {
2859+ if let Ok(store) = client.media_store().lock().await {
2860+ let _ = store.remove_media_content(request).await;
2861+ }
2862+}
2863+
2864+async fn cache_media(client: &Client, request: &MediaRequestParameters, bytes: &[u8]) {
2865+ if let Ok(store) = client.media_store().lock().await {
2866+ let _ = store
2867+ .add_media_content(
2868+ request,
2869+ bytes.to_vec(),
2870+ matrix_sdk::media::store::IgnoreMediaRetentionPolicy::No,
2871+ )
2872+ .await;
2873+ }
2874+}
2875+
2876+fn maybe_decrypt_media(
2877+ bytes: Vec<u8>,
2878+ request: &MediaRequestParameters,
2879+ max_media_bytes: usize,
2880+) -> Result<Vec<u8>, CommandError> {
2881+ let matrix_sdk::ruma::events::room::MediaSource::Encrypted(file) = &request.source else {
2882+ return Ok(bytes);
2883+ };
2884+ let info: MediaEncryptionInfo = (**file).clone().into();
2885+ let mut cursor = std::io::Cursor::new(bytes);
2886+ let mut decryptor = AttachmentDecryptor::new(&mut cursor, info).map_err(|source| {
2887+ CommandError::with_source(
2888+ CommandErrorKind::MediaUnavailable,
2889+ "media decryption failed",
2890+ source,
2891+ )
2892+ })?;
2893+ let mut decrypted = Vec::new();
2894+ let mut chunk = [0_u8; 8192];
2895+ loop {
2896+ match decryptor.read(&mut chunk) {
2897+ Ok(0) => break,
2898+ Ok(read) => {
2899+ if read > max_media_bytes.saturating_sub(decrypted.len()) {
2900+ return Err(CommandError::new(
2901+ CommandErrorKind::MediaTooLarge,
2902+ "decrypted media exceeds the configured byte limit",
2903+ ));
2904+ }
2905+ decrypted.extend_from_slice(&chunk[..read]);
2906+ }
2907+ Err(source) => {
2908+ return Err(CommandError::with_source(
2909+ CommandErrorKind::MediaUnavailable,
2910+ "media decryption failed",
2911+ source,
2912+ ));
2913+ }
2914+ }
2915+ }
2916+ Ok(decrypted)
2917+}
2918+
2919+async fn download_bounded(
2920+ client: &Client,
2921+ request: &MediaRequestParameters,
2922+ max_media_bytes: usize,
2923+) -> Result<Vec<u8>, CommandError> {
2924+ if is_local_media(&request.source) {
2925+ return Err(CommandError::new(
2926+ CommandErrorKind::MediaUnavailable,
2927+ "local media is not in the media cache",
2928+ ));
2929+ }
2930+ let http_request = media_http_request(client, request).await?;
2931+ let http_client = client.http_client().clone();
2932+ let request = http_client
2933+ .execute(request_from_http(http_request)?)
2934+ .await
2935+ .map_err(|source| {
2936+ CommandError::with_source(CommandErrorKind::Network, "media download failed", source)
2937+ })?;
2938+ let status = request.status();
2939+ if status == matrix_sdk::reqwest::StatusCode::UNAUTHORIZED {
2940+ client.refresh_access_token().await.map_err(|source| {
2941+ CommandError::from_refresh_token("refreshing media access token failed", source)
2942+ })?;
2943+ return Err(CommandError::new(
2944+ CommandErrorKind::Unauthenticated,
2945+ "media download was not authorized",
2946+ ));
2947+ }
2948+ if !status.is_success() {
2949+ return Err(CommandError::new(
2950+ CommandErrorKind::MediaUnavailable,
2951+ format!("media download failed with status {status}"),
2952+ ));
2953+ }
2954+ if request
2955+ .content_length()
2956+ .is_some_and(|length| length > max_media_bytes as u64)
2957+ {
2958+ return Err(CommandError::new(
2959+ CommandErrorKind::MediaTooLarge,
2960+ "media exceeds the configured byte limit",
2961+ ));
2962+ }
2963+ let mut stream = request.bytes_stream();
2964+ let mut bytes = Vec::new();
2965+ while let Some(chunk) = stream.next().await {
2966+ let chunk = chunk.map_err(|source| {
2967+ CommandError::with_source(CommandErrorKind::Network, "media download failed", source)
2968+ })?;
2969+ if chunk.len() > max_media_bytes.saturating_sub(bytes.len()) {
2970+ return Err(CommandError::new(
2971+ CommandErrorKind::MediaTooLarge,
2972+ "downloaded media exceeds the configured byte limit",
2973+ ));
2974+ }
2975+ bytes.extend_from_slice(&chunk);
2976+ }
2977+ Ok(bytes)
2978+}
2979+
2980+fn is_local_media(source: &matrix_sdk::ruma::events::room::MediaSource) -> bool {
2981+ let uri = match source {
2982+ matrix_sdk::ruma::events::room::MediaSource::Plain(uri) => uri,
2983+ matrix_sdk::ruma::events::room::MediaSource::Encrypted(file) => &file.url,
2984+ };
2985+ let Ok(server) = uri.server_name() else {
2986+ return false;
2987+ };
2988+ server.as_str() == "send-queue.localhost"
2989+}
2990+
2991+async fn media_http_request(
2992+ client: &Client,
2993+ request: &MediaRequestParameters,
2994+) -> Result<http::Request<Vec<u8>>, CommandError> {
2995+ use matrix_sdk::ruma::api::client::{authenticated_media, media};
2996+
2997+ let versions = client.supported_versions().await.map_err(|source| {
2998+ CommandError::with_source(
2999+ CommandErrorKind::Network,
3000+ "reading supported server versions failed",
3001+ source,
3002+ )
3003+ })?;
3004+ let use_auth =
3005+ authenticated_media::get_content::v1::Request::PATH_BUILDER.is_supported(&versions);
3006+ let base = client.homeserver().to_string();
3007+ let token = client.access_token();
3008+ let build = |request: http::Request<Vec<u8>>| -> Result<http::Request<Vec<u8>>, CommandError> {
3009+ let mut request = request;
3010+ if let Some(token) = token.as_deref() {
3011+ request.headers_mut().insert(
3012+ http::header::AUTHORIZATION,
3013+ format!("Bearer {token}").parse().map_err(|_| {
3014+ CommandError::new(
3015+ CommandErrorKind::Unauthenticated,
3016+ "stored access token is invalid",
3017+ )
3018+ })?,
3019+ );
3020+ }
3021+ Ok(request)
3022+ };
3023+ match &request.source {
3024+ matrix_sdk::ruma::events::room::MediaSource::Encrypted(file) => {
3025+ if use_auth {
3026+ let endpoint = authenticated_media::get_content::v1::Request::from_uri(&file.url)
3027+ .map_err(|source| {
3028+ CommandError::with_source(
3029+ CommandErrorKind::Other,
3030+ "media URI is invalid",
3031+ source,
3032+ )
3033+ })?;
3034+ endpoint
3035+ .try_into_http_request::<Vec<u8>>(
3036+ &base,
3037+ SendAccessToken::IfRequired(token.as_deref().unwrap_or("")),
3038+ Cow::Borrowed(&versions),
3039+ )
3040+ .map_err(|_| {
3041+ CommandError::new(
3042+ CommandErrorKind::Unauthenticated,
3043+ "media request could not be authorized",
3044+ )
3045+ })
3046+ .and_then(build)
3047+ } else {
3048+ #[allow(deprecated)]
3049+ let endpoint =
3050+ media::get_content::v3::Request::from_url(&file.url).map_err(|source| {
3051+ CommandError::with_source(
3052+ CommandErrorKind::Other,
3053+ "media URI is invalid",
3054+ source,
3055+ )
3056+ })?;
3057+ endpoint
3058+ .try_into_http_request::<Vec<u8>>(
3059+ &base,
3060+ SendAccessToken::None,
3061+ Cow::Borrowed(&versions),
3062+ )
3063+ .map_err(|_| {
3064+ CommandError::new(CommandErrorKind::Other, "media request is invalid")
3065+ })
3066+ .and_then(build)
3067+ }
3068+ }
3069+ matrix_sdk::ruma::events::room::MediaSource::Plain(uri) => {
3070+ if let MediaFormat::Thumbnail(settings) = &request.format {
3071+ if use_auth {
3072+ let mut endpoint =
3073+ authenticated_media::get_content_thumbnail::v1::Request::from_uri(
3074+ uri,
3075+ settings.width,
3076+ settings.height,
3077+ )
3078+ .map_err(|source| {
3079+ CommandError::with_source(
3080+ CommandErrorKind::Other,
3081+ "media URI is invalid",
3082+ source,
3083+ )
3084+ })?;
3085+ endpoint.method = Some(settings.method.clone());
3086+ endpoint.animated = Some(settings.animated);
3087+ endpoint
3088+ .try_into_http_request::<Vec<u8>>(
3089+ &base,
3090+ SendAccessToken::IfRequired(token.as_deref().unwrap_or("")),
3091+ Cow::Borrowed(&versions),
3092+ )
3093+ .map_err(|_| {
3094+ CommandError::new(
3095+ CommandErrorKind::Unauthenticated,
3096+ "media request could not be authorized",
3097+ )
3098+ })
3099+ .and_then(build)
3100+ } else {
3101+ #[allow(deprecated)]
3102+ let endpoint = {
3103+ let mut endpoint = media::get_content_thumbnail::v3::Request::from_url(
3104+ uri,
3105+ settings.width,
3106+ settings.height,
3107+ )
3108+ .map_err(|source| {
3109+ CommandError::with_source(
3110+ CommandErrorKind::Other,
3111+ "media URI is invalid",
3112+ source,
3113+ )
3114+ })?;
3115+ endpoint.method = Some(settings.method.clone());
3116+ endpoint.animated = Some(settings.animated);
3117+ endpoint
3118+ };
3119+ endpoint
3120+ .try_into_http_request::<Vec<u8>>(
3121+ &base,
3122+ SendAccessToken::None,
3123+ Cow::Borrowed(&versions),
3124+ )
3125+ .map_err(|_| {
3126+ CommandError::new(CommandErrorKind::Other, "media request is invalid")
3127+ })
3128+ .and_then(build)
3129+ }
3130+ } else if use_auth {
3131+ let endpoint = authenticated_media::get_content::v1::Request::from_uri(uri)
3132+ .map_err(|source| {
3133+ CommandError::with_source(
3134+ CommandErrorKind::Other,
3135+ "media URI is invalid",
3136+ source,
3137+ )
3138+ })?;
3139+ endpoint
3140+ .try_into_http_request::<Vec<u8>>(
3141+ &base,
3142+ SendAccessToken::IfRequired(token.as_deref().unwrap_or("")),
3143+ Cow::Borrowed(&versions),
3144+ )
3145+ .map_err(|_| {
3146+ CommandError::new(
3147+ CommandErrorKind::Unauthenticated,
3148+ "media request could not be authorized",
3149+ )
3150+ })
3151+ .and_then(build)
3152+ } else {
3153+ #[allow(deprecated)]
3154+ let endpoint =
3155+ media::get_content::v3::Request::from_url(uri).map_err(|source| {
3156+ CommandError::with_source(
3157+ CommandErrorKind::Other,
3158+ "media URI is invalid",
3159+ source,
3160+ )
3161+ })?;
3162+ endpoint
3163+ .try_into_http_request::<Vec<u8>>(
3164+ &base,
3165+ SendAccessToken::None,
3166+ Cow::Borrowed(&versions),
3167+ )
3168+ .map_err(|_| {
3169+ CommandError::new(CommandErrorKind::Other, "media request is invalid")
3170+ })
3171+ .and_then(build)
3172+ }
3173+ }
3174+ }
3175+}
3176+
3177+fn request_from_http(
3178+ request: http::Request<Vec<u8>>,
3179+) -> Result<matrix_sdk::reqwest::Request, CommandError> {
3180+ let (parts, _) = request.into_parts();
3181+ let mut builder = matrix_sdk::reqwest::Client::new().get(parts.uri.to_string());
3182+ for (name, value) in parts.headers.iter() {
3183+ builder = builder.header(name.as_str(), value.as_bytes());
3184+ }
3185+ builder
3186+ .build()
3187+ .map_err(|_| CommandError::new(CommandErrorKind::Network, "media request is invalid"))
3188+}
3189+
3190+impl RoomActor {
3191+ async fn run(mut self, current_user_id: Option<ruma::OwnedUserId>) {
3192+ let timeline = match self
3193+ .room
3194+ .timeline_builder()
3195+ .with_focus(TimelineFocus::Live {
3196+ hide_threaded_events: true,
3197+ })
3198+ .track_read_marker_and_receipts(TimelineReadReceiptTracking::AllEvents)
3199+ .build()
3200+ .await
3201+ {
3202+ Ok(timeline) => timeline,
3203+ Err(error) => {
3204+ tracing::warn!(error = %error, room_id = %self.room_id, "room actor could not build its timeline");
3205+ let message = format!("room timeline build failed: {error}");
3206+ for id in self.open_ids.drain(..) {
3207+ let _ = emit_command_result(
3208+ &self.events,
3209+ id,
3210+ CommandKind::Room,
3211+ Err(CommandError::new(
3212+ CommandErrorKind::Timeline,
3213+ message.clone(),
3214+ )),
3215+ )
3216+ .await;
3217+ }
3218+ self.receiver.close();
3219+ while let Some(request) = self.receiver.recv().await {
3220+ let (id, command) = match request {
3221+ RoomActorRequest::Opened { id } | RoomActorRequest::Close { id } => {
3222+ (id, CommandKind::Room)
3223+ }
3224+ RoomActorRequest::Execute { id, .. } => (id, CommandKind::Timeline),
3225+ };
3226+ let _ = emit_command_result(
3227+ &self.events,
3228+ id,
3229+ command,
3230+ Err(CommandError::new(
3231+ CommandErrorKind::Timeline,
3232+ "room timeline build failed",
3233+ )),
3234+ )
3235+ .await;
3236+ }
3237+ return;
3238+ }
3239+ };
3240+ let room_timeline_id = TimelineId::Room(self.room_id.clone());
3241+ let mut room_timeline = match TimelineSession::start(
3242+ room_timeline_id,
3243+ timeline,
3244+ self.events.clone(),
3245+ self.media.clone(),
3246+ current_user_id.clone(),
3247+ self.initial_history_limit,
3248+ )
3249+ .await
3250+ {
3251+ Ok(timeline) => timeline,
3252+ Err(_) => return,
3253+ };
3254+ let _ = self.engine_events.send(RoomActorEvent::Ready {
3255+ room_id: self.room_id.clone(),
3256+ generation: self.generation,
3257+ });
3258+ for id in self.open_ids.drain(..) {
3259+ if emit_command_result(&self.events, id, CommandKind::Room, Ok(()))
3260+ .await
3261+ .is_err()
3262+ {
3263+ return;
3264+ }
3265+ }
3266+
3267+ let (typing_guard, mut typing_rx) = self.room.subscribe_to_typing_notifications();
3268+ let typing_sender = self.events.clone();
3269+ let typing_room_id = self.room_id.clone();
3270+ let mut room_tasks = JoinSet::new();
3271+ room_tasks.spawn(async move {
3272+ loop {
3273+ match typing_rx.recv().await {
3274+ Ok(users) => {
3275+ let state = TypingState {
3276+ room_id: typing_room_id.clone(),
3277+ users,
3278+ };
3279+ if typing_sender
3280+ .send(Event::TypingChanged(state))
3281+ .await
3282+ .is_err()
3283+ {
3284+ tracing::debug!(
3285+ room_id = %typing_room_id,
3286+ "typing watcher stopped because the event receiver closed"
3287+ );
3288+ break;
3289+ }
3290+ }
3291+ Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
3292+ tracing::warn!(room_id = %typing_room_id, skipped, "typing watcher lagged");
3293+ }
3294+ Err(tokio::sync::broadcast::error::RecvError::Closed) => {
3295+ tracing::debug!(room_id = %typing_room_id, "typing watcher stopped");
3296+ break;
3297+ }
3298+ }
3299+ }
3300+ });
3301+
3302+ enum QueuedThreadCommand {
3303+ Open {
3304+ id: CommandId,
3305+ },
3306+ Execute {
3307+ id: CommandId,
3308+ command: TimelineCommand,
3309+ },
3310+ }
3311+
3312+ struct OpeningThread {
3313+ generation: u64,
3314+ abort: AbortHandle,
3315+ task_id: TaskId,
3316+ queued: VecDeque<QueuedThreadCommand>,
3317+ }
3318+
3319+ let mut thread_timelines = HashMap::<OwnedEventId, TimelineSession>::new();
3320+ let mut opening_threads = HashMap::<OwnedEventId, OpeningThread>::new();
3321+ let mut thread_task_ids = HashMap::<TaskId, OwnedEventId>::new();
3322+ let mut thread_open_tasks = JoinSet::new();
3323+ let mut next_thread_generation = 0_u64;
3324+ let mut close_id = None;
3325+ let thread_queue_cap = self.receiver.max_capacity();
3326+ let mut queued_thread_count = 0;
3327+
3328+ 'actor: loop {
3329+ tokio::select! {
3330+ request = self.receiver.recv() => {
3331+ let Some(request) = request else {
3332+ tracing::debug!(room_id = %self.room_id, "room actor stopped because its request channel closed");
3333+ break;
3334+ };
3335+ match request {
3336+ RoomActorRequest::Opened { id } => {
3337+ if emit_command_result(
3338+ &self.events,
3339+ id,
3340+ CommandKind::Room,
3341+ Ok(()),
3342+ )
3343+ .await
3344+ .is_err()
3345+ {
3346+ break;
3347+ }
3348+ }
3349+ RoomActorRequest::Execute { id, command } => {
3350+ match command {
3351+ TimelineCommand::OpenThread { root_event_id, .. } => {
3352+ if thread_timelines.contains_key(&root_event_id) {
3353+ if emit_command_result(
3354+ &self.events,
3355+ id,
3356+ CommandKind::Timeline,
3357+ Ok(()),
3358+ )
3359+ .await
3360+ .is_err()
3361+ {
3362+ break 'actor;
3363+ }
3364+ } else if let Some(opening) =
3365+ opening_threads.get_mut(&root_event_id)
3366+ {
3367+ if queued_thread_count >= thread_queue_cap {
3368+ if emit_command_result(
3369+ &self.events,
3370+ id,
3371+ CommandKind::Timeline,
3372+ Err(CommandError::new(
3373+ CommandErrorKind::QueueFull,
3374+ "thread command queue is full",
3375+ )),
3376+ )
3377+ .await
3378+ .is_err()
3379+ {
3380+ break 'actor;
3381+ }
3382+ } else {
3383+ opening
3384+ .queued
3385+ .push_back(QueuedThreadCommand::Open { id });
3386+ queued_thread_count += 1;
3387+ }
3388+ } else if queued_thread_count >= thread_queue_cap {
3389+ if emit_command_result(
3390+ &self.events,
3391+ id,
3392+ CommandKind::Timeline,
3393+ Err(CommandError::new(
3394+ CommandErrorKind::QueueFull,
3395+ "thread command queue is full",
3396+ )),
3397+ )
3398+ .await
3399+ .is_err()
3400+ {
3401+ break 'actor;
3402+ }
3403+ } else {
3404+ let generation = next_thread_generation;
3405+ next_thread_generation =
3406+ next_thread_generation.wrapping_add(1);
3407+ let task_root = root_event_id.clone();
3408+ let room = self.room.clone();
3409+ let abort = thread_open_tasks.spawn(async move {
3410+ let result = room
3411+ .timeline_builder()
3412+ .with_focus(TimelineFocus::Thread {
3413+ root_event_id: root_event_id.clone(),
3414+ })
3415+ .track_read_marker_and_receipts(
3416+ TimelineReadReceiptTracking::AllEvents,
3417+ )
3418+ .build()
3419+ .await;
3420+ (root_event_id, generation, result)
3421+ });
3422+ let task_id = abort.id();
3423+ thread_task_ids.insert(task_id, task_root.clone());
3424+ opening_threads.insert(
3425+ task_root,
3426+ OpeningThread {
3427+ generation,
3428+ abort,
3429+ task_id,
3430+ queued: VecDeque::from([
3431+ QueuedThreadCommand::Open { id },
3432+ ]),
3433+ },
3434+ );
3435+ queued_thread_count += 1;
3436+ }
3437+ }
3438+ TimelineCommand::CloseThread { root_event_id, .. } => {
3439+ if let Some(timeline) = thread_timelines.remove(&root_event_id) {
3440+ timeline
3441+ .close(
3442+ CommandErrorKind::TimelineNotOpen,
3443+ "thread closed while pagination was in progress",
3444+ )
3445+ .await;
3446+ if emit_command_result(
3447+ &self.events,
3448+ id,
3449+ CommandKind::Timeline,
3450+ Ok(()),
3451+ )
3452+ .await
3453+ .is_err()
3454+ {
3455+ break 'actor;
3456+ }
3457+ } else if let Some(opening) =
3458+ opening_threads.remove(&root_event_id)
3459+ {
3460+ thread_task_ids.remove(&opening.task_id);
3461+ queued_thread_count -= opening.queued.len();
3462+ opening.abort.abort();
3463+ for queued in opening.queued {
3464+ let queued_id = match queued {
3465+ QueuedThreadCommand::Open { id } => id,
3466+ QueuedThreadCommand::Execute { id, .. } => id,
3467+ };
3468+ let _ = emit_command_result(
3469+ &self.events,
3470+ queued_id,
3471+ CommandKind::Timeline,
3472+ Err(CommandError::new(
3473+ CommandErrorKind::TimelineNotOpen,
3474+ "thread was closed before it finished opening",
3475+ )),
3476+ )
3477+ .await;
3478+ }
3479+ if emit_command_result(
3480+ &self.events,
3481+ id,
3482+ CommandKind::Timeline,
3483+ Ok(()),
3484+ )
3485+ .await
3486+ .is_err()
3487+ {
3488+ break 'actor;
3489+ }
3490+ } else if emit_command_result(
3491+ &self.events,
3492+ id,
3493+ CommandKind::Timeline,
3494+ Err(CommandError::new(
3495+ CommandErrorKind::TimelineNotOpen,
3496+ "thread timeline is not open",
3497+ )),
3498+ )
3499+ .await
3500+ .is_err()
3501+ {
3502+ break 'actor;
3503+ }
3504+ }
3505+ TimelineCommand::Paginate { timeline_id, limit } => {
3506+ match timeline_id {
3507+ TimelineId::Room(_) => {
3508+ room_timeline.paginate(limit, Some(id)).await;
3509+ }
3510+ TimelineId::Thread { room_id, root_event_id } => {
3511+ if let Some(timeline) =
3512+ thread_timelines.get_mut(&root_event_id)
3513+ {
3514+ timeline.paginate(limit, Some(id)).await;
3515+ } else if let Some(opening) =
3516+ opening_threads.get_mut(&root_event_id)
3517+ {
3518+ if queued_thread_count >= thread_queue_cap {
3519+ if emit_command_result(
3520+ &self.events,
3521+ id,
3522+ CommandKind::Timeline,
3523+ Err(CommandError::new(
3524+ CommandErrorKind::QueueFull,
3525+ "thread command queue is full",
3526+ )),
3527+ )
3528+ .await
3529+ .is_err()
3530+ {
3531+ break 'actor;
3532+ }
3533+ } else {
3534+ opening.queued.push_back(
3535+ QueuedThreadCommand::Execute {
3536+ id,
3537+ command:
3538+ TimelineCommand::Paginate {
3539+ timeline_id:
3540+ TimelineId::Thread {
3541+ room_id,
3542+ root_event_id,
3543+ },
3544+ limit,
3545+ },
3546+ },
3547+ );
3548+ queued_thread_count += 1;
3549+ }
3550+ } else if emit_command_result(
3551+ &self.events,
3552+ id,
3553+ CommandKind::Timeline,
3554+ Err(CommandError::new(
3555+ CommandErrorKind::TimelineNotOpen,
3556+ "timeline is not open",
3557+ )),
3558+ )
3559+ .await
3560+ .is_err()
3561+ {
3562+ break 'actor;
3563+ }
3564+ }
3565+ }
3566+ }
3567+ command => {
3568+ let thread_target = match command
3569+ .timeline_id()
3570+ .expect("non-lifecycle timeline command has a timeline id")
3571+ {
3572+ TimelineId::Room(_) => None,
3573+ TimelineId::Thread { root_event_id, .. } => {
3574+ Some(root_event_id.clone())
3575+ }
3576+ };
3577+ match thread_target {
3578+ None => {
3579+ let result = execute_timeline_command(
3580+ &mut room_timeline,
3581+ command,
3582+ None,
3583+ )
3584+ .await;
3585+ if emit_command_result(
3586+ &self.events,
3587+ id,
3588+ CommandKind::Timeline,
3589+ result,
3590+ )
3591+ .await
3592+ .is_err()
3593+ {
3594+ tracing::debug!(room_id = %self.room_id, "room actor stopped because the event receiver closed");
3595+ break;
3596+ }
3597+ }
3598+ Some(root_event_id) => {
3599+ if let Some(timeline) =
3600+ thread_timelines.get_mut(&root_event_id)
3601+ {
3602+ let thread_root =
3603+ timeline.id.thread_root().cloned();
3604+ let result = execute_timeline_command(
3605+ timeline,
3606+ command,
3607+ thread_root.as_ref(),
3608+ )
3609+ .await;
3610+ if emit_command_result(
3611+ &self.events,
3612+ id,
3613+ CommandKind::Timeline,
3614+ result,
3615+ )
3616+ .await
3617+ .is_err()
3618+ {
3619+ tracing::debug!(room_id = %self.room_id, "room actor stopped because the event receiver closed");
3620+ break;
3621+ }
3622+ } else if let Some(opening) =
3623+ opening_threads.get_mut(&root_event_id)
3624+ {
3625+ if queued_thread_count >= thread_queue_cap {
3626+ if emit_command_result(
3627+ &self.events,
3628+ id,
3629+ CommandKind::Timeline,
3630+ Err(CommandError::new(
3631+ CommandErrorKind::QueueFull,
3632+ "thread command queue is full",
3633+ )),
3634+ )
3635+ .await
3636+ .is_err()
3637+ {
3638+ break 'actor;
3639+ }
3640+ } else {
3641+ opening.queued.push_back(
3642+ QueuedThreadCommand::Execute { id, command },
3643+ );
3644+ queued_thread_count += 1;
3645+ }
3646+ } else if emit_command_result(
3647+ &self.events,
3648+ id,
3649+ CommandKind::Timeline,
3650+ Err(CommandError::new(
3651+ CommandErrorKind::TimelineNotOpen,
3652+ "timeline is not open",
3653+ )),
3654+ )
3655+ .await
3656+ .is_err()
3657+ {
3658+ tracing::debug!(room_id = %self.room_id, "room actor stopped because the event receiver closed");
3659+ break;
3660+ }
3661+ }
3662+ }
3663+ }
3664+ }
3665+ }
3666+ RoomActorRequest::Close { id } => {
3667+ close_id = Some(id);
3668+ break 'actor;
3669+ }
3670+ }
3671+ }
3672+ completed = thread_open_tasks.join_next(), if !thread_open_tasks.is_empty() => {
3673+ match completed {
3674+ Some(Ok((root_event_id, generation, result))) => {
3675+ if opening_threads
3676+ .get(&root_event_id)
3677+ .is_none_or(|opening| opening.generation != generation)
3678+ {
3679+ continue;
3680+ }
3681+ let opening = opening_threads
3682+ .remove(&root_event_id)
3683+ .expect("thread generation was checked");
3684+ thread_task_ids.remove(&opening.task_id);
3685+ queued_thread_count -= opening.queued.len();
3686+ match result {
3687+ Ok(timeline) => {
3688+ let timeline_id = TimelineId::thread(
3689+ self.room_id.clone(),
3690+ root_event_id.clone(),
3691+ );
3692+ match TimelineSession::start(
3693+ timeline_id,
3694+ timeline,
3695+ self.events.clone(),
3696+ self.media.clone(),
3697+ current_user_id.clone(),
3698+ self.initial_history_limit,
3699+ )
3700+ .await
3701+ {
3702+ Ok(session) => {
3703+ thread_timelines
3704+ .insert(root_event_id.clone(), session);
3705+ let timeline = thread_timelines
3706+ .get_mut(&root_event_id)
3707+ .expect("thread session was inserted");
3708+ for queued in opening.queued {
3709+ match queued {
3710+ QueuedThreadCommand::Open { id } => {
3711+ if emit_command_result(
3712+ &self.events,
3713+ id,
3714+ CommandKind::Timeline,
3715+ Ok(()),
3716+ )
3717+ .await
3718+ .is_err()
3719+ {
3720+ break 'actor;
3721+ }
3722+ }
3723+ QueuedThreadCommand::Execute {
3724+ id,
3725+ command,
3726+ } => match command {
3727+ TimelineCommand::Paginate {
3728+ limit,
3729+ ..
3730+ } => {
3731+ timeline
3732+ .paginate(limit, Some(id))
3733+ .await;
3734+ }
3735+ command => {
3736+ let thread_root = timeline
3737+ .id
3738+ .thread_root()
3739+ .cloned();
3740+ let result =
3741+ execute_timeline_command(
3742+ timeline,
3743+ command,
3744+ thread_root.as_ref(),
3745+ )
3746+ .await;
3747+ if emit_command_result(
3748+ &self.events,
3749+ id,
3750+ CommandKind::Timeline,
3751+ result,
3752+ )
3753+ .await
3754+ .is_err()
3755+ {
3756+ break 'actor;
3757+ }
3758+ }
3759+ },
3760+ }
3761+ }
3762+ }
3763+ Err(_) => break 'actor,
3764+ }
3765+ }
3766+ Err(error) => {
3767+ tracing::warn!(error = %error, room_id = %self.room_id, %root_event_id, "thread timeline build failed");
3768+ let message = format!("thread timeline build failed: {error}");
3769+ for queued in opening.queued {
3770+ let queued_id = match queued {
3771+ QueuedThreadCommand::Open { id } => id,
3772+ QueuedThreadCommand::Execute { id, .. } => id,
3773+ };
3774+ if emit_command_result(
3775+ &self.events,
3776+ queued_id,
3777+ CommandKind::Timeline,
3778+ Err(CommandError::new(
3779+ CommandErrorKind::Timeline,
3780+ message.clone(),
3781+ )),
3782+ )
3783+ .await
3784+ .is_err()
3785+ {
3786+ break 'actor;
3787+ }
3788+ }
3789+ }
3790+ }
3791+ }
3792+ Some(Err(error)) => {
3793+ if let Some(root_event_id) = thread_task_ids.remove(&error.id()) {
3794+ tracing::warn!(error = %error, room_id = %self.room_id, %root_event_id, "thread open task failed");
3795+ if let Some(opening) = opening_threads.remove(&root_event_id) {
3796+ queued_thread_count -= opening.queued.len();
3797+ for queued in opening.queued {
3798+ let queued_id = match queued {
3799+ QueuedThreadCommand::Open { id } => id,
3800+ QueuedThreadCommand::Execute { id, .. } => id,
3801+ };
3802+ if emit_command_result(
3803+ &self.events,
3804+ queued_id,
3805+ CommandKind::Timeline,
3806+ Err(CommandError::new(
3807+ CommandErrorKind::Timeline,
3808+ "thread timeline build task failed",
3809+ )),
3810+ )
3811+ .await
3812+ .is_err()
3813+ {
3814+ break 'actor;
3815+ }
3816+ }
3817+ }
3818+ } else {
3819+ tracing::debug!(error = %error, room_id = %self.room_id, "thread open task was aborted");
3820+ }
3821+ }
3822+ None => {}
3823+ }
3824+ }
3825+ completed = room_tasks.join_next(), if !room_tasks.is_empty() => {
3826+ match completed {
3827+ Some(Err(error)) => {
3828+ tracing::warn!(error = %error, room_id = %self.room_id, "room actor task failed");
3829+ }
3830+ Some(Ok(())) => {
3831+ tracing::warn!(room_id = %self.room_id, "room actor task stopped unexpectedly");
3832+ }
3833+ None => {}
3834+ }
3835+ }
3836+ }
3837+ }
3838+
3839+ drop(typing_guard);
3840+ room_tasks.abort_all();
3841+ while room_tasks.join_next().await.is_some() {}
3842+ thread_task_ids.clear();
3843+ let pending_opens = opening_threads
3844+ .drain()
3845+ .flat_map(|(_, opening)| opening.queued)
3846+ .collect::<Vec<_>>();
3847+ for queued in pending_opens {
3848+ let queued_id = match queued {
3849+ QueuedThreadCommand::Open { id } => id,
3850+ QueuedThreadCommand::Execute { id, .. } => id,
3851+ };
3852+ let _ = emit_command_result(
3853+ &self.events,
3854+ queued_id,
3855+ CommandKind::Timeline,
3856+ Err(CommandError::new(
3857+ CommandErrorKind::RoomNotOpen,
3858+ "room closed while thread timeline was opening",
3859+ )),
3860+ )
3861+ .await;
3862+ }
3863+ thread_open_tasks.abort_all();
3864+ while thread_open_tasks.join_next().await.is_some() {}
3865+ for (_, timeline) in thread_timelines {
3866+ timeline
3867+ .close(
3868+ CommandErrorKind::RoomNotOpen,
3869+ "room closed while thread pagination was in progress",
3870+ )
3871+ .await;
3872+ }
3873+ room_timeline
3874+ .close(
3875+ CommandErrorKind::RoomNotOpen,
3876+ "room closed while pagination was in progress",
3877+ )
3878+ .await;
3879+ if let Some(id) = close_id
3880+ && emit_command_result(&self.events, id, CommandKind::Room, Ok(()))
3881+ .await
3882+ .is_err()
3883+ {
3884+ tracing::debug!(room_id = %self.room_id, "room close acknowledgement was not delivered");
3885+ }
3886+ }
3887+}
3888+
3889+async fn execute_timeline_command(
3890+ session: &mut TimelineSession,
3891+ command: TimelineCommand,
3892+ thread_root: Option<&OwnedEventId>,
3893+) -> Result<(), CommandError> {
3894+ let timeline = &session.timeline;
3895+ match command {
3896+ TimelineCommand::OpenThread { .. } | TimelineCommand::CloseThread { .. } => {
3897+ Err(CommandError::new(
3898+ CommandErrorKind::Other,
3899+ "thread lifecycle command was misrouted",
3900+ ))
3901+ }
3902+ TimelineCommand::Resync { .. } => session.resync().await.map_err(|_| {
3903+ CommandError::new(
3904+ CommandErrorKind::Timeline,
3905+ "timeline resync could not emit a reset",
3906+ )
3907+ }),
3908+ TimelineCommand::Paginate { .. } => Ok(()),
3909+ TimelineCommand::Send { content, .. } => {
3910+ let body = match &content {
3911+ MessageContent::Text { content, .. }
3912+ | MessageContent::Emote { content, .. }
3913+ | MessageContent::Reply { content, .. } => &content.body,
3914+ };
3915+ if body.trim().is_empty() {
3916+ return Err(CommandError::new(
3917+ CommandErrorKind::EmptyBody,
3918+ "message body is empty",
3919+ ));
3920+ }
3921+ match content {
3922+ MessageContent::Text { content, mentions } => timeline
3923+ .send(
3924+ message_content(content, mentions, false)
3925+ .with_relation(None)
3926+ .into(),
3927+ )
3928+ .await
3929+ .map(|_| ())
3930+ .map_err(timeline_error),
3931+ MessageContent::Emote { content, mentions } => timeline
3932+ .send(
3933+ message_content(content, mentions, true)
3934+ .with_relation(None)
3935+ .into(),
3936+ )
3937+ .await
3938+ .map(|_| ())
3939+ .map_err(timeline_error),
3940+ MessageContent::Reply {
3941+ event_id,
3942+ content,
3943+ mentions,
3944+ notify_replied_to,
3945+ } => {
3946+ let content = timeline
3947+ .room()
3948+ .make_reply_event(
3949+ message_content(content, mentions, false),
3950+ SdkReply {
3951+ event_id,
3952+ enforce_thread: if thread_root.is_some() {
3953+ EnforceThread::Threaded(ReplyWithinThread::Yes)
3954+ } else {
3955+ EnforceThread::MaybeThreaded
3956+ },
3957+ add_mentions: if notify_replied_to {
3958+ AddMentions::Yes
3959+ } else {
3960+ AddMentions::No
3961+ },
3962+ },
3963+ )
3964+ .await
3965+ .map_err(|source| {
3966+ CommandError::with_source(
3967+ CommandErrorKind::Timeline,
3968+ "reply operation failed",
3969+ source,
3970+ )
3971+ })?;
3972+ if let Some(thread_root) = thread_root
3973+ && !matches!(
3974+ content.relates_to.as_ref(),
3975+ Some(Relation::Thread(thread)) if &thread.event_id == thread_root
3976+ )
3977+ {
3978+ return Err(CommandError::new(
3979+ CommandErrorKind::Timeline,
3980+ "reply target does not belong to the selected thread",
3981+ ));
3982+ }
3983+ timeline
3984+ .send(content.into())
3985+ .await
3986+ .map(|_| ())
3987+ .map_err(timeline_error)
3988+ }
3989+ }
3990+ }
3991+ TimelineCommand::Edit {
3992+ event_id, content, ..
3993+ } => {
3994+ let (content, mentions, emote) = match content {
3995+ MessageEditContent::Text { content, mentions } => (content, mentions, false),
3996+ MessageEditContent::Emote { content, mentions } => (content, mentions, true),
3997+ };
3998+ if content.body.trim().is_empty() {
3999+ return Err(CommandError::new(
4000+ CommandErrorKind::EmptyBody,
4001+ "message body is empty",
4002+ ));
4003+ }
4004+ timeline
4005+ .edit(
4006+ &TimelineEventItemId::EventId(event_id),
4007+ EditedContent::RoomMessage(message_content(content, mentions, emote)),
4008+ )
4009+ .await
4010+ .map_err(timeline_error)
4011+ }
4012+ TimelineCommand::Redact {
4013+ event_id, reason, ..
4014+ } => timeline
4015+ .redact(&TimelineEventItemId::EventId(event_id), reason.as_deref())
4016+ .await
4017+ .map_err(timeline_error),
4018+ TimelineCommand::ToggleReaction { event_id, key, .. } => timeline
4019+ .toggle_reaction(&TimelineEventItemId::EventId(event_id), &key)
4020+ .await
4021+ .map(|_| ())
4022+ .map_err(timeline_error),
4023+ }
4024+}
4025+
4026+fn message_content(
4027+ content: RichText,
4028+ mentions: MessageMentions,
4029+ emote: bool,
4030+) -> RoomMessageEventContentWithoutRelation {
4031+ let content = match content.formatted_body {
4032+ Some(formatted_body) if emote => {
4033+ RoomMessageEventContentWithoutRelation::emote_html(content.body, formatted_body)
4034+ }
4035+ Some(formatted_body) => {
4036+ RoomMessageEventContentWithoutRelation::text_html(content.body, formatted_body)
4037+ }
4038+ None if emote => RoomMessageEventContentWithoutRelation::emote_plain(content.body),
4039+ None => RoomMessageEventContentWithoutRelation::text_plain(content.body),
4040+ };
4041+ let content = content.add_mentions(Mentions::with_user_ids(mentions.user_ids));
4042+ if mentions.room {
4043+ content.add_mentions(Mentions::with_room_mention())
4044+ } else {
4045+ content
4046+ }
4047+}
4048+
4049+async fn run_pagination(request: PaginationRequest) {
4050+ let PaginationRequest {
4051+ timeline,
4052+ events,
4053+ timeline_id,
4054+ reached_start,
4055+ limit,
4056+ command_id,
4057+ results,
4058+ } = request;
4059+ // One worker per timeline preserves pagination order without accumulating waiting tasks.
4060+ if reached_start.load(Ordering::Acquire) {
4061+ let _ = events
4062+ .send(Event::HistoryChanged {
4063+ timeline_id,
4064+ state: HistoryState::StartReached,
4065+ })
4066+ .await;
4067+ if let Some(id) = command_id {
4068+ let _ = results.send((id, Ok(())));
4069+ }
4070+ return;
4071+ }
4072+ if events
4073+ .send(Event::HistoryChanged {
4074+ timeline_id: timeline_id.clone(),
4075+ state: HistoryState::Loading,
4076+ })
4077+ .await
4078+ .is_err()
4079+ {
4080+ if let Some(id) = command_id {
4081+ let _ = results.send((
4082+ id,
4083+ Err(CommandError::new(
4084+ CommandErrorKind::EventReceiverClosed,
4085+ "event receiver has closed",
4086+ )),
4087+ ));
4088+ }
4089+ return;
4090+ }
4091+ let result = timeline
4092+ .paginate_backwards(limit.max(1))
4093+ .await
4094+ .map_err(timeline_error);
4095+ match result {
4096+ Ok(at_start) => {
4097+ reached_start.store(at_start, Ordering::Release);
4098+ let _ = events
4099+ .send(Event::HistoryChanged {
4100+ timeline_id,
4101+ state: if at_start {
4102+ HistoryState::StartReached
4103+ } else {
4104+ HistoryState::Idle
4105+ },
4106+ })
4107+ .await;
4108+ if let Some(id) = command_id {
4109+ let _ = results.send((id, Ok(())));
4110+ }
4111+ }
4112+ Err(error) => {
4113+ let _ = events
4114+ .send(Event::HistoryChanged {
4115+ timeline_id,
4116+ state: HistoryState::Failed(error.to_string()),
4117+ })
4118+ .await;
4119+ if let Some(id) = command_id {
4120+ let _ = results.send((id, Err(error)));
4121+ }
4122+ }
4123+ }
4124+}
4125+
4126+async fn emit_command_result_with_room(
4127+ events: &EventSender,
4128+ id: CommandId,
4129+ room_id: OwnedRoomId,
4130+) -> Result<(), SendEventError> {
4131+ events
4132+ .send(Event::CommandSucceededWithRoom {
4133+ id,
4134+ command: CommandKind::Room,
4135+ room_id,
4136+ })
4137+ .await
4138+}
4139+
4140+async fn emit_command_result(
4141+ events: &EventSender,
4142+ id: CommandId,
4143+ command: CommandKind,
4144+ result: Result<(), CommandError>,
4145+) -> Result<(), SendEventError> {
4146+ let event = match result {
4147+ Ok(()) => Event::CommandSucceeded { id, command },
4148+ Err(error) => {
4149+ tracing::warn!(?id, ?command, error = ?error, "room actor command failed");
4150+ Event::CommandFailed(CommandFailure {
4151+ id,
4152+ command,
4153+ kind: error.kind,
4154+ message: error.to_string(),
4155+ })
4156+ }
4157+ };
4158+ events.send(event).await
4159+}
4160+
4161+fn send_error(source: SendEventError) -> CommandError {
4162+ CommandError::with_source(
4163+ CommandErrorKind::EventReceiverClosed,
4164+ "event receiver has closed",
4165+ source,
4166+ )
4167+}
4168+
4169+fn timeline_error(source: matrix_sdk_ui::timeline::Error) -> CommandError {
4170+ CommandError::with_source(
4171+ CommandErrorKind::Timeline,
4172+ "timeline operation failed",
4173+ source,
4174+ )
4175+}
Added file: src/error.rs+170 additions
@@ -0,0 +1,170 @@
1+use thiserror::Error;
2+
3+use matrix_sdk::{
4+ HttpError, RefreshTokenError,
5+ authentication::oauth::error::{
6+ OAuthAuthorizationCodeError, OAuthDiscoveryError, OAuthError, OAuthTokenRevocationError,
7+ RequestTokenError,
8+ },
9+ encryption::{
10+ identities::{ManualVerifyError, RequestVerificationError},
11+ recovery::RecoveryError,
12+ secret_storage::{ImportError, SecretStorageError},
13+ },
14+};
15+
16+use crate::CommandErrorKind;
17+
18+#[derive(Debug, Error)]
19+#[error("{message}")]
20+pub(crate) struct CommandError {
21+ pub(crate) kind: CommandErrorKind,
22+ message: String,
23+ #[source]
24+ source: Option<Box<dyn std::error::Error + Send + Sync>>,
25+}
26+
27+impl CommandError {
28+ pub(crate) fn new(kind: CommandErrorKind, message: impl Into<String>) -> Self {
29+ Self {
30+ kind,
31+ message: message.into(),
32+ source: None,
33+ }
34+ }
35+
36+ pub(crate) fn with_source(
37+ kind: CommandErrorKind,
38+ message: impl Into<String>,
39+ source: impl std::error::Error + Send + Sync + 'static,
40+ ) -> Self {
41+ Self {
42+ kind,
43+ message: message.into(),
44+ source: Some(Box::new(source)),
45+ }
46+ }
47+
48+ pub(crate) fn from_sdk(message: impl Into<String>, source: matrix_sdk::Error) -> Self {
49+ Self::with_source(classify_sdk_error(&source), message, source)
50+ }
51+
52+ pub(crate) fn from_http(message: impl Into<String>, source: HttpError) -> Self {
53+ Self::with_source(classify_http_error(&source), message, source)
54+ }
55+ pub(crate) fn from_refresh_token(
56+ message: impl Into<String>,
57+ source: RefreshTokenError,
58+ ) -> Self {
59+ Self::with_source(classify_refresh_token_error(&source), message, source)
60+ }
61+
62+ pub(crate) fn from_recovery(message: impl Into<String>, source: RecoveryError) -> Self {
63+ let kind = match &source {
64+ RecoveryError::Sdk(source) => classify_sdk_error(source),
65+ RecoveryError::SecretStorage(SecretStorageError::Sdk(source)) => {
66+ classify_sdk_error(source)
67+ }
68+ RecoveryError::SecretStorage(SecretStorageError::ImportError { error, .. }) => {
69+ match error {
70+ ImportError::Sdk(source) => classify_sdk_error(source),
71+ _ => CommandErrorKind::RecoveryRejected,
72+ }
73+ }
74+ RecoveryError::SecretStorage(SecretStorageError::Verification(source)) => {
75+ match source {
76+ ManualVerifyError::Http(source) => classify_http_error(source),
77+ _ => CommandErrorKind::RecoveryRejected,
78+ }
79+ }
80+ RecoveryError::SecretStorage(SecretStorageError::SecretStorageKey(_)) => {
81+ CommandErrorKind::RecoveryRejected
82+ }
83+ RecoveryError::SecretStorage(SecretStorageError::Storage(_)) => CommandErrorKind::Store,
84+ RecoveryError::BackupExistsOnServer | RecoveryError::SecretStorage(_) => {
85+ CommandErrorKind::RecoveryRejected
86+ }
87+ };
88+ Self::with_source(kind, message, source)
89+ }
90+
91+ pub(crate) fn from_verification_request(
92+ message: impl Into<String>,
93+ source: RequestVerificationError,
94+ ) -> Self {
95+ let kind = match &source {
96+ RequestVerificationError::Sdk(source) => classify_sdk_error(source),
97+ RequestVerificationError::RoomCreation(_) => CommandErrorKind::Encryption,
98+ };
99+ Self::with_source(kind, message, source)
100+ }
101+}
102+
103+pub(crate) fn classify_sdk_error(source: &matrix_sdk::Error) -> CommandErrorKind {
104+ match source {
105+ matrix_sdk::Error::Http(source) => classify_http_error(source),
106+ matrix_sdk::Error::OAuth(source) => classify_oauth_error(source),
107+ matrix_sdk::Error::AuthenticationRequired => CommandErrorKind::Unauthenticated,
108+ matrix_sdk::Error::BadCryptoStoreState
109+ | matrix_sdk::Error::CryptoStoreError(_)
110+ | matrix_sdk::Error::CrossProcessLockError(_)
111+ | matrix_sdk::Error::StateStore(_)
112+ | matrix_sdk::Error::EventCacheStore(_)
113+ | matrix_sdk::Error::MediaStore(_) => CommandErrorKind::Store,
114+ matrix_sdk::Error::NoOlmMachine
115+ | matrix_sdk::Error::OlmError(_)
116+ | matrix_sdk::Error::MegolmError(_)
117+ | matrix_sdk::Error::DecryptorError(_) => CommandErrorKind::Encryption,
118+ matrix_sdk::Error::Media(_) => CommandErrorKind::MediaUnavailable,
119+ matrix_sdk::Error::Timeout => CommandErrorKind::Network,
120+ _ => CommandErrorKind::Other,
121+ }
122+}
123+
124+fn classify_http_error(source: &HttpError) -> CommandErrorKind {
125+ match source {
126+ HttpError::Reqwest(_) => CommandErrorKind::Network,
127+ HttpError::Api(_) => CommandErrorKind::Server,
128+ HttpError::RefreshToken(source) => classify_refresh_token_error(source),
129+ HttpError::Cached(source) => classify_http_error(source),
130+ _ => CommandErrorKind::Other,
131+ }
132+}
133+
134+fn classify_refresh_token_error(source: &RefreshTokenError) -> CommandErrorKind {
135+ match source {
136+ RefreshTokenError::RefreshTokenRequired => CommandErrorKind::Unauthenticated,
137+ RefreshTokenError::MatrixAuth(source) => classify_http_error(source),
138+ RefreshTokenError::OAuth(source) => classify_oauth_error(source),
139+ }
140+}
141+
142+fn classify_oauth_error(source: &OAuthError) -> CommandErrorKind {
143+ let classify_token_request_error = |source: &_| match source {
144+ RequestTokenError::Request(_) => CommandErrorKind::Network,
145+ RequestTokenError::ServerResponse(_) => CommandErrorKind::Unauthenticated,
146+ _ => CommandErrorKind::Other,
147+ };
148+ match source {
149+ OAuthError::Discovery(source) => match source {
150+ OAuthDiscoveryError::Http(source) => classify_http_error(source),
151+ OAuthDiscoveryError::Oidc(source) => classify_token_request_error(source),
152+ _ => CommandErrorKind::Unauthenticated,
153+ },
154+ OAuthError::RefreshToken(source) => classify_token_request_error(source),
155+ OAuthError::AuthorizationCode(OAuthAuthorizationCodeError::RequestToken(source)) => {
156+ classify_token_request_error(source)
157+ }
158+ OAuthError::AuthorizationCode(_) => CommandErrorKind::Unauthenticated,
159+ OAuthError::Logout(source) => match source {
160+ OAuthTokenRevocationError::Revoke(RequestTokenError::Request(_)) => {
161+ CommandErrorKind::Network
162+ }
163+ OAuthTokenRevocationError::Revoke(RequestTokenError::ServerResponse(_)) => {
164+ CommandErrorKind::Unauthenticated
165+ }
166+ _ => CommandErrorKind::Other,
167+ },
168+ _ => CommandErrorKind::Unauthenticated,
169+ }
170+}
Added file: src/lib.rs+42 additions
@@ -0,0 +1,42 @@
1+//! A compact application backend contract on top of the Matrix Rust SDK.
2+
3+mod encryption;
4+mod engine;
5+mod error;
6+mod model;
7+mod normalise;
8+mod transport;
9+mod uiaa;
10+mod upload;
11+
12+#[cfg(feature = "sqlite")]
13+mod account;
14+
15+#[cfg(feature = "sqlite")]
16+pub use account::{
17+ AccountError, OAuthApplicationType, OAuthLogin, PasswordLogin, PendingOAuthLogin, Session,
18+ SessionRecord, StoreConfig,
19+};
20+
21+pub use engine::{Engine, EngineConfig, EngineError};
22+pub use model::{
23+ AccountCommand, AttachmentUpload, Command, CommandErrorKind, CommandFailure, CommandId,
24+ CommandKind, ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState,
25+ EngineErrorKind, EngineFailure, Event, HistoryState, KeyBackupState, LifecycleCommand,
26+ MediaCommand, MediaDescriptor, MediaId, MediaKind, MediaSize, MemberFilter, MemberMembership,
27+ MemberSummary, MessageContent, MessageEditContent, MessageMentions, NotificationCommand,
28+ OwnDevice, PowerLevelChanges, PushNotification, PushNotificationEvent, PushNotificationRoom,
29+ PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState, ReplyReference,
30+ RichText, RoomCategory, RoomCommand, RoomListFilter, RoomMembership, RoomNotificationMode,
31+ RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState,
32+ ThreadSummary, TimelineCommand, TimelineDiff, TimelineDiffError, TimelineId, TimelineItem,
33+ TimelineItemId, TimelineMessage, TypingState, UploadDetails, UploadThumbnail,
34+ VerificationAction, VerificationFailure, VerificationState, VerificationTarget,
35+};
36+pub use transport::{
37+ ClientHandle, ClientQueryError, CommandResultError, EventStream, SendCommandError,
38+};
39+pub use uiaa::{
40+ InteractiveAuth, InteractiveAuthChallenge, InteractiveAuthFailure, InteractiveAuthFlow,
41+ InteractiveAuthOutcome,
42+};
Added file: src/model.rs+1368 additions
@@ -0,0 +1,1368 @@
1+use std::{fmt, sync::Arc};
2+
3+use ruma::{
4+ OwnedDeviceId, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedRoomOrAliasId,
5+ OwnedServerName, OwnedTransactionId, OwnedUserId,
6+ events::{AnySyncTimelineEvent, room::member::StrippedRoomMemberEvent},
7+ serde::Raw,
8+};
9+#[cfg(feature = "serde")]
10+use serde::{Deserialize, Serialize};
11+
12+#[derive(Debug, Eq, PartialEq)]
13+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
14+#[non_exhaustive]
15+pub enum Command {
16+ Account(AccountCommand),
17+ Room(RoomCommand),
18+ Timeline(TimelineCommand),
19+ Media(MediaCommand),
20+ Encryption(EncryptionCommand),
21+ Notification(NotificationCommand),
22+ Lifecycle(LifecycleCommand),
23+}
24+
25+#[derive(Debug, Eq, PartialEq)]
26+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
27+#[non_exhaustive]
28+pub enum AccountCommand {
29+ Logout,
30+ RenameDevice {
31+ device_id: OwnedDeviceId,
32+ display_name: String,
33+ },
34+ DeleteDevices {
35+ device_ids: Vec<OwnedDeviceId>,
36+ auth: Option<crate::InteractiveAuth>,
37+ },
38+}
39+
40+#[derive(Clone, Debug, Eq, PartialEq)]
41+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
42+#[non_exhaustive]
43+pub enum RoomCommand {
44+ Open(OwnedRoomId),
45+ Close(OwnedRoomId),
46+ Join {
47+ room: OwnedRoomOrAliasId,
48+ via: Vec<OwnedServerName>,
49+ },
50+ Leave(OwnedRoomId),
51+ Forget(OwnedRoomId),
52+ AcceptInvite(OwnedRoomId),
53+ RejectInvite(OwnedRoomId),
54+ InviteUser {
55+ room_id: OwnedRoomId,
56+ user_id: OwnedUserId,
57+ },
58+ KickUser {
59+ room_id: OwnedRoomId,
60+ user_id: OwnedUserId,
61+ reason: Option<String>,
62+ },
63+ BanUser {
64+ room_id: OwnedRoomId,
65+ user_id: OwnedUserId,
66+ reason: Option<String>,
67+ },
68+ UnbanUser {
69+ room_id: OwnedRoomId,
70+ user_id: OwnedUserId,
71+ reason: Option<String>,
72+ },
73+ SetPowerLevels {
74+ room_id: OwnedRoomId,
75+ changes: PowerLevelChanges,
76+ },
77+ SetUserPowerLevel {
78+ room_id: OwnedRoomId,
79+ user_id: OwnedUserId,
80+ level: i64,
81+ },
82+ Create(CreateRoomOptions),
83+ CreateDirect(OwnedUserId),
84+ CancelSend {
85+ room_id: OwnedRoomId,
86+ transaction_id: OwnedTransactionId,
87+ },
88+ RetrySend {
89+ room_id: OwnedRoomId,
90+ transaction_id: OwnedTransactionId,
91+ },
92+ MarkRead {
93+ timeline_id: TimelineId,
94+ event_id: OwnedEventId,
95+ },
96+ SetTyping {
97+ room_id: OwnedRoomId,
98+ typing: bool,
99+ },
100+}
101+
102+#[derive(Clone, Debug, Default, Eq, PartialEq)]
103+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
104+pub struct CreateRoomOptions {
105+ pub name: Option<String>,
106+ pub topic: Option<String>,
107+ pub alias_localpart: Option<String>,
108+ pub invite: Vec<OwnedUserId>,
109+ pub direct: bool,
110+ pub visibility: RoomVisibility,
111+ pub preset: Option<RoomPreset>,
112+}
113+
114+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
115+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
116+pub enum RoomVisibility {
117+ #[default]
118+ Private,
119+ Public,
120+}
121+
122+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
123+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
124+pub enum RoomPreset {
125+ PrivateChat,
126+ TrustedPrivateChat,
127+ PublicChat,
128+}
129+
130+#[derive(Clone, Debug, Eq, PartialEq)]
131+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
132+#[non_exhaustive]
133+pub enum TimelineCommand {
134+ OpenThread {
135+ room_id: OwnedRoomId,
136+ root_event_id: OwnedEventId,
137+ },
138+ CloseThread {
139+ room_id: OwnedRoomId,
140+ root_event_id: OwnedEventId,
141+ },
142+ /// Re-emit an authoritative reset for an open room or thread without
143+ /// closing it. The reset arrives before this command's terminal result.
144+ Resync {
145+ timeline_id: TimelineId,
146+ },
147+ Paginate {
148+ timeline_id: TimelineId,
149+ limit: u16,
150+ },
151+ Send {
152+ timeline_id: TimelineId,
153+ content: MessageContent,
154+ },
155+ Edit {
156+ timeline_id: TimelineId,
157+ event_id: OwnedEventId,
158+ content: MessageEditContent,
159+ },
160+ Redact {
161+ timeline_id: TimelineId,
162+ event_id: OwnedEventId,
163+ reason: Option<String>,
164+ },
165+ ToggleReaction {
166+ timeline_id: TimelineId,
167+ event_id: OwnedEventId,
168+ key: String,
169+ },
170+}
171+
172+impl TimelineCommand {
173+ #[must_use]
174+ pub const fn room_id(&self) -> &OwnedRoomId {
175+ match self {
176+ Self::OpenThread { room_id, .. } | Self::CloseThread { room_id, .. } => room_id,
177+ Self::Resync { timeline_id }
178+ | Self::Paginate { timeline_id, .. }
179+ | Self::Send { timeline_id, .. }
180+ | Self::Edit { timeline_id, .. }
181+ | Self::Redact { timeline_id, .. }
182+ | Self::ToggleReaction { timeline_id, .. } => timeline_id.room_id(),
183+ }
184+ }
185+
186+ #[must_use]
187+ pub const fn timeline_id(&self) -> Option<&TimelineId> {
188+ match self {
189+ Self::OpenThread { .. } | Self::CloseThread { .. } => None,
190+ Self::Resync { timeline_id }
191+ | Self::Paginate { timeline_id, .. }
192+ | Self::Send { timeline_id, .. }
193+ | Self::Edit { timeline_id, .. }
194+ | Self::Redact { timeline_id, .. }
195+ | Self::ToggleReaction { timeline_id, .. } => Some(timeline_id),
196+ }
197+ }
198+}
199+
200+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
201+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
202+#[non_exhaustive]
203+pub enum TimelineId {
204+ Room(OwnedRoomId),
205+ Thread {
206+ room_id: OwnedRoomId,
207+ root_event_id: OwnedEventId,
208+ },
209+}
210+
211+impl TimelineId {
212+ #[must_use]
213+ pub const fn room_id(&self) -> &OwnedRoomId {
214+ match self {
215+ Self::Room(room_id) | Self::Thread { room_id, .. } => room_id,
216+ }
217+ }
218+
219+ #[must_use]
220+ pub fn thread(room_id: OwnedRoomId, root_event_id: OwnedEventId) -> Self {
221+ Self::Thread {
222+ room_id,
223+ root_event_id,
224+ }
225+ }
226+
227+ #[must_use]
228+ pub fn thread_root(&self) -> Option<&OwnedEventId> {
229+ match self {
230+ Self::Room(_) => None,
231+ Self::Thread { root_event_id, .. } => Some(root_event_id),
232+ }
233+ }
234+}
235+
236+impl From<OwnedRoomId> for TimelineId {
237+ fn from(room_id: OwnedRoomId) -> Self {
238+ Self::Room(room_id)
239+ }
240+}
241+
242+#[derive(Clone, Debug, Eq, PartialEq)]
243+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
244+#[non_exhaustive]
245+pub enum MediaCommand {
246+ Fetch {
247+ id: MediaId,
248+ size: MediaSize,
249+ },
250+ Upload {
251+ timeline_id: TimelineId,
252+ filename: String,
253+ mimetype: String,
254+ bytes: Vec<u8>,
255+ caption: Option<String>,
256+ },
257+ UploadAttachment(AttachmentUpload),
258+}
259+
260+#[derive(Clone, Debug, Eq, PartialEq)]
261+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
262+pub struct AttachmentUpload {
263+ pub timeline_id: TimelineId,
264+ pub filename: String,
265+ pub mimetype: String,
266+ pub bytes: Vec<u8>,
267+ pub caption: Option<String>,
268+ pub details: Option<UploadDetails>,
269+ pub thumbnail: Option<UploadThumbnail>,
270+}
271+
272+impl AttachmentUpload {
273+ #[must_use]
274+ pub fn new(
275+ timeline_id: impl Into<TimelineId>,
276+ filename: impl Into<String>,
277+ mimetype: impl Into<String>,
278+ bytes: Vec<u8>,
279+ ) -> Self {
280+ Self {
281+ timeline_id: timeline_id.into(),
282+ filename: filename.into(),
283+ mimetype: mimetype.into(),
284+ bytes,
285+ caption: None,
286+ details: None,
287+ thumbnail: None,
288+ }
289+ }
290+}
291+
292+#[derive(Clone, Debug, Eq, PartialEq)]
293+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
294+#[non_exhaustive]
295+pub enum UploadDetails {
296+ Image {
297+ width: Option<u32>,
298+ height: Option<u32>,
299+ blurhash: Option<String>,
300+ },
301+ Video {
302+ width: Option<u32>,
303+ height: Option<u32>,
304+ duration_ms: Option<u64>,
305+ blurhash: Option<String>,
306+ },
307+ Audio {
308+ duration_ms: Option<u64>,
309+ },
310+ Voice {
311+ duration_ms: u64,
312+ waveform: Vec<u16>,
313+ },
314+ File,
315+}
316+
317+#[derive(Clone, Debug, Eq, PartialEq)]
318+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
319+pub struct UploadThumbnail {
320+ pub bytes: Vec<u8>,
321+ pub mimetype: String,
322+ pub width: u32,
323+ pub height: u32,
324+}
325+
326+#[derive(Eq, PartialEq)]
327+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
328+#[non_exhaustive]
329+pub enum EncryptionCommand {
330+ Recover(String),
331+ CreateKeyBackup,
332+ EnableKeyBackup,
333+ DisableKeyBackup { delete_from_server: bool },
334+ RecoverKeyBackup(String),
335+ StartVerification(VerificationTarget),
336+ Verify(VerificationAction),
337+}
338+
339+impl fmt::Debug for EncryptionCommand {
340+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
341+ match self {
342+ Self::Recover(_) => formatter.debug_tuple("Recover").field(&"...").finish(),
343+ Self::CreateKeyBackup => formatter.write_str("CreateKeyBackup"),
344+ Self::EnableKeyBackup => formatter.write_str("EnableKeyBackup"),
345+ Self::DisableKeyBackup { delete_from_server } => formatter
346+ .debug_struct("DisableKeyBackup")
347+ .field("delete_from_server", delete_from_server)
348+ .finish(),
349+ Self::RecoverKeyBackup(_) => formatter
350+ .debug_tuple("RecoverKeyBackup")
351+ .field(&"...")
352+ .finish(),
353+ Self::StartVerification(target) => formatter
354+ .debug_tuple("StartVerification")
355+ .field(target)
356+ .finish(),
357+ Self::Verify(action) => formatter.debug_tuple("Verify").field(action).finish(),
358+ }
359+ }
360+}
361+
362+#[derive(Clone, Debug, Eq, PartialEq)]
363+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
364+#[non_exhaustive]
365+pub enum NotificationCommand {
366+ SetRoomMode {
367+ room_id: OwnedRoomId,
368+ mode: RoomNotificationMode,
369+ },
370+ ResetRoomMode(OwnedRoomId),
371+ SetDefaultMode {
372+ encrypted: bool,
373+ one_to_one: bool,
374+ mode: RoomNotificationMode,
375+ },
376+ AddKeyword(String),
377+ RemoveKeyword(String),
378+}
379+
380+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
381+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
382+#[non_exhaustive]
383+pub enum LifecycleCommand {
384+ Shutdown,
385+}
386+
387+impl Command {
388+ #[must_use]
389+ pub const fn kind(&self) -> CommandKind {
390+ match self {
391+ Self::Account(_) => CommandKind::Account,
392+ Self::Room(_) => CommandKind::Room,
393+ Self::Timeline(_) => CommandKind::Timeline,
394+ Self::Media(_) => CommandKind::Media,
395+ Self::Encryption(_) => CommandKind::Encryption,
396+ Self::Notification(_) => CommandKind::Notification,
397+ Self::Lifecycle(_) => CommandKind::Lifecycle,
398+ }
399+ }
400+}
401+
402+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
403+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
404+pub enum CommandKind {
405+ Account,
406+ Room,
407+ Timeline,
408+ Media,
409+ Encryption,
410+ Notification,
411+ Lifecycle,
412+}
413+
414+#[derive(Clone, Debug, Eq, PartialEq)]
415+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
416+#[non_exhaustive]
417+pub enum MessageContent {
418+ Text {
419+ content: RichText,
420+ mentions: MessageMentions,
421+ },
422+ Emote {
423+ content: RichText,
424+ mentions: MessageMentions,
425+ },
426+ Reply {
427+ event_id: OwnedEventId,
428+ content: RichText,
429+ mentions: MessageMentions,
430+ notify_replied_to: bool,
431+ },
432+}
433+
434+impl MessageContent {
435+ #[must_use]
436+ pub fn text(body: impl Into<String>) -> Self {
437+ Self::Text {
438+ content: RichText::plain(body),
439+ mentions: MessageMentions::default(),
440+ }
441+ }
442+
443+ #[must_use]
444+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {
445+ Self::Text {
446+ content: RichText::html(body, formatted_body),
447+ mentions: MessageMentions::default(),
448+ }
449+ }
450+
451+ #[must_use]
452+ pub fn emote(content: RichText, mentions: MessageMentions) -> Self {
453+ Self::Emote { content, mentions }
454+ }
455+
456+ #[must_use]
457+ pub fn reply(event_id: OwnedEventId, content: RichText, mentions: MessageMentions) -> Self {
458+ Self::Reply {
459+ event_id,
460+ content,
461+ mentions,
462+ notify_replied_to: true,
463+ }
464+ }
465+
466+ #[must_use]
467+ pub fn reply_without_notification(
468+ event_id: OwnedEventId,
469+ content: RichText,
470+ mentions: MessageMentions,
471+ ) -> Self {
472+ Self::Reply {
473+ event_id,
474+ content,
475+ mentions,
476+ notify_replied_to: false,
477+ }
478+ }
479+}
480+
481+#[derive(Clone, Debug, Eq, PartialEq)]
482+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
483+#[non_exhaustive]
484+pub enum MessageEditContent {
485+ Text {
486+ content: RichText,
487+ mentions: MessageMentions,
488+ },
489+ Emote {
490+ content: RichText,
491+ mentions: MessageMentions,
492+ },
493+}
494+
495+impl MessageEditContent {
496+ #[must_use]
497+ pub fn text(body: impl Into<String>) -> Self {
498+ Self::Text {
499+ content: RichText::plain(body),
500+ mentions: MessageMentions::default(),
501+ }
502+ }
503+
504+ #[must_use]
505+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {
506+ Self::Text {
507+ content: RichText::html(body, formatted_body),
508+ mentions: MessageMentions::default(),
509+ }
510+ }
511+
512+ #[must_use]
513+ pub fn emote(content: RichText, mentions: MessageMentions) -> Self {
514+ Self::Emote { content, mentions }
515+ }
516+}
517+
518+#[derive(Clone, Debug, Default, Eq, PartialEq)]
519+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
520+#[non_exhaustive]
521+pub struct RichText {
522+ pub body: String,
523+ pub formatted_body: Option<String>,
524+}
525+
526+impl RichText {
527+ #[must_use]
528+ pub fn plain(body: impl Into<String>) -> Self {
529+ Self {
530+ body: body.into(),
531+ formatted_body: None,
532+ }
533+ }
534+
535+ #[must_use]
536+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {
537+ Self {
538+ body: body.into(),
539+ formatted_body: Some(formatted_body.into()),
540+ }
541+ }
542+}
543+
544+#[derive(Clone, Debug, Default, Eq, PartialEq)]
545+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
546+#[non_exhaustive]
547+pub struct MessageMentions {
548+ pub user_ids: Vec<OwnedUserId>,
549+ pub room: bool,
550+}
551+
552+impl MessageMentions {
553+ #[must_use]
554+ pub fn with_user_ids(user_ids: impl IntoIterator<Item = OwnedUserId>) -> Self {
555+ Self {
556+ user_ids: user_ids.into_iter().collect(),
557+ room: false,
558+ }
559+ }
560+
561+ #[must_use]
562+ pub fn room() -> Self {
563+ Self {
564+ user_ids: Vec::new(),
565+ room: true,
566+ }
567+ }
568+}
569+
570+#[derive(Clone, Debug, Eq, PartialEq)]
571+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
572+#[non_exhaustive]
573+pub enum Event {
574+ ConnectionChanged(ConnectionState),
575+ Authenticated {
576+ user_id: OwnedUserId,
577+ },
578+ /// Refresh tokens changed. Persist a fresh `ClientHandle::session_record()`
579+ /// atomically; a record captured before this event may no longer restore.
580+ SessionTokensChanged,
581+ /// Authentication was rejected. Remove the saved session record and store.
582+ SessionAccessRejected,
583+ NotificationSettingsChanged,
584+ VerificationTargetChanged(VerificationTarget),
585+ InteractiveAuthRequired {
586+ id: CommandId,
587+ command: CommandKind,
588+ challenge: crate::InteractiveAuthChallenge,
589+ },
590+ RoomsChanged,
591+ /// Authoritative snapshot on open or resync. A resync reset precedes the
592+ /// corresponding terminal command event on this stream.
593+ TimelineReset {
594+ timeline_id: TimelineId,
595+ items: Vec<TimelineItem>,
596+ },
597+ TimelineChanged {
598+ timeline_id: TimelineId,
599+ diffs: Vec<TimelineDiff>,
600+ },
601+ HistoryChanged {
602+ timeline_id: TimelineId,
603+ state: HistoryState,
604+ },
605+ TypingChanged(TypingState),
606+ MediaReady {
607+ media_id: MediaId,
608+ size: MediaSize,
609+ bytes: Arc<[u8]>,
610+ mimetype: String,
611+ },
612+ MediaUploadProgress {
613+ room_id: OwnedRoomId,
614+ transaction_id: OwnedTransactionId,
615+ uploaded_bytes: u64,
616+ total_bytes: u64,
617+ complete: bool,
618+ },
619+ MediaUploadFailed {
620+ room_id: OwnedRoomId,
621+ transaction_id: OwnedTransactionId,
622+ kind: CommandErrorKind,
623+ recoverable: bool,
624+ },
625+ EncryptionChanged(EncryptionState),
626+ VerificationChanged(VerificationState),
627+ /// Terminal command success. For sends, this means the SDK send queue
628+ /// accepted the item; network delivery is reflected by its send state.
629+ CommandSucceeded {
630+ id: CommandId,
631+ command: CommandKind,
632+ },
633+ CommandSucceededWithRoom {
634+ id: CommandId,
635+ command: CommandKind,
636+ room_id: OwnedRoomId,
637+ },
638+ /// Terminal failure correlated by `CommandFailure::id`.
639+ CommandFailed(CommandFailure),
640+ /// Explicit logout completed; delete the saved authentication record.
641+ LoggedOut,
642+ Failed(EngineFailure),
643+ /// Engine stopped without logging out; retain saved credentials for restore.
644+ Stopped,
645+}
646+
647+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
648+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
649+pub struct CommandId(u64);
650+
651+impl CommandId {
652+ pub(crate) const fn new(value: u64) -> Self {
653+ Self(value)
654+ }
655+
656+ #[must_use]
657+ pub const fn get(self) -> u64 {
658+ self.0
659+ }
660+}
661+
662+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
663+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
664+pub enum ConnectionState {
665+ Idle,
666+ Connecting,
667+ Online,
668+ Offline,
669+ Stopped,
670+}
671+
672+#[derive(Clone, Debug, Eq, PartialEq)]
673+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
674+#[non_exhaustive]
675+pub struct RoomSummary {
676+ pub room_id: OwnedRoomId,
677+ pub name: String,
678+ pub membership: RoomMembership,
679+ pub unread_messages: u64,
680+ pub unread_mentions: u64,
681+ pub encrypted: bool,
682+ pub space: bool,
683+ pub direct: bool,
684+ pub favourite: bool,
685+ pub low_priority: bool,
686+ pub avatar: Option<OwnedMxcUri>,
687+ pub latest_event_timestamp_ms: Option<u64>,
688+ pub notification_mode: Option<RoomNotificationMode>,
689+ pub notification_mode_custom: bool,
690+}
691+
692+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
693+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
694+pub enum RoomNotificationMode {
695+ AllMessages,
696+ MentionsAndKeywordsOnly,
697+ Mute,
698+}
699+
700+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
701+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
702+#[non_exhaustive]
703+pub struct RoomNotificationSettings {
704+ pub mode: RoomNotificationMode,
705+ pub custom: bool,
706+}
707+
708+#[derive(Clone, Debug)]
709+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
710+#[non_exhaustive]
711+pub enum PushNotificationStatus {
712+ Event(Box<PushNotification>),
713+ NotFound,
714+ FilteredOut,
715+ Redacted,
716+}
717+
718+#[derive(Clone, Debug)]
719+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
720+#[non_exhaustive]
721+pub struct PushNotification {
722+ pub event_id: OwnedEventId,
723+ pub event: PushNotificationEvent,
724+ pub sender: PushNotificationSender,
725+ pub room: PushNotificationRoom,
726+ pub noisy: Option<bool>,
727+ pub has_mention: Option<bool>,
728+ pub thread_id: Option<OwnedEventId>,
729+}
730+
731+#[derive(Clone, Debug, Eq, PartialEq)]
732+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
733+#[non_exhaustive]
734+pub struct PushNotificationSender {
735+ pub user_id: OwnedUserId,
736+ pub display_name: Option<String>,
737+ pub avatar_url: Option<OwnedMxcUri>,
738+ pub name_ambiguous: bool,
739+}
740+
741+#[derive(Clone, Debug, Eq, PartialEq)]
742+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
743+#[non_exhaustive]
744+pub struct PushNotificationRoom {
745+ pub room_id: OwnedRoomId,
746+ pub name: String,
747+ pub avatar_url: Option<OwnedMxcUri>,
748+ pub canonical_alias: Option<OwnedRoomAliasId>,
749+ pub topic: Option<String>,
750+ pub encrypted: Option<bool>,
751+ pub direct: bool,
752+ pub joined_members_count: u64,
753+ pub service_members: Vec<OwnedUserId>,
754+ pub active_service_members_count: u64,
755+ pub space: bool,
756+}
757+
758+#[derive(Clone, Debug)]
759+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
760+#[non_exhaustive]
761+pub enum PushNotificationEvent {
762+ Timeline(Raw<AnySyncTimelineEvent>),
763+ Invite(Raw<StrippedRoomMemberEvent>),
764+}
765+
766+#[derive(Clone, Debug, Eq, PartialEq)]
767+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
768+#[non_exhaustive]
769+pub struct RoomPowerLevels {
770+ pub ban: i64,
771+ pub invite: i64,
772+ pub kick: i64,
773+ pub redact: i64,
774+ pub events_default: i64,
775+ pub state_default: i64,
776+ pub users_default: i64,
777+ pub room_notification: i64,
778+ pub users: Vec<(OwnedUserId, i64)>,
779+ pub events: Vec<(String, i64)>,
780+}
781+
782+#[derive(Clone, Debug, Default, Eq, PartialEq)]
783+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
784+#[non_exhaustive]
785+pub struct PowerLevelChanges {
786+ pub ban: Option<i64>,
787+ pub invite: Option<i64>,
788+ pub kick: Option<i64>,
789+ pub redact: Option<i64>,
790+ pub events_default: Option<i64>,
791+ pub state_default: Option<i64>,
792+ pub users_default: Option<i64>,
793+ pub room_notification: Option<i64>,
794+ pub room_name: Option<i64>,
795+ pub room_avatar: Option<i64>,
796+ pub room_topic: Option<i64>,
797+ pub space_child: Option<i64>,
798+ pub beacon: Option<i64>,
799+ pub beacon_info: Option<i64>,
800+}
801+
802+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
803+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
804+pub enum RoomMembership {
805+ Joined,
806+ Invited,
807+ Left,
808+ Knocked,
809+ Banned,
810+}
811+
812+#[derive(Clone, Debug, Default, Eq, PartialEq)]
813+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
814+#[non_exhaustive]
815+pub struct RoomListFilter {
816+ pub membership: Option<RoomMembership>,
817+ pub category: Option<RoomCategory>,
818+ pub favourite: Option<bool>,
819+ pub unread_only: bool,
820+ pub search: Option<String>,
821+}
822+
823+impl RoomListFilter {
824+ #[must_use]
825+ pub fn with_membership(mut self, membership: RoomMembership) -> Self {
826+ self.membership = Some(membership);
827+ self
828+ }
829+
830+ #[must_use]
831+ pub fn with_category(mut self, category: RoomCategory) -> Self {
832+ self.category = Some(category);
833+ self
834+ }
835+
836+ #[must_use]
837+ pub fn with_favourite(mut self, favourite: bool) -> Self {
838+ self.favourite = Some(favourite);
839+ self
840+ }
841+
842+ #[must_use]
843+ pub const fn with_unread_only(mut self, unread_only: bool) -> Self {
844+ self.unread_only = unread_only;
845+ self
846+ }
847+
848+ #[must_use]
849+ pub fn with_search(mut self, search: impl Into<String>) -> Self {
850+ self.search = Some(search.into());
851+ self
852+ }
853+
854+ #[must_use]
855+ pub fn matches(&self, room: &RoomSummary) -> bool {
856+ self.membership
857+ .is_none_or(|membership| room.membership == membership)
858+ && self.category.is_none_or(|category| match category {
859+ RoomCategory::People => room.direct && !room.space,
860+ RoomCategory::Rooms => !room.direct && !room.space,
861+ RoomCategory::Spaces => room.space,
862+ })
863+ && self
864+ .favourite
865+ .is_none_or(|favourite| room.favourite == favourite)
866+ && (!self.unread_only || room.unread_messages > 0 || room.unread_mentions > 0)
867+ && self.search.as_ref().is_none_or(|search| {
868+ let search = search.to_lowercase();
869+ room.name.to_lowercase().contains(&search)
870+ || room.room_id.as_str().to_lowercase().contains(&search)
871+ })
872+ }
873+}
874+
875+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
876+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
877+pub enum RoomCategory {
878+ People,
879+ Rooms,
880+ Spaces,
881+}
882+
883+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
884+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
885+pub enum MemberFilter {
886+ Active,
887+ Joined,
888+ Invited,
889+ All,
890+}
891+
892+#[derive(Clone, Debug, Eq, PartialEq)]
893+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
894+#[non_exhaustive]
895+pub enum MemberMembership {
896+ Joined,
897+ Invited,
898+ Left,
899+ Knocked,
900+ Banned,
901+ Custom(String),
902+}
903+
904+#[derive(Clone, Debug, Eq, PartialEq)]
905+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
906+#[non_exhaustive]
907+pub struct MemberSummary {
908+ pub user_id: OwnedUserId,
909+ pub name: String,
910+ pub display_name: Option<String>,
911+ pub avatar: Option<OwnedMxcUri>,
912+ pub membership: MemberMembership,
913+ pub own: bool,
914+ pub name_ambiguous: bool,
915+ pub ignored: bool,
916+ pub service: bool,
917+}
918+
919+#[derive(Clone, Debug, Eq, PartialEq)]
920+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
921+#[non_exhaustive]
922+pub struct OwnDevice {
923+ pub device_id: OwnedDeviceId,
924+ pub display_name: Option<String>,
925+ pub last_seen_ip: Option<String>,
926+ pub last_seen_timestamp_ms: Option<u64>,
927+ pub current: bool,
928+}
929+
930+#[derive(Clone, Debug, Eq, PartialEq)]
931+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
932+pub enum TimelineItem {
933+ Message(Box<TimelineMessage>),
934+ DayDivider { timestamp_ms: u64 },
935+ ReadMarker,
936+ TimelineStart,
937+}
938+
939+#[derive(Clone, Debug, Eq, PartialEq)]
940+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
941+#[non_exhaustive]
942+pub enum TimelineDiff {
943+ Append { values: Vec<TimelineItem> },
944+ Clear,
945+ PushFront { value: TimelineItem },
946+ PushBack { value: TimelineItem },
947+ PopFront,
948+ PopBack,
949+ Insert { index: usize, value: TimelineItem },
950+ Set { index: usize, value: TimelineItem },
951+ Remove { index: usize },
952+ Truncate { length: usize },
953+ Reset { values: Vec<TimelineItem> },
954+}
955+
956+#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
957+#[error("timeline diff is incompatible with the current {length}-item snapshot")]
958+pub struct TimelineDiffError {
959+ pub length: usize,
960+}
961+
962+impl TimelineDiff {
963+ /// Apply a diff only when its indices match the current snapshot.
964+ /// On error the snapshot is unchanged; request `TimelineCommand::Resync`
965+ /// and ignore further diffs until the corresponding `TimelineReset`.
966+ pub fn try_apply(self, items: &mut Vec<TimelineItem>) -> Result<(), TimelineDiffError> {
967+ let length = items.len();
968+ match self {
969+ Self::Append { values } => items.extend(values),
970+ Self::Clear => items.clear(),
971+ Self::PushFront { value } => items.insert(0, value),
972+ Self::PushBack { value } => items.push(value),
973+ Self::PopFront if length == 0 => return Err(TimelineDiffError { length }),
974+ Self::PopFront => {
975+ items.remove(0);
976+ }
977+ Self::PopBack if length == 0 => return Err(TimelineDiffError { length }),
978+ Self::PopBack => {
979+ items.pop();
980+ }
981+ Self::Insert { index, .. } if index > length => {
982+ return Err(TimelineDiffError { length });
983+ }
984+ Self::Insert { index, value } => items.insert(index, value),
985+ Self::Set { index, .. } if index >= length => return Err(TimelineDiffError { length }),
986+ Self::Set { index, value } => items[index] = value,
987+ Self::Remove { index } if index >= length => return Err(TimelineDiffError { length }),
988+ Self::Remove { index } => {
989+ items.remove(index);
990+ }
991+ Self::Truncate { length: target } if target > length => {
992+ return Err(TimelineDiffError { length });
993+ }
994+ Self::Truncate { length } => items.truncate(length),
995+ Self::Reset { values } => *items = values,
996+ }
997+ Ok(())
998+ }
999+}
1000+
1001+#[cfg(test)]
1002+mod tests {
1003+ use super::{TimelineDiff, TimelineItem};
1004+
1005+ fn item(timestamp_ms: u64) -> TimelineItem {
1006+ TimelineItem::DayDivider { timestamp_ms }
1007+ }
1008+
1009+ #[test]
1010+ fn timeline_diff_try_apply_handles_every_variant() {
1011+ let mut items = vec![item(1), item(2)];
1012+
1013+ TimelineDiff::Append {
1014+ values: vec![item(3)],
1015+ }
1016+ .try_apply(&mut items)
1017+ .unwrap();
1018+ assert_eq!(items, vec![item(1), item(2), item(3)]);
1019+
1020+ TimelineDiff::Clear.try_apply(&mut items).unwrap();
1021+ assert!(items.is_empty());
1022+
1023+ TimelineDiff::PushFront { value: item(2) }
1024+ .try_apply(&mut items)
1025+ .unwrap();
1026+ TimelineDiff::PushBack { value: item(3) }
1027+ .try_apply(&mut items)
1028+ .unwrap();
1029+ assert_eq!(items, vec![item(2), item(3)]);
1030+
1031+ TimelineDiff::PopFront.try_apply(&mut items).unwrap();
1032+ TimelineDiff::PopBack.try_apply(&mut items).unwrap();
1033+ assert!(items.is_empty());
1034+
1035+ TimelineDiff::Reset {
1036+ values: vec![item(1), item(3)],
1037+ }
1038+ .try_apply(&mut items)
1039+ .unwrap();
1040+ TimelineDiff::Insert {
1041+ index: 1,
1042+ value: item(2),
1043+ }
1044+ .try_apply(&mut items)
1045+ .unwrap();
1046+ assert_eq!(items, vec![item(1), item(2), item(3)]);
1047+
1048+ TimelineDiff::Set {
1049+ index: 1,
1050+ value: item(4),
1051+ }
1052+ .try_apply(&mut items)
1053+ .unwrap();
1054+ assert_eq!(items, vec![item(1), item(4), item(3)]);
1055+
1056+ TimelineDiff::Remove { index: 1 }
1057+ .try_apply(&mut items)
1058+ .unwrap();
1059+ assert_eq!(items, vec![item(1), item(3)]);
1060+
1061+ TimelineDiff::Truncate { length: 1 }
1062+ .try_apply(&mut items)
1063+ .unwrap();
1064+ assert_eq!(items, vec![item(1)]);
1065+ }
1066+
1067+ #[test]
1068+ fn invalid_timeline_diff_leaves_snapshot_unchanged() {
1069+ let invalid = [
1070+ TimelineDiff::Insert {
1071+ index: 3,
1072+ value: item(3),
1073+ },
1074+ TimelineDiff::Set {
1075+ index: 2,
1076+ value: item(3),
1077+ },
1078+ TimelineDiff::Remove { index: 2 },
1079+ TimelineDiff::Truncate { length: 3 },
1080+ ];
1081+ for diff in invalid {
1082+ let mut items = vec![item(1), item(2)];
1083+ assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 2);
1084+ assert_eq!(items, vec![item(1), item(2)]);
1085+ }
1086+ for diff in [TimelineDiff::PopFront, TimelineDiff::PopBack] {
1087+ let mut items = Vec::new();
1088+ assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 0);
1089+ assert!(items.is_empty());
1090+ }
1091+ }
1092+}
1093+
1094+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1095+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1096+pub struct TimelineItemId(String);
1097+
1098+impl TimelineItemId {
1099+ pub(crate) fn new(value: String) -> Self {
1100+ Self(value)
1101+ }
1102+
1103+ #[must_use]
1104+ pub fn as_str(&self) -> &str {
1105+ &self.0
1106+ }
1107+}
1108+
1109+#[derive(Clone, Debug, Eq, PartialEq)]
1110+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1111+#[non_exhaustive]
1112+pub struct TimelineMessage {
1113+ pub item_id: TimelineItemId,
1114+ pub event_id: Option<OwnedEventId>,
1115+ pub transaction_id: Option<OwnedTransactionId>,
1116+ pub sender_id: OwnedUserId,
1117+ pub sender_name: String,
1118+ pub body: String,
1119+ pub formatted_body: Option<String>,
1120+ pub mentions: Option<MessageMentions>,
1121+ pub thread_root: Option<OwnedEventId>,
1122+ pub thread_summary: Option<ThreadSummary>,
1123+ pub timestamp_ms: u64,
1124+ pub own: bool,
1125+ pub send_state: SendState,
1126+ pub edited: bool,
1127+ pub unable_to_decrypt: bool,
1128+ pub media: Option<MediaDescriptor>,
1129+ pub reply_to: Option<ReplyReference>,
1130+ pub reactions: Vec<Reaction>,
1131+ pub read_by: Vec<OwnedUserId>,
1132+}
1133+
1134+#[derive(Clone, Debug, Eq, PartialEq)]
1135+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1136+#[non_exhaustive]
1137+pub struct ThreadSummary {
1138+ pub reply_count: u32,
1139+ pub latest_event_id: Option<OwnedEventId>,
1140+ pub latest_event_timestamp_ms: Option<u64>,
1141+}
1142+
1143+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1144+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1145+#[non_exhaustive]
1146+pub enum SendState {
1147+ Sent,
1148+ Queued,
1149+ Sending,
1150+ Failed { retriable: bool },
1151+}
1152+
1153+#[derive(Clone, Debug, Eq, PartialEq)]
1154+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1155+pub struct ReplyReference {
1156+ pub event_id: OwnedEventId,
1157+ pub sender_id: Option<OwnedUserId>,
1158+ pub sender_name: Option<String>,
1159+}
1160+
1161+#[derive(Clone, Debug, Eq, PartialEq)]
1162+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1163+#[non_exhaustive]
1164+pub struct Reaction {
1165+ pub key: String,
1166+ pub count: usize,
1167+ pub own: bool,
1168+ pub senders: Vec<OwnedUserId>,
1169+}
1170+
1171+#[derive(Clone, Debug, Eq, PartialEq)]
1172+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1173+#[non_exhaustive]
1174+pub struct MediaDescriptor {
1175+ pub id: MediaId,
1176+ pub kind: MediaKind,
1177+ pub filename: String,
1178+ pub caption: Option<String>,
1179+ pub mimetype: Option<String>,
1180+ pub size_bytes: Option<u64>,
1181+ pub width: Option<u64>,
1182+ pub height: Option<u64>,
1183+}
1184+
1185+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1186+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1187+pub struct MediaId(String);
1188+
1189+impl MediaId {
1190+ pub(crate) fn new(value: String) -> Self {
1191+ Self(value)
1192+ }
1193+
1194+ #[must_use]
1195+ pub fn as_str(&self) -> &str {
1196+ &self.0
1197+ }
1198+}
1199+
1200+impl std::fmt::Display for MediaId {
1201+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1202+ self.0.fmt(formatter)
1203+ }
1204+}
1205+
1206+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1207+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1208+pub enum MediaSize {
1209+ Thumbnail,
1210+ Full,
1211+}
1212+
1213+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1214+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1215+#[non_exhaustive]
1216+pub enum MediaKind {
1217+ Image,
1218+ Video,
1219+ Audio,
1220+ File,
1221+ Sticker,
1222+}
1223+
1224+#[derive(Clone, Debug, Eq, PartialEq)]
1225+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1226+pub struct TypingState {
1227+ pub room_id: OwnedRoomId,
1228+ pub users: Vec<OwnedUserId>,
1229+}
1230+
1231+#[derive(Clone, Debug, Eq, PartialEq)]
1232+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1233+pub enum HistoryState {
1234+ Idle,
1235+ Loading,
1236+ StartReached,
1237+ Failed(String),
1238+}
1239+
1240+#[derive(Clone, Debug, Eq, PartialEq)]
1241+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1242+#[non_exhaustive]
1243+pub struct EncryptionState {
1244+ pub device_id: OwnedDeviceId,
1245+ pub device_verified: bool,
1246+ pub recovery: RecoveryState,
1247+ pub key_backup: KeyBackupState,
1248+}
1249+
1250+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1251+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1252+pub enum KeyBackupState {
1253+ Unknown,
1254+ Creating,
1255+ Enabling,
1256+ Resuming,
1257+ Downloading,
1258+ Enabled,
1259+ Disabling,
1260+}
1261+
1262+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1263+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1264+pub enum RecoveryState {
1265+ Unknown,
1266+ Disabled,
1267+ Incomplete,
1268+ Enabled,
1269+}
1270+
1271+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1272+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1273+pub enum VerificationAction {
1274+ Confirm,
1275+ Reject,
1276+ Cancel,
1277+}
1278+
1279+#[derive(Clone, Debug, Eq, PartialEq)]
1280+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1281+#[non_exhaustive]
1282+pub enum VerificationTarget {
1283+ OwnIdentity,
1284+ User(OwnedUserId),
1285+ Device {
1286+ user_id: OwnedUserId,
1287+ device_id: OwnedDeviceId,
1288+ },
1289+}
1290+
1291+#[derive(Clone, Debug, Eq, PartialEq)]
1292+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1293+pub enum VerificationState {
1294+ Waiting,
1295+ Comparison {
1296+ emojis: Vec<(String, String)>,
1297+ decimals: (u16, u16, u16),
1298+ },
1299+ Confirmed,
1300+ Done,
1301+ Cancelled(String),
1302+ Failed(VerificationFailure),
1303+}
1304+
1305+#[derive(Clone, Debug, Eq, PartialEq)]
1306+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1307+#[non_exhaustive]
1308+pub enum VerificationFailure {
1309+ RequestAcceptance(String),
1310+ SasStartup(String),
1311+ SasAcceptance(String),
1312+ RequestStreamEnded,
1313+ SasStreamEnded,
1314+}
1315+
1316+#[derive(Clone, Debug, Eq, PartialEq)]
1317+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1318+#[non_exhaustive]
1319+pub struct CommandFailure {
1320+ pub id: CommandId,
1321+ pub command: CommandKind,
1322+ pub kind: CommandErrorKind,
1323+ pub message: String,
1324+}
1325+
1326+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1327+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1328+#[non_exhaustive]
1329+pub enum CommandErrorKind {
1330+ Unauthenticated,
1331+ RoomUnknown,
1332+ RoomNotOpen,
1333+ TimelineNotOpen,
1334+ RoomBusy,
1335+ QueueFull,
1336+ InteractiveAuthRequired,
1337+ EmptyBody,
1338+ MediaUnknown,
1339+ MediaTooLarge,
1340+ MediaUnavailable,
1341+ VerificationInactive,
1342+ VerificationUnsupported,
1343+ NoCrossSigningIdentity,
1344+ RecoveryRejected,
1345+ EventReceiverClosed,
1346+ Network,
1347+ Server,
1348+ Store,
1349+ Timeline,
1350+ Encryption,
1351+ Other,
1352+}
1353+
1354+#[derive(Clone, Debug, Eq, PartialEq)]
1355+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1356+pub struct EngineFailure {
1357+ pub kind: EngineErrorKind,
1358+ pub message: String,
1359+}
1360+
1361+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1362+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1363+#[non_exhaustive]
1364+pub enum EngineErrorKind {
1365+ EventReceiverClosed,
1366+ Watcher,
1367+ Other,
1368+}
Added file: src/normalise/media.rs+340 additions
@@ -0,0 +1,340 @@
1+use std::sync::atomic::{AtomicU64, Ordering};
2+
3+use matrix_sdk::{media::MediaEventContent, ruma::events::room::MediaSource};
4+use matrix_sdk_ui::timeline::{MsgLikeKind, TimelineItem as SdkTimelineItem, TimelineItemContent};
5+use ruma::events::{
6+ room::{
7+ ImageInfo, ThumbnailInfo,
8+ message::{FileInfo, MessageType, VideoInfo},
9+ },
10+ sticker::StickerEventContent,
11+};
12+
13+use crate::{MediaDescriptor, MediaId, MediaKind};
14+
15+static NEXT_MEDIA_SCOPE: AtomicU64 = AtomicU64::new(1);
16+
17+/// Per-timeline-session nonce namespacing recycled SDK unique IDs.
18+///
19+/// SDK 0.18 recycles [`SdkTimelineItem::unique_id`] across the local-to-remote
20+/// echo replacement, while transaction IDs disappear. Scoping the recycled ID
21+/// by a process-wide nonce keeps media IDs stable across replacement without
22+/// colliding across rooms, threads, or reopened sessions.
23+#[derive(Clone, Debug)]
24+pub(crate) struct MediaScope {
25+ nonce: u64,
26+}
27+
28+impl MediaScope {
29+ pub(crate) fn new() -> Self {
30+ Self {
31+ nonce: NEXT_MEDIA_SCOPE.fetch_add(1, Ordering::Relaxed),
32+ }
33+ }
34+}
35+
36+#[derive(Clone)]
37+pub(crate) struct MediaRegistration {
38+ pub(crate) id: MediaId,
39+ pub(crate) kind: MediaKind,
40+ pub(crate) full: MediaSourceInfo,
41+ pub(crate) thumbnail: Option<MediaSourceInfo>,
42+}
43+
44+#[derive(Clone)]
45+pub(crate) struct MediaSourceInfo {
46+ pub(crate) source: MediaSource,
47+ pub(crate) mimetype: Option<String>,
48+ pub(crate) declared_size: Option<u64>,
49+}
50+
51+pub(super) fn media_registration_for_item(
52+ item: &SdkTimelineItem,
53+ scope: &MediaScope,
54+) -> Option<MediaRegistration> {
55+ let event = item.as_event()?;
56+ let identity = media_identity(item, scope);
57+ let content = match event.content() {
58+ TimelineItemContent::MsgLike(content) => content,
59+ _ => return None,
60+ };
61+ match &content.kind {
62+ MsgLikeKind::Message(message) => media_registration(&identity, message.msgtype()),
63+ MsgLikeKind::Sticker(sticker) => media_sticker_registration(&identity, sticker.content()),
64+ _ => None,
65+ }
66+}
67+
68+pub(super) fn media_identity(item: &SdkTimelineItem, scope: &MediaScope) -> String {
69+ format!("{}#{}", scope.nonce, item.unique_id().0)
70+}
71+
72+pub(super) fn media_from_message(identity: &str, message: &MessageType) -> Option<MediaDescriptor> {
73+ let (kind, filename, caption, mimetype, size_bytes, width, height) = match message {
74+ MessageType::Image(content) => (
75+ MediaKind::Image,
76+ content.filename().to_owned(),
77+ content.caption().map(str::to_owned),
78+ content.info.as_ref().and_then(|info| info.mimetype.clone()),
79+ content
80+ .info
81+ .as_ref()
82+ .and_then(|info| info.size)
83+ .map(u64::from),
84+ content
85+ .info
86+ .as_ref()
87+ .and_then(|info| info.width)
88+ .map(u64::from),
89+ content
90+ .info
91+ .as_ref()
92+ .and_then(|info| info.height)
93+ .map(u64::from),
94+ ),
95+ MessageType::Video(content) => (
96+ MediaKind::Video,
97+ content.filename().to_owned(),
98+ content.caption().map(str::to_owned),
99+ content.info.as_ref().and_then(|info| info.mimetype.clone()),
100+ content
101+ .info
102+ .as_ref()
103+ .and_then(|info| info.size)
104+ .map(u64::from),
105+ content
106+ .info
107+ .as_ref()
108+ .and_then(|info| info.width)
109+ .map(u64::from),
110+ content
111+ .info
112+ .as_ref()
113+ .and_then(|info| info.height)
114+ .map(u64::from),
115+ ),
116+ MessageType::Audio(content) => (
117+ MediaKind::Audio,
118+ content.filename().to_owned(),
119+ content.caption().map(str::to_owned),
120+ content.info.as_ref().and_then(|info| info.mimetype.clone()),
121+ content
122+ .info
123+ .as_ref()
124+ .and_then(|info| info.size)
125+ .map(u64::from),
126+ None,
127+ None,
128+ ),
129+ MessageType::File(content) => (
130+ MediaKind::File,
131+ content.filename().to_owned(),
132+ content.caption().map(str::to_owned),
133+ content.info.as_ref().and_then(|info| info.mimetype.clone()),
134+ content
135+ .info
136+ .as_ref()
137+ .and_then(|info| info.size)
138+ .map(u64::from),
139+ None,
140+ None,
141+ ),
142+ _ => return None,
143+ };
144+ Some(MediaDescriptor {
145+ id: media_id(identity, "media"),
146+ kind,
147+ filename,
148+ caption,
149+ mimetype,
150+ size_bytes,
151+ width,
152+ height,
153+ })
154+}
155+
156+pub(super) fn media_descriptor_for_sticker(
157+ identity: &str,
158+ content: &StickerEventContent,
159+) -> MediaDescriptor {
160+ MediaDescriptor {
161+ id: media_id(identity, "sticker"),
162+ kind: MediaKind::Sticker,
163+ filename: content.body.clone(),
164+ caption: None,
165+ mimetype: content.info.mimetype.clone(),
166+ size_bytes: content.info.size.map(u64::from),
167+ width: content.info.width.map(u64::from),
168+ height: content.info.height.map(u64::from),
169+ }
170+}
171+
172+pub(super) fn media_id(identity: &str, suffix: &str) -> MediaId {
173+ MediaId::new(format!("{identity}#{suffix}"))
174+}
175+
176+fn media_sticker_registration(
177+ identity: &str,
178+ content: &StickerEventContent,
179+) -> Option<MediaRegistration> {
180+ Some(MediaRegistration {
181+ id: media_id(identity, "sticker"),
182+ kind: MediaKind::Sticker,
183+ full: MediaSourceInfo {
184+ source: content.source()?,
185+ mimetype: content.info.mimetype.clone(),
186+ declared_size: content.info.size.map(u64::from),
187+ },
188+ thumbnail: thumbnail_media_source(
189+ content.info.thumbnail_source.clone(),
190+ content.info.thumbnail_info.as_deref(),
191+ ),
192+ })
193+}
194+
195+fn media_registration(identity: &str, message: &MessageType) -> Option<MediaRegistration> {
196+ match message {
197+ MessageType::Image(content) => media_registration_with_info(
198+ identity,
199+ MediaKind::Image,
200+ content.source(),
201+ content.info.as_deref(),
202+ ),
203+ MessageType::Video(content) => media_registration_with_info(
204+ identity,
205+ MediaKind::Video,
206+ content.source(),
207+ content.info.as_deref(),
208+ ),
209+ MessageType::File(content) => media_registration_with_info(
210+ identity,
211+ MediaKind::File,
212+ content.source(),
213+ content.info.as_deref(),
214+ ),
215+ MessageType::Audio(content) => media_registration_with_parts(
216+ identity,
217+ MediaKind::Audio,
218+ content.source(),
219+ content.info.as_ref().and_then(|info| info.mimetype.clone()),
220+ content
221+ .info
222+ .as_ref()
223+ .and_then(|info| info.size)
224+ .map(u64::from),
225+ None,
226+ ),
227+ _ => None,
228+ }
229+}
230+
231+fn media_registration_with_info<T: MediaInfo>(
232+ identity: &str,
233+ kind: MediaKind,
234+ source: Option<MediaSource>,
235+ info: Option<&T>,
236+) -> Option<MediaRegistration> {
237+ media_registration_with_parts(
238+ identity,
239+ kind,
240+ source,
241+ info.and_then(MediaInfo::mimetype),
242+ info.and_then(MediaInfo::declared_size),
243+ info.and_then(MediaInfo::thumbnail),
244+ )
245+}
246+
247+fn media_registration_with_parts(
248+ identity: &str,
249+ kind: MediaKind,
250+ source: Option<MediaSource>,
251+ mimetype: Option<String>,
252+ declared_size: Option<u64>,
253+ thumbnail: Option<MediaSourceInfo>,
254+) -> Option<MediaRegistration> {
255+ Some(MediaRegistration {
256+ id: media_id(identity, "media"),
257+ kind,
258+ full: MediaSourceInfo {
259+ source: source?,
260+ mimetype,
261+ declared_size,
262+ },
263+ thumbnail,
264+ })
265+}
266+
267+trait MediaInfo {
268+ fn mimetype(&self) -> Option<String>;
269+ fn declared_size(&self) -> Option<u64>;
270+ fn thumbnail(&self) -> Option<MediaSourceInfo>;
271+}
272+
273+macro_rules! impl_media_info {
274+ ($type:ty) => {
275+ impl MediaInfo for $type {
276+ fn mimetype(&self) -> Option<String> {
277+ self.mimetype.clone()
278+ }
279+
280+ fn declared_size(&self) -> Option<u64> {
281+ self.size.map(u64::from)
282+ }
283+
284+ fn thumbnail(&self) -> Option<MediaSourceInfo> {
285+ thumbnail_media_source(
286+ self.thumbnail_source.clone(),
287+ self.thumbnail_info.as_deref(),
288+ )
289+ }
290+ }
291+ };
292+}
293+
294+impl_media_info!(ImageInfo);
295+impl_media_info!(VideoInfo);
296+impl_media_info!(FileInfo);
297+
298+fn thumbnail_media_source(
299+ source: Option<MediaSource>,
300+ info: Option<&ThumbnailInfo>,
301+) -> Option<MediaSourceInfo> {
302+ source.map(|source| MediaSourceInfo {
303+ source,
304+ mimetype: info.and_then(|info| info.mimetype.clone()),
305+ declared_size: info.and_then(|info| info.size).map(u64::from),
306+ })
307+}
308+
309+#[cfg(test)]
310+mod tests {
311+ use matrix_sdk::ruma::{
312+ events::room::{ImageInfo, message::ImageMessageEventContent},
313+ mxc_uri,
314+ };
315+
316+ use super::*;
317+
318+ #[test]
319+ fn image_messages_keep_media_metadata() {
320+ let mut info = ImageInfo::new();
321+ info.mimetype = Some("image/png".to_owned());
322+ info.size = Some(65_536_u32.into());
323+ info.width = Some(640_u32.into());
324+ info.height = Some(480_u32.into());
325+ let content = ImageMessageEventContent::plain(
326+ "screen.png".to_owned(),
327+ mxc_uri!("mxc://example.org/screen").to_owned(),
328+ )
329+ .info(Box::new(info));
330+
331+ let media = media_from_message("$event:example.org", &MessageType::Image(content)).unwrap();
332+ assert_eq!(media.id.as_str(), "$event:example.org#media");
333+ assert_eq!(media.kind, MediaKind::Image);
334+ assert_eq!(media.filename, "screen.png");
335+ assert_eq!(media.caption, None);
336+ assert_eq!(media.mimetype.as_deref(), Some("image/png"));
337+ assert_eq!(media.size_bytes, Some(65_536));
338+ assert_eq!((media.width, media.height), (Some(640), Some(480)));
339+ }
340+}
Added file: src/normalise/member.rs+25 additions
@@ -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+}
Added file: src/normalise/mod.rs+10 additions
@@ -0,0 +1,10 @@
1+mod media;
2+mod member;
3+mod room;
4+mod timeline;
5+
6+pub(crate) use media::{MediaRegistration, MediaScope};
7+pub(crate) use member::member_summary;
8+pub(crate) use room::notification_mode;
9+pub(crate) use room::room_summaries;
10+pub(crate) use timeline::{normalise_timeline, normalise_timeline_diffs};
Added file: src/normalise/room.rs+71 additions
@@ -0,0 +1,71 @@
1+use futures_util::{StreamExt, stream};
2+use matrix_sdk::{Client, Room, RoomState};
3+
4+use crate::{RoomMembership, RoomNotificationMode, RoomSummary};
5+
6+pub(crate) async fn room_summaries(client: &Client) -> Vec<RoomSummary> {
7+ let mut summaries = stream::iter(client.rooms())
8+ .map(|room| async move { room_summary(&room).await })
9+ .buffer_unordered(16)
10+ .collect::<Vec<_>>()
11+ .await;
12+ summaries.sort_by(|left, right| {
13+ right
14+ .latest_event_timestamp_ms
15+ .cmp(&left.latest_event_timestamp_ms)
16+ .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
17+ .then_with(|| left.room_id.cmp(&right.room_id))
18+ });
19+ summaries
20+}
21+
22+async fn room_summary(room: &Room) -> RoomSummary {
23+ let name = room
24+ .display_name()
25+ .await
26+ .map(|name| name.to_string())
27+ .unwrap_or_else(|_| room.room_id().to_string());
28+ let custom_notification_mode = room.user_defined_notification_mode().await;
29+ let effective_notification_mode = match custom_notification_mode {
30+ Some(mode) => Some(mode),
31+ None => room.notification_mode().await,
32+ };
33+ RoomSummary {
34+ room_id: room.room_id().to_owned(),
35+ name,
36+ membership: match room.state() {
37+ RoomState::Joined => RoomMembership::Joined,
38+ RoomState::Invited => RoomMembership::Invited,
39+ RoomState::Left => RoomMembership::Left,
40+ RoomState::Knocked => RoomMembership::Knocked,
41+ RoomState::Banned => RoomMembership::Banned,
42+ },
43+ unread_messages: room.num_unread_messages(),
44+ unread_mentions: room.num_unread_mentions(),
45+ encrypted: room.encryption_state().is_encrypted(),
46+ space: room.is_space(),
47+ direct: room.is_direct().await.unwrap_or(false),
48+ favourite: room.is_favourite(),
49+ low_priority: room.is_low_priority(),
50+ avatar: room.avatar_url(),
51+ latest_event_timestamp_ms: room
52+ .latest_event_timestamp()
53+ .map(|value| value.get().into()),
54+ notification_mode: effective_notification_mode.map(notification_mode),
55+ notification_mode_custom: custom_notification_mode.is_some(),
56+ }
57+}
58+
59+pub(crate) const fn notification_mode(
60+ mode: matrix_sdk::notification_settings::RoomNotificationMode,
61+) -> RoomNotificationMode {
62+ match mode {
63+ matrix_sdk::notification_settings::RoomNotificationMode::AllMessages => {
64+ RoomNotificationMode::AllMessages
65+ }
66+ matrix_sdk::notification_settings::RoomNotificationMode::MentionsAndKeywordsOnly => {
67+ RoomNotificationMode::MentionsAndKeywordsOnly
68+ }
69+ matrix_sdk::notification_settings::RoomNotificationMode::Mute => RoomNotificationMode::Mute,
70+ }
71+}
Added file: src/normalise/timeline.rs+302 additions
@@ -0,0 +1,302 @@
1+use std::sync::Arc;
2+
3+use matrix_sdk_ui::{
4+ eyeball_im::VectorDiff,
5+ timeline::{
6+ EventSendState, MsgLikeKind, TimelineDetails, TimelineEventItemId,
7+ TimelineItem as SdkTimelineItem, TimelineItemContent, TimelineItemKind,
8+ VirtualTimelineItem,
9+ },
10+};
11+use ruma::{
12+ UserId,
13+ events::room::message::{MessageFormat, MessageType},
14+};
15+
16+use crate::{
17+ MessageMentions, Reaction, ReplyReference, SendState, ThreadSummary, TimelineDiff,
18+ TimelineItem, TimelineItemId, TimelineMessage,
19+};
20+
21+use super::media::{
22+ MediaRegistration, MediaScope, media_descriptor_for_sticker, media_from_message,
23+ media_identity, media_registration_for_item,
24+};
25+
26+pub(crate) struct NormalisedTimeline {
27+ pub(crate) items: Vec<TimelineItem>,
28+ pub(crate) media: Vec<MediaRegistration>,
29+}
30+
31+pub(crate) fn normalise_timeline<'a>(
32+ items: impl Iterator<Item = &'a Arc<SdkTimelineItem>>,
33+ current_user_id: Option<&UserId>,
34+ scope: &MediaScope,
35+) -> NormalisedTimeline {
36+ let mut normalised = NormalisedTimeline {
37+ items: Vec::new(),
38+ media: Vec::new(),
39+ };
40+ for item in items {
41+ if let Some(media) = media_registration_for_item(item, scope) {
42+ normalised.media.push(media);
43+ }
44+ normalised
45+ .items
46+ .push(normalise_timeline_item(item, current_user_id, scope));
47+ }
48+ normalised
49+}
50+
51+pub(crate) fn normalise_timeline_diffs(
52+ diffs: Vec<VectorDiff<Arc<SdkTimelineItem>>>,
53+ current_user_id: Option<&UserId>,
54+ scope: &MediaScope,
55+) -> NormalisedTimelineDiffs {
56+ let mut media = Vec::new();
57+ let diffs = diffs
58+ .into_iter()
59+ .map(|diff| {
60+ let diff = diff.map(|item| {
61+ if let Some(registration) = media_registration_for_item(&item, scope) {
62+ media.push(registration);
63+ }
64+ normalise_timeline_item(&item, current_user_id, scope)
65+ });
66+ match diff {
67+ VectorDiff::Append { values } => TimelineDiff::Append {
68+ values: values.into_iter().collect(),
69+ },
70+ VectorDiff::Clear => TimelineDiff::Clear,
71+ VectorDiff::PushFront { value } => TimelineDiff::PushFront { value },
72+ VectorDiff::PushBack { value } => TimelineDiff::PushBack { value },
73+ VectorDiff::PopFront => TimelineDiff::PopFront,
74+ VectorDiff::PopBack => TimelineDiff::PopBack,
75+ VectorDiff::Insert { index, value } => TimelineDiff::Insert { index, value },
76+ VectorDiff::Set { index, value } => TimelineDiff::Set { index, value },
77+ VectorDiff::Remove { index } => TimelineDiff::Remove { index },
78+ VectorDiff::Truncate { length } => TimelineDiff::Truncate { length },
79+ VectorDiff::Reset { values } => TimelineDiff::Reset {
80+ values: values.into_iter().collect(),
81+ },
82+ }
83+ })
84+ .collect();
85+ NormalisedTimelineDiffs { diffs, media }
86+}
87+
88+pub(crate) struct NormalisedTimelineDiffs {
89+ pub(crate) diffs: Vec<TimelineDiff>,
90+ pub(crate) media: Vec<MediaRegistration>,
91+}
92+
93+fn normalise_timeline_item(
94+ item: &SdkTimelineItem,
95+ current_user_id: Option<&UserId>,
96+ scope: &MediaScope,
97+) -> TimelineItem {
98+ match item.kind() {
99+ TimelineItemKind::Event(event) => TimelineItem::Message(Box::new(timeline_message(
100+ item,
101+ event,
102+ current_user_id,
103+ scope,
104+ ))),
105+ TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp)) => {
106+ TimelineItem::DayDivider {
107+ timestamp_ms: timestamp.get().into(),
108+ }
109+ }
110+ TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => TimelineItem::ReadMarker,
111+ TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => {
112+ TimelineItem::TimelineStart
113+ }
114+ }
115+}
116+
117+fn timeline_message(
118+ item: &SdkTimelineItem,
119+ event: &matrix_sdk_ui::timeline::EventTimelineItem,
120+ current_user_id: Option<&UserId>,
121+ scope: &MediaScope,
122+) -> TimelineMessage {
123+ let (body, formatted_body, mentions, edited, unable_to_decrypt, media) = match event.content() {
124+ TimelineItemContent::MsgLike(content) => match &content.kind {
125+ MsgLikeKind::Message(message) => (
126+ message.body().to_owned(),
127+ formatted_body(message.msgtype()),
128+ message.mentions().map(message_mentions),
129+ message.is_edited(),
130+ false,
131+ media_from_message(&media_identity(item, scope), message.msgtype()),
132+ ),
133+ MsgLikeKind::Sticker(sticker) => {
134+ let content = sticker.content();
135+ (
136+ content.body.clone(),
137+ None,
138+ None,
139+ false,
140+ false,
141+ Some(media_descriptor_for_sticker(
142+ &media_identity(item, scope),
143+ content,
144+ )),
145+ )
146+ }
147+ MsgLikeKind::Redacted => ("<message redacted>".into(), None, None, false, false, None),
148+ MsgLikeKind::UnableToDecrypt(_) => (
149+ "<unable to decrypt message>".into(),
150+ None,
151+ None,
152+ false,
153+ true,
154+ None,
155+ ),
156+ MsgLikeKind::Poll(_) => ("[poll]".into(), None, None, false, false, None),
157+ MsgLikeKind::LiveLocation(_) => {
158+ ("[live location]".into(), None, None, false, false, None)
159+ }
160+ MsgLikeKind::Other(other) => (
161+ format!("[{}]", other.event_type()),
162+ None,
163+ None,
164+ false,
165+ false,
166+ None,
167+ ),
168+ },
169+ other => (
170+ format!(
171+ "[{}]",
172+ other.event_type_str().unwrap_or_else(|| "event".into())
173+ ),
174+ None,
175+ None,
176+ false,
177+ false,
178+ None,
179+ ),
180+ };
181+ let reply_to = match event.content() {
182+ TimelineItemContent::MsgLike(content) => content.in_reply_to.as_ref().map(|reply| {
183+ let event_id = reply.event_id.to_owned();
184+ let embedded_sender = match &reply.event {
185+ TimelineDetails::Ready(event) => Some((
186+ event.sender.clone(),
187+ sender_name(&event.sender, &event.sender_profile),
188+ )),
189+ _ => None,
190+ };
191+ ReplyReference {
192+ event_id,
193+ sender_id: embedded_sender.as_ref().map(|(id, _)| id.clone()),
194+ sender_name: embedded_sender.map(|(_, name)| name),
195+ }
196+ }),
197+ _ => None,
198+ };
199+ let (thread_root, thread_summary) = match event.content() {
200+ TimelineItemContent::MsgLike(content) => (
201+ content.thread_root.clone(),
202+ content.thread_summary.as_ref().map(|summary| {
203+ let latest_event = match &summary.latest_event {
204+ TimelineDetails::Ready(event) => Some(event.as_ref()),
205+ _ => None,
206+ };
207+ ThreadSummary {
208+ reply_count: summary.num_replies,
209+ latest_event_id: latest_event.and_then(|event| match &event.identifier {
210+ TimelineEventItemId::EventId(event_id) => Some(event_id.clone()),
211+ TimelineEventItemId::TransactionId(_) => None,
212+ }),
213+ latest_event_timestamp_ms: latest_event
214+ .map(|event| event.timestamp.get().into()),
215+ }
216+ }),
217+ ),
218+ _ => (None, None),
219+ };
220+ let reactions = event
221+ .content()
222+ .reactions()
223+ .into_iter()
224+ .flat_map(|reactions| reactions.iter())
225+ .map(|(key, senders)| Reaction {
226+ key: key.clone(),
227+ count: senders.len(),
228+ own: current_user_id.is_some_and(|user_id| senders.contains_key(user_id)),
229+ senders: senders.keys().cloned().collect(),
230+ })
231+ .collect();
232+ let mut read_by: Vec<_> = event
233+ .read_receipts()
234+ .keys()
235+ .filter(|user_id| *user_id != event.sender())
236+ .cloned()
237+ .collect();
238+ read_by.sort();
239+
240+ TimelineMessage {
241+ item_id: TimelineItemId::new(item.unique_id().0.clone()),
242+ event_id: event.event_id().map(ToOwned::to_owned),
243+ transaction_id: event.transaction_id().map(ToOwned::to_owned),
244+ sender_id: event.sender().to_owned(),
245+ sender_name: sender_name(event.sender(), event.sender_profile()),
246+ body,
247+ formatted_body,
248+ mentions,
249+ thread_root,
250+ thread_summary,
251+ timestamp_ms: event.timestamp().get().into(),
252+ own: event.is_own(),
253+ send_state: match event.send_state() {
254+ None | Some(EventSendState::Sent { .. }) => SendState::Sent,
255+ Some(EventSendState::NotSentYet { progress: Some(_) }) => SendState::Sending,
256+ Some(EventSendState::NotSentYet { progress: None }) => SendState::Queued,
257+ Some(EventSendState::SendingFailed { is_recoverable, .. }) => SendState::Failed {
258+ retriable: *is_recoverable,
259+ },
260+ },
261+ edited,
262+ unable_to_decrypt,
263+ media,
264+ reply_to,
265+ reactions,
266+ read_by,
267+ }
268+}
269+
270+fn sender_name(
271+ sender: &UserId,
272+ sender_profile: &TimelineDetails<matrix_sdk_ui::timeline::Profile>,
273+) -> String {
274+ match sender_profile {
275+ TimelineDetails::Ready(profile) => profile
276+ .display_name
277+ .clone()
278+ .unwrap_or_else(|| sender.to_string()),
279+ _ => sender.to_string(),
280+ }
281+}
282+
283+fn formatted_body(message: &MessageType) -> Option<String> {
284+ let formatted = match message {
285+ MessageType::Text(content) => content.formatted.as_ref(),
286+ MessageType::Emote(content) => content.formatted.as_ref(),
287+ MessageType::Notice(content) => content.formatted.as_ref(),
288+ MessageType::Audio(content) => content.formatted_caption(),
289+ MessageType::File(content) => content.formatted_caption(),
290+ MessageType::Image(content) => content.formatted_caption(),
291+ MessageType::Video(content) => content.formatted_caption(),
292+ _ => None,
293+ }?;
294+ (formatted.format == MessageFormat::Html).then(|| formatted.body.clone())
295+}
296+
297+fn message_mentions(mentions: &ruma::events::Mentions) -> MessageMentions {
298+ MessageMentions {
299+ user_ids: mentions.user_ids.iter().cloned().collect(),
300+ room: mentions.room,
301+ }
302+}
Added file: src/transport.rs+1060 additions
@@ -0,0 +1,1060 @@
1+use std::sync::{
2+ Arc,
3+ atomic::{AtomicU64, Ordering},
4+};
5+use std::{
6+ fmt,
7+ pin::Pin,
8+ task::{Context, Poll},
9+};
10+
11+use matrix_sdk::{Client, RoomMemberships};
12+use matrix_sdk_ui::{
13+ notification_client::{
14+ NotificationClient, NotificationProcessSetup, NotificationStatus, RawNotificationEvent,
15+ },
16+ sync_service::SyncService,
17+};
18+use ruma::{
19+ EventId, RoomId,
20+ events::{
21+ AnyGlobalAccountDataEventContent, AnyRoomAccountDataEvent, GlobalAccountDataEventType,
22+ RoomAccountDataEventType,
23+ },
24+ serde::Raw,
25+};
26+use thiserror::Error;
27+use tokio::sync::{OnceCell, mpsc, oneshot, watch};
28+
29+use crate::{
30+ AccountCommand, Command, CommandId, ConnectionState, EncryptionState, Event, InteractiveAuth,
31+ InteractiveAuthOutcome, MemberFilter, MemberSummary, NotificationCommand, OwnDevice,
32+ PowerLevelChanges, PushNotification, PushNotificationEvent, PushNotificationStatus,
33+ RoomListFilter, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary,
34+ encryption::encryption_state,
35+ model::{PushNotificationRoom, PushNotificationSender},
36+ normalise::{member_summary, room_summaries},
37+};
38+
39+#[derive(Clone)]
40+pub struct ClientHandle {
41+ commands: mpsc::Sender<DriverRequest>,
42+ next_id: Arc<AtomicU64>,
43+ client: Client,
44+ connection: watch::Receiver<ConnectionState>,
45+ notification_client: Arc<OnceCell<NotificationClient>>,
46+ sync_service: Arc<SyncService>,
47+}
48+
49+impl fmt::Debug for ClientHandle {
50+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51+ formatter
52+ .debug_struct("ClientHandle")
53+ .finish_non_exhaustive()
54+ }
55+}
56+
57+impl ClientHandle {
58+ /// Current credentials after a refresh. Persist this snapshot atomically
59+ /// when `Event::SessionTokensChanged` arrives; it contains secrets.
60+ #[cfg(feature = "sqlite")]
61+ pub fn session_record(&self) -> Result<crate::SessionRecord, crate::AccountError> {
62+ crate::account::session_record(&self.client)
63+ }
64+
65+ /// Queue a command and return its correlation ID, not its result. Drain
66+ /// `EventStream` concurrently and match one terminal result event by ID.
67+ /// A successful timeline send only means the SDK queue accepted it.
68+ pub async fn send(&self, command: Command) -> Result<CommandId, SendCommandError> {
69+ let id = self.next_command_id();
70+ self.commands
71+ .send(DriverRequest::Command(CommandEnvelope { id, command }))
72+ .await
73+ .map_err(|_| SendCommandError::Closed)?;
74+ Ok(id)
75+ }
76+
77+ pub async fn delete_devices(
78+ &self,
79+ device_ids: Vec<ruma::OwnedDeviceId>,
80+ auth: Option<InteractiveAuth>,
81+ ) -> Result<InteractiveAuthOutcome, CommandResultError> {
82+ let (reply, result) = oneshot::channel();
83+ self.commands
84+ .send(DriverRequest::DeleteDevices {
85+ id: self.next_command_id(),
86+ device_ids,
87+ auth,
88+ reply,
89+ })
90+ .await
91+ .map_err(|_| SendCommandError::Closed)?;
92+ result.await.map_err(|_| CommandResultError::Closed)?
93+ }
94+
95+ pub async fn rename_device(
96+ &self,
97+ device_id: ruma::OwnedDeviceId,
98+ display_name: impl Into<String>,
99+ ) -> Result<CommandId, SendCommandError> {
100+ self.send(Command::Account(AccountCommand::RenameDevice {
101+ device_id,
102+ display_name: display_name.into(),
103+ }))
104+ .await
105+ }
106+
107+ pub async fn set_room_notification_mode(
108+ &self,
109+ room_id: ruma::OwnedRoomId,
110+ mode: RoomNotificationMode,
111+ ) -> Result<CommandId, SendCommandError> {
112+ self.send(Command::Notification(NotificationCommand::SetRoomMode {
113+ room_id,
114+ mode,
115+ }))
116+ .await
117+ }
118+
119+ pub async fn reset_room_notification_mode(
120+ &self,
121+ room_id: ruma::OwnedRoomId,
122+ ) -> Result<CommandId, SendCommandError> {
123+ self.send(Command::Notification(NotificationCommand::ResetRoomMode(
124+ room_id,
125+ )))
126+ .await
127+ }
128+
129+ pub async fn set_default_notification_mode(
130+ &self,
131+ encrypted: bool,
132+ one_to_one: bool,
133+ mode: RoomNotificationMode,
134+ ) -> Result<CommandId, SendCommandError> {
135+ self.send(Command::Notification(NotificationCommand::SetDefaultMode {
136+ encrypted,
137+ one_to_one,
138+ mode,
139+ }))
140+ .await
141+ }
142+
143+ pub async fn add_notification_keyword(
144+ &self,
145+ keyword: impl Into<String>,
146+ ) -> Result<CommandId, SendCommandError> {
147+ self.send(Command::Notification(NotificationCommand::AddKeyword(
148+ keyword.into(),
149+ )))
150+ .await
151+ }
152+
153+ pub async fn remove_notification_keyword(
154+ &self,
155+ keyword: impl Into<String>,
156+ ) -> Result<CommandId, SendCommandError> {
157+ self.send(Command::Notification(NotificationCommand::RemoveKeyword(
158+ keyword.into(),
159+ )))
160+ .await
161+ }
162+
163+ pub async fn open_room(
164+ &self,
165+ room_id: ruma::OwnedRoomId,
166+ ) -> Result<CommandId, SendCommandError> {
167+ self.send(Command::Room(crate::RoomCommand::Open(room_id)))
168+ .await
169+ }
170+
171+ pub async fn close_room(
172+ &self,
173+ room_id: ruma::OwnedRoomId,
174+ ) -> Result<CommandId, SendCommandError> {
175+ self.send(Command::Room(crate::RoomCommand::Close(room_id)))
176+ .await
177+ }
178+
179+ pub async fn open_thread(
180+ &self,
181+ room_id: ruma::OwnedRoomId,
182+ root_event_id: ruma::OwnedEventId,
183+ ) -> Result<CommandId, SendCommandError> {
184+ self.send(Command::Timeline(crate::TimelineCommand::OpenThread {
185+ room_id,
186+ root_event_id,
187+ }))
188+ .await
189+ }
190+
191+ pub async fn close_thread(
192+ &self,
193+ room_id: ruma::OwnedRoomId,
194+ root_event_id: ruma::OwnedEventId,
195+ ) -> Result<CommandId, SendCommandError> {
196+ self.send(Command::Timeline(crate::TimelineCommand::CloseThread {
197+ room_id,
198+ root_event_id,
199+ }))
200+ .await
201+ }
202+
203+ pub async fn join_room(
204+ &self,
205+ room: ruma::OwnedRoomOrAliasId,
206+ via: Vec<ruma::OwnedServerName>,
207+ ) -> Result<ruma::OwnedRoomId, CommandResultError> {
208+ self.room_result(crate::RoomCommand::Join { room, via })
209+ .await
210+ }
211+
212+ pub async fn leave_room(
213+ &self,
214+ room_id: ruma::OwnedRoomId,
215+ ) -> Result<CommandId, SendCommandError> {
216+ self.send(Command::Room(crate::RoomCommand::Leave(room_id)))
217+ .await
218+ }
219+
220+ pub async fn forget_room(
221+ &self,
222+ room_id: ruma::OwnedRoomId,
223+ ) -> Result<CommandId, SendCommandError> {
224+ self.send(Command::Room(crate::RoomCommand::Forget(room_id)))
225+ .await
226+ }
227+
228+ pub async fn accept_invite(
229+ &self,
230+ room_id: ruma::OwnedRoomId,
231+ ) -> Result<CommandId, SendCommandError> {
232+ self.send(Command::Room(crate::RoomCommand::AcceptInvite(room_id)))
233+ .await
234+ }
235+
236+ pub async fn reject_invite(
237+ &self,
238+ room_id: ruma::OwnedRoomId,
239+ ) -> Result<CommandId, SendCommandError> {
240+ self.send(Command::Room(crate::RoomCommand::RejectInvite(room_id)))
241+ .await
242+ }
243+
244+ pub async fn invite_user(
245+ &self,
246+ room_id: ruma::OwnedRoomId,
247+ user_id: ruma::OwnedUserId,
248+ ) -> Result<CommandId, SendCommandError> {
249+ self.send(Command::Room(crate::RoomCommand::InviteUser {
250+ room_id,
251+ user_id,
252+ }))
253+ .await
254+ }
255+
256+ pub async fn kick_user(
257+ &self,
258+ room_id: ruma::OwnedRoomId,
259+ user_id: ruma::OwnedUserId,
260+ reason: Option<String>,
261+ ) -> Result<CommandId, SendCommandError> {
262+ self.send(Command::Room(crate::RoomCommand::KickUser {
263+ room_id,
264+ user_id,
265+ reason,
266+ }))
267+ .await
268+ }
269+
270+ pub async fn ban_user(
271+ &self,
272+ room_id: ruma::OwnedRoomId,
273+ user_id: ruma::OwnedUserId,
274+ reason: Option<String>,
275+ ) -> Result<CommandId, SendCommandError> {
276+ self.send(Command::Room(crate::RoomCommand::BanUser {
277+ room_id,
278+ user_id,
279+ reason,
280+ }))
281+ .await
282+ }
283+
284+ pub async fn unban_user(
285+ &self,
286+ room_id: ruma::OwnedRoomId,
287+ user_id: ruma::OwnedUserId,
288+ reason: Option<String>,
289+ ) -> Result<CommandId, SendCommandError> {
290+ self.send(Command::Room(crate::RoomCommand::UnbanUser {
291+ room_id,
292+ user_id,
293+ reason,
294+ }))
295+ .await
296+ }
297+
298+ pub async fn set_power_levels(
299+ &self,
300+ room_id: ruma::OwnedRoomId,
301+ changes: PowerLevelChanges,
302+ ) -> Result<CommandId, SendCommandError> {
303+ self.send(Command::Room(crate::RoomCommand::SetPowerLevels {
304+ room_id,
305+ changes,
306+ }))
307+ .await
308+ }
309+
310+ pub async fn set_user_power_level(
311+ &self,
312+ room_id: ruma::OwnedRoomId,
313+ user_id: ruma::OwnedUserId,
314+ level: i64,
315+ ) -> Result<CommandId, SendCommandError> {
316+ self.send(Command::Room(crate::RoomCommand::SetUserPowerLevel {
317+ room_id,
318+ user_id,
319+ level,
320+ }))
321+ .await
322+ }
323+
324+ pub async fn create_room(
325+ &self,
326+ options: crate::CreateRoomOptions,
327+ ) -> Result<ruma::OwnedRoomId, CommandResultError> {
328+ self.room_result(crate::RoomCommand::Create(options)).await
329+ }
330+
331+ pub async fn create_direct(
332+ &self,
333+ user_id: ruma::OwnedUserId,
334+ ) -> Result<ruma::OwnedRoomId, CommandResultError> {
335+ self.room_result(crate::RoomCommand::CreateDirect(user_id))
336+ .await
337+ }
338+
339+ async fn room_result(
340+ &self,
341+ command: crate::RoomCommand,
342+ ) -> Result<ruma::OwnedRoomId, CommandResultError> {
343+ let (reply, result) = oneshot::channel();
344+ self.commands
345+ .send(DriverRequest::RoomResult {
346+ id: self.next_command_id(),
347+ command,
348+ reply,
349+ })
350+ .await
351+ .map_err(|_| SendCommandError::Closed)?;
352+ result.await.map_err(|_| CommandResultError::Closed)?
353+ }
354+
355+ pub async fn cancel_send(
356+ &self,
357+ room_id: ruma::OwnedRoomId,
358+ transaction_id: ruma::OwnedTransactionId,
359+ ) -> Result<CommandId, SendCommandError> {
360+ self.send(Command::Room(crate::RoomCommand::CancelSend {
361+ room_id,
362+ transaction_id,
363+ }))
364+ .await
365+ }
366+
367+ pub async fn retry_send(
368+ &self,
369+ room_id: ruma::OwnedRoomId,
370+ transaction_id: ruma::OwnedTransactionId,
371+ ) -> Result<CommandId, SendCommandError> {
372+ self.send(Command::Room(crate::RoomCommand::RetrySend {
373+ room_id,
374+ transaction_id,
375+ }))
376+ .await
377+ }
378+
379+ pub async fn upload_media(
380+ &self,
381+ timeline_id: crate::TimelineId,
382+ filename: String,
383+ mimetype: String,
384+ bytes: Vec<u8>,
385+ caption: Option<String>,
386+ ) -> Result<CommandId, SendCommandError> {
387+ self.send(Command::Media(crate::MediaCommand::Upload {
388+ timeline_id,
389+ filename,
390+ mimetype,
391+ bytes,
392+ caption,
393+ }))
394+ .await
395+ }
396+
397+ pub async fn upload_attachment(
398+ &self,
399+ upload: crate::AttachmentUpload,
400+ ) -> Result<CommandId, SendCommandError> {
401+ self.send(Command::Media(crate::MediaCommand::UploadAttachment(
402+ upload,
403+ )))
404+ .await
405+ }
406+
407+ pub async fn create_key_backup(&self) -> Result<CommandId, SendCommandError> {
408+ self.send(Command::Encryption(
409+ crate::EncryptionCommand::CreateKeyBackup,
410+ ))
411+ .await
412+ }
413+
414+ pub async fn enable_key_backup(&self) -> Result<CommandId, SendCommandError> {
415+ self.send(Command::Encryption(
416+ crate::EncryptionCommand::EnableKeyBackup,
417+ ))
418+ .await
419+ }
420+
421+ pub async fn disable_key_backup(
422+ &self,
423+ delete_from_server: bool,
424+ ) -> Result<CommandId, SendCommandError> {
425+ self.send(Command::Encryption(
426+ crate::EncryptionCommand::DisableKeyBackup { delete_from_server },
427+ ))
428+ .await
429+ }
430+
431+ pub async fn recover_key_backup(
432+ &self,
433+ recovery_key: impl Into<String>,
434+ ) -> Result<CommandId, SendCommandError> {
435+ self.send(Command::Encryption(
436+ crate::EncryptionCommand::RecoverKeyBackup(recovery_key.into()),
437+ ))
438+ .await
439+ }
440+
441+ pub async fn start_verification(
442+ &self,
443+ target: crate::VerificationTarget,
444+ ) -> Result<CommandId, SendCommandError> {
445+ self.send(Command::Encryption(
446+ crate::EncryptionCommand::StartVerification(target),
447+ ))
448+ .await
449+ }
450+
451+ pub fn try_send(&self, command: Command) -> Result<CommandId, SendCommandError> {
452+ let id = self.next_command_id();
453+ self.commands
454+ .try_send(DriverRequest::Command(CommandEnvelope { id, command }))
455+ .map_err(|error| match error {
456+ mpsc::error::TrySendError::Full(_) => SendCommandError::Full,
457+ mpsc::error::TrySendError::Closed(_) => SendCommandError::Closed,
458+ })?;
459+ Ok(id)
460+ }
461+
462+ fn next_command_id(&self) -> CommandId {
463+ CommandId::new(self.next_id.fetch_add(1, Ordering::Relaxed))
464+ }
465+
466+ pub async fn rooms(&self) -> Vec<RoomSummary> {
467+ room_summaries(&self.client).await
468+ }
469+
470+ pub async fn rooms_filtered(&self, filter: &RoomListFilter) -> Vec<RoomSummary> {
471+ let mut room_filter = filter.clone();
472+ let search = room_filter
473+ .search
474+ .take()
475+ .map(|search| search.to_lowercase());
476+
477+ self.rooms()
478+ .await
479+ .into_iter()
480+ .filter(|room| {
481+ room_filter.matches(room)
482+ && search.as_ref().is_none_or(|search| {
483+ room.name.to_lowercase().contains(search)
484+ || room.room_id.as_str().to_lowercase().contains(search)
485+ })
486+ })
487+ .collect()
488+ }
489+
490+ pub async fn devices(&self) -> Result<Vec<OwnDevice>, ClientQueryError> {
491+ let current_device_id = self.client.device_id();
492+ Ok(self
493+ .client
494+ .devices()
495+ .await
496+ .map_err(|source| ClientQueryError::Sdk(source.into()))?
497+ .devices
498+ .into_iter()
499+ .map(|device| OwnDevice {
500+ current: current_device_id == Some(device.device_id.as_ref()),
501+ device_id: device.device_id,
502+ display_name: device.display_name,
503+ last_seen_ip: device.last_seen_ip,
504+ last_seen_timestamp_ms: device.last_seen_ts.map(|timestamp| timestamp.get().into()),
505+ })
506+ .collect())
507+ }
508+
509+ pub async fn room_notification_settings(
510+ &self,
511+ room_id: &RoomId,
512+ ) -> Result<Option<RoomNotificationSettings>, ClientQueryError> {
513+ let room = self
514+ .client
515+ .get_room(room_id)
516+ .ok_or_else(|| ClientQueryError::RoomUnknown(room_id.to_owned()))?;
517+ let custom = room.user_defined_notification_mode().await;
518+ let mode = match custom {
519+ Some(mode) => mode,
520+ None => match room.notification_mode().await {
521+ Some(mode) => mode,
522+ None => return Ok(None),
523+ },
524+ };
525+ Ok(Some(RoomNotificationSettings {
526+ mode: crate::normalise::notification_mode(mode),
527+ custom: custom.is_some(),
528+ }))
529+ }
530+
531+ pub async fn notification_keywords(&self) -> Vec<String> {
532+ self.client
533+ .notification_settings()
534+ .await
535+ .enabled_keywords()
536+ .await
537+ .into_iter()
538+ .collect()
539+ }
540+
541+ pub async fn resolve_push_notification(
542+ &self,
543+ room_id: &RoomId,
544+ event_id: &EventId,
545+ ) -> Result<PushNotificationStatus, ClientQueryError> {
546+ let client = self
547+ .notification_client
548+ .get_or_try_init(|| async {
549+ NotificationClient::new(
550+ self.client.clone(),
551+ NotificationProcessSetup::SingleProcess {
552+ sync_service: self.sync_service.clone(),
553+ },
554+ )
555+ .await
556+ })
557+ .await
558+ .map_err(ClientQueryError::Notification)?;
559+ let status = client
560+ .get_notification(room_id, event_id)
561+ .await
562+ .map_err(ClientQueryError::Notification)?;
563+ Ok(normalise_push_notification(room_id, event_id, status))
564+ }
565+
566+ pub async fn members(
567+ &self,
568+ room_id: &RoomId,
569+ filter: MemberFilter,
570+ ) -> Result<Vec<MemberSummary>, ClientQueryError> {
571+ let room = self
572+ .client
573+ .get_room(room_id)
574+ .ok_or_else(|| ClientQueryError::RoomUnknown(room_id.to_owned()))?;
575+ let memberships = match filter {
576+ MemberFilter::Active => RoomMemberships::ACTIVE,
577+ MemberFilter::Joined => RoomMemberships::JOIN,
578+ MemberFilter::Invited => RoomMemberships::INVITE,
579+ // An empty mask is the SDK's unfiltered wildcard: it matches every
580+ // membership, including custom states that `all()` would exclude.
581+ MemberFilter::All => RoomMemberships::empty(),
582+ };
583+ Ok(room
584+ .members(memberships)
585+ .await
586+ .map_err(ClientQueryError::Sdk)?
587+ .into_iter()
588+ .map(member_summary)
589+ .collect())
590+ }
591+
592+ pub async fn room_power_levels(
593+ &self,
594+ room_id: &RoomId,
595+ ) -> Result<RoomPowerLevels, ClientQueryError> {
596+ let room = self
597+ .client
598+ .get_room(room_id)
599+ .ok_or_else(|| ClientQueryError::RoomUnknown(room_id.to_owned()))?;
600+ let levels = room
601+ .power_levels()
602+ .await
603+ .map_err(|source| ClientQueryError::Sdk(source.into()))?;
604+ Ok(RoomPowerLevels {
605+ ban: levels.ban.into(),
606+ invite: levels.invite.into(),
607+ kick: levels.kick.into(),
608+ redact: levels.redact.into(),
609+ events_default: levels.events_default.into(),
610+ state_default: levels.state_default.into(),
611+ users_default: levels.users_default.into(),
612+ room_notification: levels.notifications.room.into(),
613+ users: levels
614+ .users
615+ .into_iter()
616+ .map(|(user_id, level)| (user_id, level.into()))
617+ .collect(),
618+ events: levels
619+ .events
620+ .into_iter()
621+ .map(|(event_type, level)| (event_type.to_string(), level.into()))
622+ .collect(),
623+ })
624+ }
625+
626+ pub async fn pending_send_ids(
627+ &self,
628+ room_id: &RoomId,
629+ ) -> Result<Vec<ruma::OwnedTransactionId>, ClientQueryError> {
630+ let room = self
631+ .client
632+ .get_room(room_id)
633+ .ok_or_else(|| ClientQueryError::RoomUnknown(room_id.to_owned()))?;
634+ let (echoes, _) = room
635+ .send_queue()
636+ .subscribe()
637+ .await
638+ .map_err(ClientQueryError::SendQueue)?;
639+ Ok(echoes
640+ .into_iter()
641+ .filter(|echo| {
642+ matches!(
643+ echo.content,
644+ matrix_sdk::send_queue::LocalEchoContent::Event { .. }
645+ )
646+ })
647+ .map(|echo| echo.transaction_id)
648+ .collect())
649+ }
650+
651+ pub async fn global_account_data(
652+ &self,
653+ event_type: GlobalAccountDataEventType,
654+ ) -> Result<Option<Raw<AnyGlobalAccountDataEventContent>>, ClientQueryError> {
655+ self.client
656+ .account()
657+ .account_data_raw(event_type)
658+ .await
659+ .map_err(ClientQueryError::Sdk)
660+ }
661+
662+ pub async fn set_global_account_data(
663+ &self,
664+ event_type: GlobalAccountDataEventType,
665+ content: Raw<AnyGlobalAccountDataEventContent>,
666+ ) -> Result<(), ClientQueryError> {
667+ let (reply, result) = oneshot::channel();
668+ self.commands
669+ .send(DriverRequest::SetGlobalAccountData {
670+ event_type,
671+ content,
672+ reply,
673+ })
674+ .await
675+ .map_err(|_| ClientQueryError::EngineStopped)?;
676+ result.await.map_err(|_| ClientQueryError::EngineStopped)?
677+ }
678+
679+ pub async fn room_account_data(
680+ &self,
681+ room_id: &RoomId,
682+ event_type: RoomAccountDataEventType,
683+ ) -> Result<Option<Raw<AnyRoomAccountDataEvent>>, ClientQueryError> {
684+ let room = self
685+ .client
686+ .get_room(room_id)
687+ .ok_or_else(|| ClientQueryError::RoomUnknown(room_id.to_owned()))?;
688+ room.account_data(event_type)
689+ .await
690+ .map_err(ClientQueryError::Sdk)
691+ }
692+
693+ pub async fn set_room_account_data(
694+ &self,
695+ room_id: &RoomId,
696+ event_type: RoomAccountDataEventType,
697+ content: Raw<ruma::events::AnyRoomAccountDataEventContent>,
698+ ) -> Result<(), ClientQueryError> {
699+ let (reply, result) = oneshot::channel();
700+ self.commands
701+ .send(DriverRequest::SetRoomAccountData {
702+ room_id: room_id.to_owned(),
703+ event_type,
704+ content,
705+ reply,
706+ })
707+ .await
708+ .map_err(|_| ClientQueryError::EngineStopped)?;
709+ result.await.map_err(|_| ClientQueryError::EngineStopped)?
710+ }
711+
712+ #[must_use]
713+ pub fn connection_state(&self) -> watch::Receiver<ConnectionState> {
714+ self.connection.clone()
715+ }
716+
717+ #[must_use]
718+ pub fn encryption_state(&self) -> Option<EncryptionState> {
719+ let device_id = self.client.device_id()?.to_owned();
720+ let encryption = self.client.encryption();
721+ Some(encryption_state(
722+ device_id,
723+ encryption.recovery().state(),
724+ encryption.verification_state().get(),
725+ encryption.backups().state(),
726+ ))
727+ }
728+}
729+
730+#[derive(Debug, Eq, PartialEq)]
731+pub(crate) struct CommandEnvelope {
732+ pub(crate) id: CommandId,
733+ pub(crate) command: Command,
734+}
735+
736+#[derive(Debug)]
737+pub(crate) enum DriverRequest {
738+ Command(CommandEnvelope),
739+ DeleteDevices {
740+ id: CommandId,
741+ device_ids: Vec<ruma::OwnedDeviceId>,
742+ auth: Option<InteractiveAuth>,
743+ reply: oneshot::Sender<Result<InteractiveAuthOutcome, CommandResultError>>,
744+ },
745+ RoomResult {
746+ id: CommandId,
747+ command: crate::RoomCommand,
748+ reply: oneshot::Sender<Result<ruma::OwnedRoomId, CommandResultError>>,
749+ },
750+ SetGlobalAccountData {
751+ event_type: GlobalAccountDataEventType,
752+ content: Raw<AnyGlobalAccountDataEventContent>,
753+ reply: oneshot::Sender<Result<(), ClientQueryError>>,
754+ },
755+ SetRoomAccountData {
756+ room_id: ruma::OwnedRoomId,
757+ event_type: RoomAccountDataEventType,
758+ content: Raw<ruma::events::AnyRoomAccountDataEventContent>,
759+ reply: oneshot::Sender<Result<(), ClientQueryError>>,
760+ },
761+}
762+
763+#[derive(Debug)]
764+pub struct EventStream {
765+ events: mpsc::Receiver<Event>,
766+}
767+
768+impl EventStream {
769+ pub async fn recv(&mut self) -> Option<Event> {
770+ self.events.recv().await
771+ }
772+
773+ pub fn try_recv(&mut self) -> Result<Event, mpsc::error::TryRecvError> {
774+ self.events.try_recv()
775+ }
776+}
777+
778+impl futures_util::Stream for EventStream {
779+ type Item = Event;
780+
781+ fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
782+ self.events.poll_recv(context)
783+ }
784+}
785+
786+#[derive(Debug)]
787+pub(crate) struct DriverPort {
788+ commands: mpsc::Receiver<DriverRequest>,
789+ events: mpsc::Sender<Event>,
790+ connection: watch::Sender<ConnectionState>,
791+}
792+
793+#[derive(Clone, Debug)]
794+pub(crate) struct EventSender(mpsc::Sender<Event>);
795+
796+impl EventSender {
797+ pub(crate) async fn send(&self, event: Event) -> Result<(), SendEventError> {
798+ self.0.send(event).await.map_err(|_| SendEventError::Closed)
799+ }
800+
801+ /// Resolves once every [`EventStream`] receiver has been dropped, so the
802+ /// engine can notice a lost event consumer inside a `select!`.
803+ pub(crate) async fn closed(&self) {
804+ self.0.closed().await;
805+ }
806+}
807+
808+impl DriverPort {
809+ pub(crate) fn event_sender(&self) -> EventSender {
810+ EventSender(self.events.clone())
811+ }
812+
813+ pub(crate) fn connection_sender(&self) -> watch::Sender<ConnectionState> {
814+ self.connection.clone()
815+ }
816+
817+ pub async fn recv(&mut self) -> Option<DriverRequest> {
818+ self.commands.recv().await
819+ }
820+
821+ pub async fn send(&self, event: Event) -> Result<(), SendEventError> {
822+ self.event_sender().send(event).await
823+ }
824+}
825+
826+#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
827+pub enum SendCommandError {
828+ #[error("the command channel is full")]
829+ Full,
830+ #[error("the engine has stopped")]
831+ Closed,
832+}
833+
834+#[derive(Debug, Error)]
835+pub enum CommandResultError {
836+ #[error(transparent)]
837+ Send(#[from] SendCommandError),
838+ #[error("the engine stopped before the operation completed")]
839+ Closed,
840+ #[error("{0:?}")]
841+ Failed(crate::CommandFailure),
842+}
843+
844+#[derive(Debug, Error)]
845+pub enum ClientQueryError {
846+ #[error("room {0} is not available in the local store")]
847+ RoomUnknown(ruma::OwnedRoomId),
848+ #[error("Matrix query failed")]
849+ Sdk(#[source] matrix_sdk::Error),
850+ #[error("reading the send queue failed")]
851+ SendQueue(#[source] matrix_sdk::send_queue::RoomSendQueueError),
852+ #[error("resolving the push notification failed")]
853+ Notification(#[source] matrix_sdk_ui::notification_client::Error),
854+ #[error("the engine stopped before the operation completed")]
855+ EngineStopped,
856+}
857+
858+#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
859+pub(crate) enum SendEventError {
860+ #[error("the event receiver has closed")]
861+ Closed,
862+}
863+
864+#[must_use]
865+pub(crate) fn transport_pair(
866+ client: Client,
867+ sync_service: Arc<SyncService>,
868+ command_capacity: usize,
869+ event_capacity: usize,
870+) -> (ClientHandle, DriverPort, EventStream) {
871+ let (command_tx, command_rx) = mpsc::channel(command_capacity);
872+ let (event_tx, event_rx) = mpsc::channel(event_capacity);
873+ let (connection_tx, connection_rx) = watch::channel(ConnectionState::Idle);
874+ (
875+ ClientHandle {
876+ commands: command_tx,
877+ next_id: Arc::new(AtomicU64::new(1)),
878+ client,
879+ connection: connection_rx,
880+ notification_client: Arc::new(OnceCell::new()),
881+ sync_service,
882+ },
883+ DriverPort {
884+ commands: command_rx,
885+ events: event_tx,
886+ connection: connection_tx,
887+ },
888+ EventStream { events: event_rx },
889+ )
890+}
891+
892+fn normalise_push_notification(
893+ room_id: &RoomId,
894+ event_id: &EventId,
895+ status: NotificationStatus,
896+) -> PushNotificationStatus {
897+ match status {
898+ NotificationStatus::Event(item) => {
899+ let event = match &item.raw_event {
900+ RawNotificationEvent::Timeline(event) => {
901+ PushNotificationEvent::Timeline(event.clone())
902+ }
903+ RawNotificationEvent::Invite(event) => PushNotificationEvent::Invite(event.clone()),
904+ };
905+ PushNotificationStatus::Event(Box::new(PushNotification {
906+ event_id: event_id.to_owned(),
907+ event,
908+ sender: PushNotificationSender {
909+ user_id: item.event.sender().to_owned(),
910+ display_name: item.sender_display_name,
911+ avatar_url: item.sender_avatar_url.map(Into::into),
912+ name_ambiguous: item.is_sender_name_ambiguous,
913+ },
914+ room: PushNotificationRoom {
915+ room_id: room_id.to_owned(),
916+ name: item.room_computed_display_name,
917+ avatar_url: item.room_avatar_url.map(Into::into),
918+ canonical_alias: item
919+ .room_canonical_alias
920+ .as_deref()
921+ .and_then(|alias| alias.parse().ok()),
922+ topic: item.room_topic,
923+ encrypted: item.is_room_encrypted,
924+ direct: item.room_is_dm,
925+ joined_members_count: item.joined_members_count,
926+ service_members: item
927+ .service_members
928+ .into_iter()
929+ .filter_map(|user_id| user_id.parse().ok())
930+ .collect(),
931+ active_service_members_count: item.active_service_members_count,
932+ space: item.is_space,
933+ },
934+ noisy: item.is_noisy,
935+ has_mention: item.has_mention,
936+ thread_id: item.thread_id,
937+ }))
938+ }
939+ NotificationStatus::EventNotFound => PushNotificationStatus::NotFound,
940+ NotificationStatus::EventFilteredOut => PushNotificationStatus::FilteredOut,
941+ NotificationStatus::EventRedacted => PushNotificationStatus::Redacted,
942+ }
943+}
944+
945+#[cfg(test)]
946+mod tests {
947+ use matrix_sdk::{Client, SessionMeta, SessionTokens, authentication::matrix::MatrixSession};
948+
949+ use crate::{ConnectionState, LifecycleCommand};
950+
951+ use super::*;
952+
953+ async fn sync_service(client: Client) -> Arc<SyncService> {
954+ Arc::new(SyncService::builder(client).build().await.unwrap())
955+ }
956+
957+ async fn test_client() -> Client {
958+ let client = Client::builder()
959+ .homeserver_url("http://localhost")
960+ .build()
961+ .await
962+ .unwrap();
963+ client
964+ .restore_session(MatrixSession {
965+ meta: SessionMeta {
966+ user_id: "@test:localhost".parse().unwrap(),
967+ device_id: "DEVICE".into(),
968+ },
969+ tokens: SessionTokens {
970+ access_token: "access_token".to_owned(),
971+ refresh_token: None,
972+ },
973+ })
974+ .await
975+ .unwrap();
976+ client
977+ }
978+
979+ #[tokio::test]
980+ async fn transports_are_isolated() {
981+ let first_client = test_client().await;
982+ let second_client = test_client().await;
983+ let (first_handle, mut first_driver, mut first_events) =
984+ transport_pair(first_client.clone(), sync_service(first_client).await, 2, 2);
985+ let (_second_handle, mut second_driver, mut second_events) = transport_pair(
986+ second_client.clone(),
987+ sync_service(second_client).await,
988+ 2,
989+ 2,
990+ );
991+
992+ let id = first_handle
993+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
994+ .await
995+ .unwrap();
996+ let request = first_driver.recv().await.unwrap();
997+ assert!(matches!(
998+ request,
999+ DriverRequest::Command(CommandEnvelope {
1000+ id: received_id,
1001+ command: Command::Lifecycle(LifecycleCommand::Shutdown),
1002+ }) if received_id == id
1003+ ));
1004+ assert!(second_driver.commands.try_recv().is_err());
1005+
1006+ first_driver
1007+ .send(Event::ConnectionChanged(ConnectionState::Online))
1008+ .await
1009+ .unwrap();
1010+ assert_eq!(
1011+ first_events.recv().await,
1012+ Some(Event::ConnectionChanged(ConnectionState::Online))
1013+ );
1014+ assert!(second_events.try_recv().is_err());
1015+ }
1016+
1017+ #[tokio::test]
1018+ async fn bounded_command_channel_reports_pressure() {
1019+ let client = test_client().await;
1020+ let (handle, _driver, _events) =
1021+ transport_pair(client.clone(), sync_service(client).await, 1, 1);
1022+
1023+ handle
1024+ .try_send(Command::Lifecycle(LifecycleCommand::Shutdown))
1025+ .unwrap();
1026+ assert_eq!(
1027+ handle.try_send(Command::Lifecycle(LifecycleCommand::Shutdown)),
1028+ Err(SendCommandError::Full)
1029+ );
1030+ }
1031+
1032+ #[tokio::test]
1033+ async fn cloned_handles_share_command_id_sequence() {
1034+ let client = test_client().await;
1035+ let (handle, mut driver, _events) =
1036+ transport_pair(client.clone(), sync_service(client).await, 1, 1);
1037+ let cloned = handle.clone();
1038+ let first = handle
1039+ .try_send(Command::Lifecycle(LifecycleCommand::Shutdown))
1040+ .unwrap();
1041+ assert_eq!(first.get(), 1);
1042+ assert_eq!(
1043+ cloned.try_send(Command::Lifecycle(LifecycleCommand::Shutdown)),
1044+ Err(SendCommandError::Full)
1045+ );
1046+ driver.recv().await.unwrap();
1047+
1048+ let third = cloned
1049+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
1050+ .await
1051+ .unwrap();
1052+ assert_eq!(third.get(), 3);
1053+ driver.recv().await.unwrap();
1054+
1055+ let fourth = handle
1056+ .try_send(Command::Lifecycle(LifecycleCommand::Shutdown))
1057+ .unwrap();
1058+ assert_eq!(fourth.get(), 4);
1059+ }
1060+}
Added file: src/uiaa.rs+234 additions
@@ -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+ } => formatter
119+ .debug_struct("Password")
120+ .field("session", &session.as_ref().map(|_| "..."))
121+ .finish_non_exhaustive(),
122+ Self::ReCaptcha {
123+ response: _,
124+ session,
125+ } => formatter
126+ .debug_struct("ReCaptcha")
127+ .field("session", &session.as_ref().map(|_| "..."))
128+ .finish_non_exhaustive(),
129+ Self::Dummy { session } => formatter
130+ .debug_struct("Dummy")
131+ .field("session", &session.as_ref().map(|_| "..."))
132+ .finish(),
133+ Self::FallbackAcknowledgement { session: _ } => formatter
134+ .debug_struct("FallbackAcknowledgement")
135+ .field("session", &"...")
136+ .finish(),
137+ Self::OAuth { session } => formatter
138+ .debug_struct("OAuth")
139+ .field("session", &session.as_ref().map(|_| "..."))
140+ .finish(),
141+ Self::Stage {
142+ auth_type,
143+ session,
144+ data: _,
145+ } => formatter
146+ .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+ formatter
168+ .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: info
185+ .flows
186+ .iter()
187+ .map(|flow| InteractiveAuthFlow {
188+ stages: flow
189+ .stages
190+ .iter()
191+ .map(|stage| stage.as_str().to_owned())
192+ .collect(),
193+ })
194+ .collect(),
195+ completed: info
196+ .completed
197+ .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: info
203+ .auth_error
204+ .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+}
Added file: src/upload.rs+163 additions
@@ -0,0 +1,163 @@
1+use std::time::Duration;
2+
3+use matrix_sdk::{
4+ attachment::{
5+ AttachmentConfig, AttachmentInfo, BaseAudioInfo, BaseFileInfo, BaseImageInfo,
6+ BaseVideoInfo, Thumbnail,
7+ },
8+ ruma::events::room::message::TextMessageEventContent,
9+};
10+
11+use crate::{AttachmentUpload, CommandErrorKind, UploadDetails, error::CommandError};
12+
13+pub(crate) fn prepare_upload(
14+ mut upload: AttachmentUpload,
15+ max_media_bytes: usize,
16+) -> Result<(AttachmentUpload, mime::Mime, AttachmentConfig), CommandError> {
17+ if upload.bytes.len() > max_media_bytes {
18+ return Err(CommandError::new(
19+ CommandErrorKind::MediaTooLarge,
20+ "attachment exceeds the configured byte limit",
21+ ));
22+ }
23+ let mimetype: mime::Mime = upload
24+ .mimetype
25+ .parse()
26+ .map_err(|_| CommandError::new(CommandErrorKind::Other, "media MIME type is invalid"))?;
27+
28+ let size = ruma::UInt::try_from(upload.bytes.len()).map_err(|_| {
29+ CommandError::new(
30+ CommandErrorKind::MediaTooLarge,
31+ "attachment size is not representable",
32+ )
33+ })?;
34+ let info = match upload.details.as_ref() {
35+ Some(UploadDetails::Image {
36+ width,
37+ height,
38+ blurhash,
39+ }) if mimetype.type_() == mime::IMAGE => AttachmentInfo::Image(BaseImageInfo {
40+ width: width.map(Into::into),
41+ height: height.map(Into::into),
42+ size: Some(size),
43+ blurhash: blurhash.clone(),
44+ ..Default::default()
45+ }),
46+ Some(UploadDetails::Video {
47+ width,
48+ height,
49+ duration_ms,
50+ blurhash,
51+ }) if mimetype.type_() == mime::VIDEO => AttachmentInfo::Video(BaseVideoInfo {
52+ width: width.map(Into::into),
53+ height: height.map(Into::into),
54+ duration: duration_ms.map(Duration::from_millis),
55+ size: Some(size),
56+ blurhash: blurhash.clone(),
57+ }),
58+ Some(UploadDetails::Audio { duration_ms }) if mimetype.type_() == mime::AUDIO => {
59+ AttachmentInfo::Audio(BaseAudioInfo {
60+ duration: duration_ms.map(Duration::from_millis),
61+ size: Some(size),
62+ ..Default::default()
63+ })
64+ }
65+ Some(UploadDetails::Voice {
66+ duration_ms,
67+ waveform,
68+ }) if mimetype.type_() == mime::AUDIO => {
69+ if waveform.iter().any(|sample| *sample > 1024) {
70+ return Err(CommandError::new(
71+ CommandErrorKind::Other,
72+ "voice waveform samples must be at most 1024",
73+ ));
74+ }
75+ AttachmentInfo::Voice(BaseAudioInfo {
76+ duration: Some(Duration::from_millis(*duration_ms)),
77+ size: Some(size),
78+ waveform: Some(
79+ waveform
80+ .iter()
81+ .map(|sample| f32::from(*sample) / 1024.0)
82+ .collect(),
83+ ),
84+ })
85+ }
86+ Some(UploadDetails::File)
87+ if mimetype.type_() != mime::IMAGE
88+ && mimetype.type_() != mime::VIDEO
89+ && mimetype.type_() != mime::AUDIO =>
90+ {
91+ AttachmentInfo::File(BaseFileInfo { size: Some(size) })
92+ }
93+ None => match mimetype.type_() {
94+ mime::IMAGE => AttachmentInfo::Image(BaseImageInfo {
95+ size: Some(size),
96+ ..Default::default()
97+ }),
98+ mime::VIDEO => AttachmentInfo::Video(BaseVideoInfo {
99+ size: Some(size),
100+ ..Default::default()
101+ }),
102+ mime::AUDIO => AttachmentInfo::Audio(BaseAudioInfo {
103+ size: Some(size),
104+ ..Default::default()
105+ }),
106+ _ => AttachmentInfo::File(BaseFileInfo { size: Some(size) }),
107+ },
108+ _ => {
109+ return Err(CommandError::new(
110+ CommandErrorKind::Other,
111+ "attachment metadata does not match its MIME type",
112+ ));
113+ }
114+ };
115+ if upload.thumbnail.is_some() && mimetype.type_() == mime::AUDIO {
116+ return Err(CommandError::new(
117+ CommandErrorKind::MediaUnavailable,
118+ "audio attachments do not support thumbnails",
119+ ));
120+ }
121+ let thumbnail = upload
122+ .thumbnail
123+ .take()
124+ .map(|thumbnail| {
125+ if thumbnail.bytes.len() > max_media_bytes
126+ || thumbnail.width == 0
127+ || thumbnail.height == 0
128+ {
129+ return Err(CommandError::new(
130+ CommandErrorKind::MediaTooLarge,
131+ "thumbnail size or dimensions are invalid",
132+ ));
133+ }
134+ let content_type: mime::Mime = thumbnail.mimetype.parse().map_err(|_| {
135+ CommandError::new(CommandErrorKind::Other, "thumbnail MIME type is invalid")
136+ })?;
137+ if content_type.type_() != mime::IMAGE {
138+ return Err(CommandError::new(
139+ CommandErrorKind::MediaUnavailable,
140+ "thumbnail MIME type must be an image",
141+ ));
142+ }
143+ let size = ruma::UInt::try_from(thumbnail.bytes.len()).map_err(|_| {
144+ CommandError::new(
145+ CommandErrorKind::MediaTooLarge,
146+ "thumbnail size is not representable",
147+ )
148+ })?;
149+ Ok(Thumbnail {
150+ data: thumbnail.bytes,
151+ content_type,
152+ width: thumbnail.width.into(),
153+ height: thumbnail.height.into(),
154+ size,
155+ })
156+ })
157+ .transpose()?;
158+ let config = AttachmentConfig::new()
159+ .info(info)
160+ .thumbnail(thumbnail)
161+ .caption(upload.caption.clone().map(TextMessageEventContent::plain));
162+ Ok((upload, mimetype, config))
163+}
Added file: tests/account.rs+92 additions
@@ -0,0 +1,92 @@
1+#![cfg(feature = "sqlite")]
2+use std::time::{Duration, SystemTime};
3+
4+use macltok::{
5+ Command, EngineConfig, Event, LifecycleCommand, PasswordLogin, Session, SessionRecord,
6+ StoreConfig,
7+};
8+use matrix_sdk::{ruma::api::client::sync::sync_events::v5, test_utils::mocks::MatrixMockServer};
9+
10+#[tokio::test]
11+async fn password_session_round_trips_through_an_encrypted_store() {
12+ let server = MatrixMockServer::new().await;
13+ server
14+ .mock_versions()
15+ .with_simplified_sliding_sync()
16+ .ok()
17+ .mount()
18+ .await;
19+ server.mock_login().ok().mount().await;
20+ server
21+ .mock_sliding_sync()
22+ .ok(v5::Response::new("1".to_owned()))
23+ .mount()
24+ .await;
25+ let store_path = temporary_store_path();
26+ std::fs::create_dir_all(&store_path).unwrap();
27+ let config = EngineConfig::default().with_initial_history_limit(0);
28+ let session = Session::login_password(
29+ PasswordLogin {
30+ homeserver: server.uri(),
31+ username: "example".into(),
32+ password: "wordpass".into(),
33+ device_display_name: Some("macltok test".into()),
34+ },
35+ StoreConfig::new(&store_path, "test store passphrase"),
36+ )
37+ .await
38+ .unwrap();
39+ let encoded = serde_json::to_vec(&session.record()).unwrap();
40+ let record: SessionRecord = serde_json::from_slice(&encoded).unwrap();
41+ assert_eq!(
42+ record.homeserver().trim_end_matches('/'),
43+ server.uri().trim_end_matches('/')
44+ );
45+
46+ let (engine, handle, mut events) = session.build(config.clone()).await.unwrap();
47+ let running = tokio::spawn(engine.run());
48+ receive_authenticated(&mut events).await;
49+ handle
50+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
51+ .await
52+ .unwrap();
53+ running.await.unwrap().unwrap();
54+
55+ let session = Session::restore(
56+ record,
57+ StoreConfig::new(&store_path, "test store passphrase"),
58+ )
59+ .await
60+ .unwrap();
61+ let (engine, handle, mut events) = session.build(config).await.unwrap();
62+ let running = tokio::spawn(engine.run());
63+ receive_authenticated(&mut events).await;
64+ handle
65+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
66+ .await
67+ .unwrap();
68+ running.await.unwrap().unwrap();
69+
70+ std::fs::remove_dir_all(store_path).unwrap();
71+}
72+
73+async fn receive_authenticated(events: &mut macltok::EventStream) {
74+ tokio::time::timeout(Duration::from_secs(2), async {
75+ while let Some(event) = events.recv().await {
76+ if matches!(event, Event::Authenticated { .. }) {
77+ return;
78+ }
79+ }
80+ panic!("event stream closed before authentication");
81+ })
82+ .await
83+ .expect("authentication event did not arrive");
84+}
85+
86+fn temporary_store_path() -> std::path::PathBuf {
87+ let nonce = SystemTime::now()
88+ .duration_since(SystemTime::UNIX_EPOCH)
89+ .unwrap()
90+ .as_nanos();
91+ std::env::temp_dir().join(format!("macltok-account-{nonce}"))
92+}
Added file: tests/commands.rs+224 additions
@@ -0,0 +1,224 @@
1+use std::time::Duration;
2+
3+use macltok::{
4+ ClientHandle, Command, CommandErrorKind, CommandId, CommandKind, Engine, EngineConfig,
5+ EngineError, Event, EventStream, LifecycleCommand, MessageContent, MessageEditContent,
6+ RichText, RoomCommand, TimelineCommand,
7+};
8+use matrix_sdk::{
9+ ruma::{RoomId, api::client::sync::sync_events::v5, event_id, room_id},
10+ test_utils::mocks::MatrixMockServer,
11+};
12+use tokio::task::JoinHandle;
13+
14+#[tokio::test]
15+async fn empty_message_bodies_report_their_command_ids() {
16+ let room_id = room_id!("!empty-bodies:localhost");
17+ let (server, handle, mut events, running) = start_engine(room_id).await;
18+ open_room(&handle, &mut events, room_id).await;
19+
20+ let commands = [
21+ TimelineCommand::Send {
22+ timeline_id: room_id.to_owned().into(),
23+ content: MessageContent::text(String::new()),
24+ },
25+ TimelineCommand::Send {
26+ timeline_id: room_id.to_owned().into(),
27+ content: MessageContent::emote(RichText::plain(" "), Default::default()),
28+ },
29+ TimelineCommand::Send {
30+ timeline_id: room_id.to_owned().into(),
31+ content: MessageContent::reply(
32+ event_id!("$reply:localhost").to_owned(),
33+ RichText::plain("\n"),
34+ Default::default(),
35+ ),
36+ },
37+ TimelineCommand::Edit {
38+ timeline_id: room_id.to_owned().into(),
39+ event_id: event_id!("$edit:localhost").to_owned(),
40+ content: MessageEditContent::text("\t"),
41+ },
42+ ];
43+
44+ for command in commands {
45+ let id = handle.send(Command::Timeline(command)).await.unwrap();
46+ let failure = receive_command_failure(&mut events, id).await;
47+ assert_eq!(failure.id, id);
48+ assert_eq!(failure.command, CommandKind::Timeline);
49+ assert_eq!(failure.kind, CommandErrorKind::EmptyBody);
50+ }
51+
52+ stop_engine(handle, &mut events, running).await;
53+ drop(server);
54+}
55+
56+#[tokio::test]
57+async fn open_resets_the_timeline_before_succeeding() {
58+ let room_id = room_id!("!open-order:localhost");
59+ let (server, handle, mut events, running) = start_engine(room_id).await;
60+
61+ open_room(&handle, &mut events, room_id).await;
62+
63+ stop_engine(handle, &mut events, running).await;
64+ drop(server);
65+}
66+
67+#[tokio::test]
68+async fn same_room_sends_complete_in_submission_order() {
69+ let room_id = room_id!("!send-order:localhost");
70+ let (server, handle, mut events, running) = start_engine(room_id).await;
71+ open_room(&handle, &mut events, room_id).await;
72+
73+ let first_id = handle
74+ .send(Command::Timeline(TimelineCommand::Send {
75+ timeline_id: room_id.to_owned().into(),
76+ content: MessageContent::text("first"),
77+ }))
78+ .await
79+ .unwrap();
80+ let second_id = handle
81+ .send(Command::Timeline(TimelineCommand::Send {
82+ timeline_id: room_id.to_owned().into(),
83+ content: MessageContent::text("second"),
84+ }))
85+ .await
86+ .unwrap();
87+
88+ assert_eq!(
89+ receive_timeline_completion(&mut events, &[first_id, second_id]).await,
90+ vec![first_id, second_id]
91+ );
92+
93+ stop_engine(handle, &mut events, running).await;
94+ drop(server);
95+}
96+
97+async fn start_engine(
98+ room_id: &RoomId,
99+) -> (
100+ MatrixMockServer,
101+ ClientHandle,
102+ EventStream,
103+ JoinHandle<Result<(), EngineError>>,
104+) {
105+ let server = MatrixMockServer::new().await;
106+ server
107+ .mock_sliding_sync()
108+ .ok(v5::Response::new("1".to_owned()))
109+ .mount()
110+ .await;
111+ let client = server.client_builder().build().await;
112+ server.mock_room_state_encryption().plain().mount().await;
113+ server.sync_joined_room(&client, room_id).await;
114+ let config = EngineConfig::default().with_initial_history_limit(0);
115+ let (engine, handle, events) = Engine::from_client(client, config).await.unwrap();
116+ let running = tokio::spawn(engine.run());
117+ (server, handle, events, running)
118+}
119+
120+async fn open_room(handle: &ClientHandle, events: &mut EventStream, room_id: &RoomId) -> CommandId {
121+ let id = handle
122+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))
123+ .await
124+ .unwrap();
125+ let mut reset_received = false;
126+
127+ tokio::time::timeout(Duration::from_secs(2), async {
128+ loop {
129+ match events.recv().await.expect("event stream closed") {
130+ Event::TimelineReset {
131+ timeline_id: reset_timeline,
132+ ..
133+ } if reset_timeline.room_id() == room_id => reset_received = true,
134+ Event::CommandSucceeded {
135+ id: succeeded_id,
136+ command: CommandKind::Room,
137+ } if succeeded_id == id => {
138+ assert!(reset_received);
139+ break;
140+ }
141+ Event::CommandFailed(failure) if failure.id == id => {
142+ panic!("opening room failed {failure:?}")
143+ }
144+ _ => {}
145+ }
146+ }
147+ })
148+ .await
149+ .expect("room did not open");
150+ id
151+}
152+
153+async fn receive_command_failure(
154+ events: &mut EventStream,
155+ expected_id: CommandId,
156+) -> macltok::CommandFailure {
157+ tokio::time::timeout(Duration::from_secs(2), async {
158+ loop {
159+ match events.recv().await.expect("event stream closed") {
160+ Event::CommandFailed(failure) if failure.id == expected_id => return failure,
161+ Event::CommandSucceeded { id, .. } if id == expected_id => {
162+ panic!("command unexpectedly succeeded")
163+ }
164+ _ => {}
165+ }
166+ }
167+ })
168+ .await
169+ .expect("command failure did not arrive")
170+}
171+
172+async fn receive_timeline_completion(
173+ events: &mut EventStream,
174+ expected_ids: &[CommandId],
175+) -> Vec<CommandId> {
176+ tokio::time::timeout(Duration::from_secs(2), async {
177+ let mut completed = Vec::new();
178+ while completed.len() < expected_ids.len() {
179+ match events.recv().await.expect("event stream closed") {
180+ Event::CommandSucceeded {
181+ id,
182+ command: CommandKind::Timeline,
183+ } if expected_ids.contains(&id) => completed.push(id),
184+ Event::CommandFailed(failure) if expected_ids.contains(&failure.id) => {
185+ panic!("timeline command failed {failure:?}")
186+ }
187+ _ => {}
188+ }
189+ }
190+ completed
191+ })
192+ .await
193+ .expect("timeline commands did not complete")
194+}
195+
196+async fn stop_engine(
197+ handle: ClientHandle,
198+ events: &mut EventStream,
199+ running: JoinHandle<Result<(), EngineError>>,
200+) {
201+ let id = handle
202+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
203+ .await
204+ .unwrap();
205+ tokio::time::timeout(Duration::from_secs(2), async {
206+ let mut succeeded = false;
207+ while let Some(event) = events.recv().await {
208+ match event {
209+ Event::CommandSucceeded {
210+ id: succeeded_id,
211+ command: CommandKind::Lifecycle,
212+ } if succeeded_id == id => succeeded = true,
213+ Event::Stopped => {
214+ assert!(succeeded);
215+ break;
216+ }
217+ _ => {}
218+ }
219+ }
220+ })
221+ .await
222+ .expect("engine did not stop");
223+ running.await.unwrap().unwrap();
224+}
Added file: tests/engine.rs+708 additions
@@ -0,0 +1,708 @@
1+use std::time::Duration;
2+
3+use macltok::{
4+ Command, CommandErrorKind, Engine, EngineConfig, Event, HistoryState, LifecycleCommand,
5+ MediaCommand, MessageContent, RoomCommand, TimelineCommand, TimelineDiff, TimelineId,
6+ TimelineItem,
7+};
8+use matrix_sdk::{
9+ ruma::{api::client::sync::sync_events::v5, event_id, mxc_uri, room_id},
10+ test_utils::mocks::{MatrixMockServer, RoomMessagesResponseTemplate},
11+};
12+use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};
13+
14+#[tokio::test]
15+async fn opens_a_room_and_sends_messages() {
16+ let server = MatrixMockServer::new().await;
17+ server
18+ .mock_sliding_sync()
19+ .ok(v5::Response::new("1".to_owned()))
20+ .mount()
21+ .await;
22+ let client = server.client_builder().build().await;
23+ let room_id = room_id!("!toolkit:localhost");
24+ server.mock_room_state_encryption().plain().mount().await;
25+ server.sync_joined_room(&client, room_id).await;
26+ server
27+ .mock_room_send()
28+ .body_matches_partial_json(serde_json::json!({ "body": "hello from macltok" }))
29+ .ok(event_id!("$sent:localhost"))
30+ .expect(1)
31+ .mount()
32+ .await;
33+
34+ let config = EngineConfig::default().with_initial_history_limit(0);
35+ let sync_client = client.clone();
36+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
37+ let running = tokio::spawn(engine.run());
38+
39+ handle
40+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))
41+ .await
42+ .unwrap();
43+ receive_until(
44+ &mut events,
45+ |event| matches!(event, Event::TimelineReset { timeline_id: reset, .. } if reset.room_id() == room_id),
46+ )
47+ .await;
48+
49+ handle
50+ .send(Command::Timeline(TimelineCommand::Send {
51+ timeline_id: room_id.to_owned().into(),
52+ content: MessageContent::text("hello from macltok"),
53+ }))
54+ .await
55+ .unwrap();
56+ tokio::time::timeout(Duration::from_secs(2), async {
57+ loop {
58+ let sent = server
59+ .received_requests()
60+ .await
61+ .unwrap()
62+ .iter()
63+ .any(|request| {
64+ request.url.path().contains("/send/m.room.message/")
65+ && serde_json::from_slice::<serde_json::Value>(&request.body)
66+ .is_ok_and(|body| body["body"] == "hello from macltok")
67+ });
68+ if sent {
69+ break;
70+ }
71+ tokio::task::yield_now().await;
72+ }
73+ })
74+ .await
75+ .expect("message did not reach the homeserver");
76+
77+ server.mock_media_download().ok_image().mount().await;
78+ server.mock_authed_media_download().ok_image().mount().await;
79+ server
80+ .sync_room(
81+ &sync_client,
82+ JoinedRoomBuilder::new(room_id).add_timeline_event(
83+ EventFactory::new()
84+ .image(
85+ "image.jpg".into(),
86+ mxc_uri!("mxc://localhost/image").to_owned(),
87+ )
88+ .sender(&ALICE),
89+ ),
90+ )
91+ .await;
92+ let media_event = receive_until(&mut events, |event| {
93+ event_messages(event).any(|message| message.media.is_some())
94+ })
95+ .await;
96+ let media_id = event_messages(&media_event)
97+ .find_map(|message| message.media.as_ref().map(|media| media.id.clone()))
98+ .unwrap();
99+ handle
100+ .send(Command::Media(MediaCommand::Fetch {
101+ id: media_id.clone(),
102+ size: macltok::MediaSize::Full,
103+ }))
104+ .await
105+ .unwrap();
106+ let media = receive_until(&mut events, |event| {
107+ matches!(event, Event::MediaReady { media_id: received, .. } if received == &media_id)
108+ })
109+ .await;
110+ assert!(matches!(media, Event::MediaReady { bytes, .. } if !bytes.is_empty()));
111+
112+ handle
113+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
114+ .await
115+ .unwrap();
116+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;
117+ running.await.unwrap().unwrap();
118+}
119+
120+#[tokio::test]
121+async fn keeps_open_room_timelines_independent() {
122+ let server = MatrixMockServer::new().await;
123+ server
124+ .mock_sliding_sync()
125+ .ok(v5::Response::new("1".to_owned()))
126+ .mount()
127+ .await;
128+ let client = server.client_builder().build().await;
129+ let sync_client = client.clone();
130+ let first_id = room_id!("!first:localhost");
131+ let second_id = room_id!("!second:localhost");
132+ server.mock_room_state_encryption().plain().mount().await;
133+ server.sync_joined_room(&client, first_id).await;
134+ server.sync_joined_room(&client, second_id).await;
135+ let config = EngineConfig::default().with_initial_history_limit(0);
136+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
137+ let running = tokio::spawn(engine.run());
138+
139+ handle
140+ .send(Command::Room(RoomCommand::Open(first_id.to_owned())))
141+ .await
142+ .unwrap();
143+ handle
144+ .send(Command::Room(RoomCommand::Open(second_id.to_owned())))
145+ .await
146+ .unwrap();
147+ receive_until(
148+ &mut events,
149+ |event| matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == first_id),
150+ )
151+ .await;
152+ receive_until(
153+ &mut events,
154+ |event| matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == second_id),
155+ )
156+ .await;
157+ wait_for_room_subscriptions(&server, &[first_id, second_id]).await;
158+
159+ let factory = EventFactory::new();
160+ server
161+ .sync_room(
162+ &sync_client,
163+ JoinedRoomBuilder::new(first_id)
164+ .add_timeline_event(factory.text_msg("first update").sender(&ALICE)),
165+ )
166+ .await;
167+ server
168+ .sync_room(
169+ &sync_client,
170+ JoinedRoomBuilder::new(second_id)
171+ .add_timeline_event(factory.text_msg("second update").sender(&ALICE)),
172+ )
173+ .await;
174+
175+ let mut first_updated = false;
176+ let mut second_updated = false;
177+ tokio::time::timeout(Duration::from_secs(2), async {
178+ while !(first_updated && second_updated) {
179+ let event = events.recv().await.expect("event stream closed");
180+ if let Event::TimelineChanged { timeline_id, .. } = &event {
181+ first_updated |= timeline_id.room_id() == first_id
182+ && event_messages(&event).any(|message| message.body == "first update");
183+ second_updated |= timeline_id.room_id() == second_id
184+ && event_messages(&event).any(|message| message.body == "second update");
185+ }
186+ }
187+ })
188+ .await
189+ .expect("both retained rooms did not update");
190+
191+ handle
192+ .send(Command::Room(RoomCommand::Close(first_id.to_owned())))
193+ .await
194+ .unwrap();
195+ server
196+ .sync_room(
197+ &sync_client,
198+ JoinedRoomBuilder::new(second_id)
199+ .add_timeline_event(factory.text_msg("still live").sender(&ALICE)),
200+ )
201+ .await;
202+ receive_until(&mut events, |event| {
203+ matches!(event, Event::TimelineChanged { timeline_id, .. } if timeline_id.room_id() == second_id)
204+ && event_messages(event).any(|message| message.body == "still live")
205+ })
206+ .await;
207+
208+ handle
209+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
210+ .await
211+ .unwrap();
212+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;
213+ running.await.unwrap().unwrap();
214+}
215+
216+#[tokio::test]
217+async fn resync_replaces_timeline_before_ack_and_preserves_following_diffs() {
218+ let server = MatrixMockServer::new().await;
219+ server
220+ .mock_sliding_sync()
221+ .ok(v5::Response::new("1".to_owned()))
222+ .mount()
223+ .await;
224+ let client = server.client_builder().build().await;
225+ let sync_client = client.clone();
226+ let room_id = room_id!("!resync:localhost");
227+ server.mock_room_state_encryption().plain().mount().await;
228+ server.sync_joined_room(&client, room_id).await;
229+ let config = EngineConfig::default().with_initial_history_limit(0);
230+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
231+ let running = tokio::spawn(engine.run());
232+
233+ handle
234+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))
235+ .await
236+ .unwrap();
237+ receive_until(&mut events, |event| {
238+ matches!(event,
239+ Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id
240+ )
241+ })
242+ .await;
243+ server
244+ .sync_room(
245+ &sync_client,
246+ JoinedRoomBuilder::new(room_id)
247+ .add_timeline_event(EventFactory::new().text_msg("before reset").sender(&ALICE)),
248+ )
249+ .await;
250+ receive_until(&mut events, |event| {
251+ event_messages(event).any(|message| message.body == "before reset")
252+ })
253+ .await;
254+
255+ let id = handle
256+ .send(Command::Timeline(TimelineCommand::Resync {
257+ timeline_id: room_id.to_owned().into(),
258+ }))
259+ .await
260+ .unwrap();
261+ let mut reset = None;
262+ tokio::time::timeout(Duration::from_secs(2), async {
263+ loop {
264+ match events.recv().await.expect("event stream closed") {
265+ Event::TimelineReset { timeline_id, items } if timeline_id.room_id() == room_id => {
266+ reset = Some(items);
267+ }
268+ Event::CommandSucceeded { id: completed, .. } if completed == id => {
269+ assert!(reset.is_some(), "resync completed before its reset");
270+ break;
271+ }
272+ Event::CommandFailed(failure) if failure.id == id => {
273+ panic!("resync failed: {failure:?}")
274+ }
275+ _ => {}
276+ }
277+ }
278+ })
279+ .await
280+ .expect("resync did not complete");
281+ let mut items = reset.unwrap();
282+ assert!(items.iter().any(|item| matches!(item,
283+ TimelineItem::Message(message) if message.body == "before reset"
284+ )));
285+
286+ server
287+ .sync_room(
288+ &sync_client,
289+ JoinedRoomBuilder::new(room_id)
290+ .add_timeline_event(EventFactory::new().text_msg("after reset").sender(&ALICE)),
291+ )
292+ .await;
293+ tokio::time::timeout(Duration::from_secs(2), async {
294+ loop {
295+ if let Event::TimelineChanged { timeline_id, diffs } =
296+ events.recv().await.expect("event stream closed")
297+ && timeline_id.room_id() == room_id
298+ {
299+ for diff in diffs {
300+ diff.try_apply(&mut items)
301+ .expect("resynced snapshot accepts following diffs");
302+ }
303+ if items.iter().any(|item| {
304+ matches!(item,
305+ TimelineItem::Message(message) if message.body == "after reset"
306+ )
307+ }) {
308+ break;
309+ }
310+ }
311+ }
312+ })
313+ .await
314+ .expect("resynced timeline did not update");
315+
316+ handle
317+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
318+ .await
319+ .unwrap();
320+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;
321+ running.await.unwrap().unwrap();
322+}
323+
324+#[tokio::test]
325+async fn closes_and_reopens_a_room_in_command_order() {
326+ let server = MatrixMockServer::new().await;
327+ server
328+ .mock_sliding_sync()
329+ .ok(v5::Response::new("1".to_owned()))
330+ .mount()
331+ .await;
332+ let client = server.client_builder().build().await;
333+ let room_id = room_id!("!reopen:localhost");
334+ server.mock_room_state_encryption().plain().mount().await;
335+ server.sync_joined_room(&client, room_id).await;
336+ let config = EngineConfig::default().with_initial_history_limit(0);
337+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
338+ let running = tokio::spawn(engine.run());
339+
340+ let initial_open = handle
341+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))
342+ .await
343+ .unwrap();
344+ receive_until(
345+ &mut events,
346+ |event| matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id),
347+ )
348+ .await;
349+ receive_until(
350+ &mut events,
351+ |event| matches!(event, Event::CommandSucceeded { id, .. } if id == &initial_open),
352+ )
353+ .await;
354+
355+ let close = handle
356+ .send(Command::Room(RoomCommand::Close(room_id.to_owned())))
357+ .await
358+ .unwrap();
359+ let replacement_open = handle
360+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))
361+ .await
362+ .unwrap();
363+ let second_close = handle
364+ .send(Command::Room(RoomCommand::Close(room_id.to_owned())))
365+ .await
366+ .unwrap();
367+ let final_open = handle
368+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))
369+ .await
370+ .unwrap();
371+ let expected = [close, replacement_open, second_close, final_open];
372+ let mut completed = 0;
373+ let mut resets = 0;
374+ tokio::time::timeout(Duration::from_secs(3), async {
375+ while completed < expected.len() {
376+ match events.recv().await.expect("event stream closed") {
377+ Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id => {
378+ resets += 1;
379+ assert!(resets <= 2, "a room generation reset more than once");
380+ }
381+ Event::CommandSucceeded { id, .. } if expected.contains(&id) => {
382+ assert_eq!(
383+ id, expected[completed],
384+ "room lifecycle commands completed out of order"
385+ );
386+ if completed == 1 || completed == 3 {
387+ assert_eq!(
388+ resets,
389+ completed / 2 + 1,
390+ "room open completed before timeline reset"
391+ );
392+ }
393+ completed += 1;
394+ }
395+ Event::CommandFailed(failure) if expected.contains(&failure.id) => {
396+ panic!("room lifecycle command failed: {failure:?}");
397+ }
398+ _ => {}
399+ }
400+ }
401+ })
402+ .await
403+ .expect("room lifecycle commands did not complete");
404+ handle
405+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
406+ .await
407+ .unwrap();
408+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;
409+ running.await.unwrap().unwrap();
410+}
411+
412+#[tokio::test]
413+async fn thread_command_waits_for_opening_timeline() {
414+ let server = MatrixMockServer::new().await;
415+ server
416+ .mock_sliding_sync()
417+ .ok(v5::Response::new("1".to_owned()))
418+ .mount()
419+ .await;
420+ let client = server.client_builder().build().await;
421+ let room_id = room_id!("!thread:localhost");
422+ let root = event_id!("$thread-root:localhost");
423+ server.mock_room_state_encryption().plain().mount().await;
424+ server.sync_joined_room(&client, room_id).await;
425+ let (engine, handle, mut events) = Engine::from_client(
426+ client,
427+ EngineConfig::default().with_initial_history_limit(0),
428+ )
429+ .await
430+ .unwrap();
431+ let running = tokio::spawn(engine.run());
432+ let room_open = handle
433+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))
434+ .await
435+ .unwrap();
436+ receive_until(
437+ &mut events,
438+ |event| matches!(event, Event::CommandSucceeded { id, .. } if *id == room_open),
439+ )
440+ .await;
441+
442+ let open = handle
443+ .send(Command::Timeline(TimelineCommand::OpenThread {
444+ room_id: room_id.to_owned(),
445+ root_event_id: root.to_owned(),
446+ }))
447+ .await
448+ .unwrap();
449+ let send = handle
450+ .send(Command::Timeline(TimelineCommand::Send {
451+ timeline_id: TimelineId::thread(room_id.to_owned(), root.to_owned()),
452+ content: MessageContent::text(""),
453+ }))
454+ .await
455+ .unwrap();
456+ let resync = handle
457+ .send(Command::Timeline(TimelineCommand::Resync {
458+ timeline_id: TimelineId::thread(room_id.to_owned(), root.to_owned()),
459+ }))
460+ .await
461+ .unwrap();
462+ let mut open_succeeded = false;
463+ tokio::time::timeout(Duration::from_secs(3), async {
464+ loop {
465+ match events.recv().await.expect("event stream closed") {
466+ Event::CommandSucceeded { id, .. } if id == open => open_succeeded = true,
467+ Event::CommandFailed(failure) if failure.id == send => {
468+ assert!(open_succeeded, "thread command completed before opening");
469+ assert_eq!(failure.kind, CommandErrorKind::EmptyBody);
470+ break;
471+ }
472+ Event::CommandFailed(failure) if failure.id == open => {
473+ panic!("thread open failed: {failure:?}");
474+ }
475+ _ => {}
476+ }
477+ }
478+ })
479+ .await
480+ .expect("thread commands did not complete");
481+ let mut reset_seen = false;
482+ tokio::time::timeout(Duration::from_secs(3), async {
483+ loop {
484+ match events.recv().await.expect("event stream closed") {
485+ Event::TimelineReset { timeline_id, .. }
486+ if timeline_id.thread_root().is_some_and(|event| event == root) =>
487+ {
488+ reset_seen = true
489+ }
490+ Event::CommandSucceeded { id, .. } if id == resync => {
491+ assert!(reset_seen, "thread resync completed before its reset");
492+ break;
493+ }
494+ Event::CommandFailed(failure) if failure.id == resync => {
495+ panic!("thread resync failed: {failure:?}")
496+ }
497+ _ => {}
498+ }
499+ }
500+ })
501+ .await
502+ .expect("thread resync did not complete");
503+ handle
504+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
505+ .await
506+ .unwrap();
507+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;
508+ running.await.unwrap().unwrap();
509+}
510+
511+#[tokio::test]
512+async fn pagination_reports_and_remembers_start_of_history() {
513+ let server = MatrixMockServer::new().await;
514+ server
515+ .mock_sliding_sync()
516+ .ok(v5::Response::new("1".to_owned()))
517+ .mount()
518+ .await;
519+ let client = server.client_builder().build().await;
520+ let sync_client = client.clone();
521+ let room_id = room_id!("!history:localhost");
522+ server.mock_room_state_encryption().plain().mount().await;
523+ server.sync_joined_room(&client, room_id).await;
524+ let config = EngineConfig::default().with_initial_history_limit(0);
525+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
526+ let running = tokio::spawn(engine.run());
527+
528+ handle
529+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))
530+ .await
531+ .unwrap();
532+ receive_until(
533+ &mut events,
534+ |event| matches!(event, Event::TimelineReset { timeline_id: reset, .. } if reset.room_id() == room_id),
535+ )
536+ .await;
537+
538+ server
539+ .sync_room(
540+ &sync_client,
541+ JoinedRoomBuilder::new(room_id)
542+ .add_timeline_event(EventFactory::new().text_msg("latest").sender(&ALICE))
543+ .set_timeline_limited()
544+ .set_timeline_prev_batch("before-latest"),
545+ )
546+ .await;
547+ receive_until(&mut events, |event| {
548+ matches!(event, Event::TimelineChanged { timeline_id: changed, .. } if changed.room_id() == room_id)
549+ && event_messages(event).any(|message| message.body == "latest")
550+ })
551+ .await;
552+
553+ server
554+ .mock_room_messages()
555+ .match_from("before-latest")
556+ .ok(RoomMessagesResponseTemplate::default())
557+ .mock_once()
558+ .mount()
559+ .await;
560+ let mut reached_start = false;
561+ for _ in 0..3 {
562+ handle
563+ .send(Command::Timeline(TimelineCommand::Paginate {
564+ timeline_id: room_id.to_owned().into(),
565+ limit: 10,
566+ }))
567+ .await
568+ .unwrap();
569+ receive_history(&mut events, room_id, HistoryState::Loading).await;
570+ match receive_history_terminal(&mut events, room_id).await {
571+ HistoryState::StartReached => {
572+ reached_start = true;
573+ break;
574+ }
575+ HistoryState::Idle => {}
576+ state => panic!("unexpected pagination state {state:?}"),
577+ }
578+ }
579+ assert!(
580+ reached_start,
581+ "pagination did not reach the mocked room start"
582+ );
583+
584+ handle
585+ .send(Command::Timeline(TimelineCommand::Paginate {
586+ timeline_id: room_id.to_owned().into(),
587+ limit: 10,
588+ }))
589+ .await
590+ .unwrap();
591+ receive_history(&mut events, room_id, HistoryState::StartReached).await;
592+
593+ handle
594+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
595+ .await
596+ .unwrap();
597+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;
598+ running.await.unwrap().unwrap();
599+}
600+
601+async fn receive_history(
602+ events: &mut macltok::EventStream,
603+ room_id: &matrix_sdk::ruma::RoomId,
604+ expected: HistoryState,
605+) {
606+ receive_until(events, |event| {
607+ matches!(event, Event::HistoryChanged { timeline_id: changed, state } if changed.room_id() == room_id && state == &expected)
608+ })
609+ .await;
610+}
611+
612+fn event_messages(event: &Event) -> std::vec::IntoIter<&macltok::TimelineMessage> {
613+ let items = match event {
614+ Event::TimelineReset { items, .. } => items.iter().collect::<Vec<_>>(),
615+ Event::TimelineChanged { diffs, .. } => {
616+ diffs.iter().flat_map(diff_items).collect::<Vec<_>>()
617+ }
618+ _ => Vec::new(),
619+ };
620+ items
621+ .into_iter()
622+ .filter_map(|item| match item {
623+ TimelineItem::Message(message) => Some(message.as_ref()),
624+ _ => None,
625+ })
626+ .collect::<Vec<_>>()
627+ .into_iter()
628+}
629+
630+fn diff_items(diff: &TimelineDiff) -> Vec<&TimelineItem> {
631+ match diff {
632+ TimelineDiff::Append { values } | TimelineDiff::Reset { values } => values.iter().collect(),
633+ TimelineDiff::PushFront { value }
634+ | TimelineDiff::PushBack { value }
635+ | TimelineDiff::Insert { value, .. }
636+ | TimelineDiff::Set { value, .. } => vec![value],
637+ TimelineDiff::Clear
638+ | TimelineDiff::PopFront
639+ | TimelineDiff::PopBack
640+ | TimelineDiff::Remove { .. }
641+ | TimelineDiff::Truncate { .. } => Vec::new(),
642+ _ => Vec::new(),
643+ }
644+}
645+
646+async fn receive_history_terminal(
647+ events: &mut macltok::EventStream,
648+ room_id: &matrix_sdk::ruma::RoomId,
649+) -> HistoryState {
650+ match receive_until(events, |event| {
651+ matches!(event, Event::HistoryChanged { timeline_id: changed, state } if changed.room_id() == room_id && !matches!(state, HistoryState::Loading))
652+ })
653+ .await
654+ {
655+ Event::HistoryChanged { state, .. } => state,
656+ _ => unreachable!(),
657+ }
658+}
659+
660+async fn wait_for_room_subscriptions(
661+ server: &MatrixMockServer,
662+ room_ids: &[&matrix_sdk::ruma::RoomId],
663+) {
664+ tokio::time::timeout(Duration::from_secs(2), async {
665+ loop {
666+ let found = server
667+ .received_requests()
668+ .await
669+ .unwrap()
670+ .iter()
671+ .filter_map(|request| {
672+ serde_json::from_slice::<serde_json::Value>(&request.body).ok()
673+ })
674+ .filter_map(|body| {
675+ body.get("room_subscriptions")
676+ .and_then(|value| value.as_object())
677+ .cloned()
678+ })
679+ .any(|subscriptions| {
680+ room_ids
681+ .iter()
682+ .all(|room_id| subscriptions.contains_key(room_id.as_str()))
683+ });
684+ if found {
685+ break;
686+ }
687+ tokio::task::yield_now().await;
688+ }
689+ })
690+ .await
691+ .expect("Sliding Sync did not retain every open room subscription");
692+}
693+
694+async fn receive_until(
695+ events: &mut macltok::EventStream,
696+ predicate: impl Fn(&Event) -> bool,
697+) -> Event {
698+ tokio::time::timeout(Duration::from_secs(2), async {
699+ while let Some(event) = events.recv().await {
700+ if predicate(&event) {
701+ return event;
702+ }
703+ }
704+ panic!("event stream closed before the expected event");
705+ })
706+ .await
707+ .expect("expected event did not arrive")
708+}
Added file: tests/media.rs+943 additions
@@ -0,0 +1,943 @@
1+use std::{sync::Arc, time::Duration};
2+
3+use macltok::{
4+ AttachmentUpload, ClientHandle, Command, CommandErrorKind, Engine, EngineConfig, EngineError,
5+ Event, EventStream, LifecycleCommand, MediaCommand, MediaId, MediaSize, RoomCommand,
6+ TimelineDiff, TimelineItem, UploadDetails, UploadThumbnail,
7+};
8+use matrix_sdk::{
9+ ruma::{
10+ api::client::sync::sync_events::v5,
11+ event_id,
12+ events::room::{
13+ MediaSource,
14+ message::{
15+ AudioMessageEventContent, FileInfo, FileMessageEventContent,
16+ ImageMessageEventContent, MessageType, RoomMessageEventContent,
17+ },
18+ },
19+ media::Method,
20+ mxc_uri, room_id,
21+ },
22+ test_utils::mocks::MatrixMockServer,
23+};
24+use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};
25+use serde_json::{Value, json};
26+use wiremock::{
27+ Mock, ResponseTemplate,
28+ matchers::{method, path_regex},
29+};
30+
31+struct MediaHarness {
32+ server: MatrixMockServer,
33+ client: matrix_sdk::Client,
34+ handle: ClientHandle,
35+ events: EventStream,
36+ running: tokio::task::JoinHandle<Result<(), EngineError>>,
37+ media_id: MediaId,
38+}
39+
40+#[tokio::test]
41+async fn full_size_uses_full_source() {
42+ let mut harness = start(file_with_thumbnail()).await;
43+ mount_downloads(&harness.server).await;
44+
45+ let bytes = fetch_ready(&mut harness, MediaSize::Full).await;
46+ assert_eq!(&*bytes, b"binaryjpegfullimagedata");
47+ assert!(
48+ media_paths(&harness.server)
49+ .await
50+ .iter()
51+ .any(|path| path.ends_with("/localhost/full"))
52+ );
53+
54+ stop(harness).await;
55+}
56+
57+#[tokio::test]
58+async fn simultaneous_fetches_share_one_download() {
59+ let mut harness = start(plain_image()).await;
60+ Mock::given(method("GET"))
61+ .and(path_regex("/download/localhost/image$"))
62+ .respond_with(
63+ ResponseTemplate::new(200)
64+ .set_body_bytes(b"shared image bytes".to_vec())
65+ .set_delay(Duration::from_millis(300)),
66+ )
67+ .mount(harness.server.server())
68+ .await;
69+
70+ let mut pending = Vec::new();
71+ for _ in 0..2 {
72+ pending.push(
73+ harness
74+ .handle
75+ .send(Command::Media(MediaCommand::Fetch {
76+ id: harness.media_id.clone(),
77+ size: MediaSize::Full,
78+ }))
79+ .await
80+ .unwrap(),
81+ );
82+ }
83+ let mut ready = false;
84+ tokio::time::timeout(Duration::from_secs(5), async {
85+ loop {
86+ match harness.events.recv().await.expect("event stream closed") {
87+ Event::MediaReady {
88+ media_id, bytes, ..
89+ } if media_id == harness.media_id => {
90+ assert_eq!(&*bytes, b"shared image bytes");
91+ ready = true;
92+ }
93+ Event::CommandSucceeded { id, .. } => pending.retain(|waiting| *waiting != id),
94+ Event::CommandFailed(failure) if pending.contains(&failure.id) => {
95+ panic!("fetch failed: {failure:?}");
96+ }
97+ _ => {}
98+ }
99+ if ready && pending.is_empty() {
100+ break;
101+ }
102+ }
103+ })
104+ .await
105+ .expect("both fetches did not complete");
106+ let downloads = harness
107+ .server
108+ .received_requests()
109+ .await
110+ .unwrap()
111+ .iter()
112+ .filter(|request| request.url.path().ends_with("/download/localhost/image"))
113+ .count();
114+ assert_eq!(downloads, 1, "duplicate in-flight media download");
115+ stop(harness).await;
116+}
117+
118+#[tokio::test]
119+async fn downloaded_media_over_limit_is_rejected() {
120+ let mut harness = start_with_config(
121+ plain_image(),
122+ EngineConfig::default()
123+ .with_initial_history_limit(0)
124+ .with_max_media_bytes(4),
125+ )
126+ .await;
127+ Mock::given(method("GET"))
128+ .and(path_regex("/download/localhost/image$"))
129+ .respond_with(ResponseTemplate::new(200).set_body_bytes(b"too large".to_vec()))
130+ .mount(harness.server.server())
131+ .await;
132+ let id = harness
133+ .handle
134+ .send(Command::Media(MediaCommand::Fetch {
135+ id: harness.media_id.clone(),
136+ size: MediaSize::Full,
137+ }))
138+ .await
139+ .unwrap();
140+ receive_until(&mut harness.events, |event| {
141+ matches!(event, Event::CommandFailed(failure) if failure.id == id && failure.kind == CommandErrorKind::MediaTooLarge)
142+ })
143+ .await;
144+ stop(harness).await;
145+}
146+
147+#[tokio::test]
148+async fn thumbnail_size_prefers_explicit_thumbnail() {
149+ let mut harness = start(file_with_thumbnail()).await;
150+ mount_downloads(&harness.server).await;
151+
152+ let bytes = fetch_ready(&mut harness, MediaSize::Thumbnail).await;
153+ assert_eq!(&*bytes, b"binaryjpegfullimagedata");
154+ let paths = media_paths(&harness.server).await;
155+ assert!(
156+ paths
157+ .iter()
158+ .any(|path| path.ends_with("/localhost/explicit-thumbnail"))
159+ );
160+ assert!(!paths.iter().any(|path| path.ends_with("/localhost/full")));
161+
162+ stop(harness).await;
163+}
164+
165+#[tokio::test]
166+async fn plain_image_thumbnail_uses_thumbnail_endpoint() {
167+ let mut harness = start(plain_image()).await;
168+ harness
169+ .server
170+ .mock_media_thumbnail(Method::Scale, 800, 800, false)
171+ .ok()
172+ .mount()
173+ .await;
174+ harness
175+ .server
176+ .mock_authed_media_thumbnail(Method::Scale, 800, 800, false)
177+ .ok()
178+ .mount()
179+ .await;
180+
181+ let bytes = fetch_ready(&mut harness, MediaSize::Thumbnail).await;
182+ assert_eq!(&*bytes, b"binaryjpegthumbnaildata");
183+ assert!(
184+ media_paths(&harness.server)
185+ .await
186+ .iter()
187+ .any(|path| path.contains("/thumbnail/localhost/image"))
188+ );
189+
190+ stop(harness).await;
191+}
192+
193+#[tokio::test]
194+async fn unavailable_thumbnail_reports_returned_command_id() {
195+ let mut harness = start(plain_audio()).await;
196+ let command_id = harness
197+ .handle
198+ .send(Command::Media(MediaCommand::Fetch {
199+ id: harness.media_id.clone(),
200+ size: MediaSize::Thumbnail,
201+ }))
202+ .await
203+ .unwrap();
204+
205+ let event = receive_until(&mut harness.events, |event| {
206+ matches!(
207+ event,
208+ Event::CommandFailed(failure)
209+ if failure.id == command_id && failure.kind == CommandErrorKind::MediaUnavailable
210+ )
211+ })
212+ .await;
213+ assert!(matches!(
214+ event,
215+ Event::CommandFailed(failure)
216+ if failure.id == command_id && failure.kind == CommandErrorKind::MediaUnavailable
217+ ));
218+
219+ stop(harness).await;
220+}
221+
222+#[tokio::test]
223+async fn media_upload_is_queued_and_sent() {
224+ let mut harness = start(plain_image()).await;
225+ harness
226+ .server
227+ .mock_authenticated_media_config()
228+ .ok_default()
229+ .mount()
230+ .await;
231+ harness
232+ .server
233+ .mock_upload()
234+ .expect_mime_type("image/jpeg")
235+ .ok(mxc_uri!("mxc://localhost/uploaded"))
236+ .expect(1)
237+ .mount()
238+ .await;
239+ harness
240+ .server
241+ .mock_room_send()
242+ .ok(event_id!("$uploaded:localhost"))
243+ .expect(1)
244+ .mount()
245+ .await;
246+
247+ let id = harness
248+ .handle
249+ .upload_media(
250+ room_id!("!media:localhost").to_owned().into(),
251+ "photo.jpg".to_owned(),
252+ "image/jpeg".to_owned(),
253+ b"photo data".to_vec(),
254+ Some("holiday".to_owned()),
255+ )
256+ .await
257+ .unwrap();
258+ receive_until(
259+ &mut harness.events,
260+ |event| matches!(event, Event::CommandSucceeded { id: completed, .. } if *completed == id),
261+ )
262+ .await;
263+
264+ tokio::time::timeout(Duration::from_secs(5), async {
265+ loop {
266+ let requests = harness.server.received_requests().await.unwrap();
267+ if requests.iter().any(|request| {
268+ request.url.path().contains("/send/m.room.message/")
269+ && String::from_utf8_lossy(&request.body).contains("mxc://localhost/uploaded")
270+ }) {
271+ break;
272+ }
273+ tokio::task::yield_now().await;
274+ }
275+ })
276+ .await
277+ .expect("queued media was not sent");
278+
279+ stop(harness).await;
280+}
281+
282+#[tokio::test]
283+async fn queued_offline_attachment_exposes_fetchable_local_media() {
284+ let mut harness = start(plain_image()).await;
285+ harness.client.send_queue().set_enabled(false).await;
286+ let original = b"offline image bytes".to_vec();
287+ let mut upload = AttachmentUpload::new(
288+ room_id!("!media:localhost").to_owned(),
289+ "pending.png",
290+ "image/png",
291+ original.clone(),
292+ );
293+ upload.details = Some(UploadDetails::Image {
294+ width: Some(32),
295+ height: Some(16),
296+ blurhash: None,
297+ });
298+ harness.handle.upload_attachment(upload).await.unwrap();
299+ let event = receive_until(&mut harness.events, |event| {
300+ event_messages(event).any(|message| {
301+ message.own
302+ && message.transaction_id.is_some()
303+ && message
304+ .media
305+ .as_ref()
306+ .is_some_and(|media| media.filename == "pending.png")
307+ })
308+ })
309+ .await;
310+ let media = event_messages(&event)
311+ .find(|message| message.own && message.media.is_some())
312+ .and_then(|message| message.media.as_ref())
313+ .unwrap();
314+ assert_eq!((media.width, media.height), (Some(32), Some(16)));
315+ assert_eq!(media.size_bytes, Some(original.len() as u64));
316+ let media_id = media.id.clone();
317+
318+ let fetch_id = harness
319+ .handle
320+ .send(Command::Media(MediaCommand::Fetch {
321+ id: media_id.clone(),
322+ size: MediaSize::Full,
323+ }))
324+ .await
325+ .unwrap();
326+ let ready = receive_until(
327+ &mut harness.events,
328+ |event| matches!(event, Event::MediaReady { media_id: id, .. } if id == &media_id),
329+ )
330+ .await;
331+ assert!(matches!(ready, Event::MediaReady { bytes, .. } if *bytes == original));
332+ receive_until(
333+ &mut harness.events,
334+ |event| matches!(event, Event::CommandSucceeded { id, .. } if *id == fetch_id),
335+ )
336+ .await;
337+ stop(harness).await;
338+}
339+
340+#[tokio::test]
341+async fn image_upload_uses_caller_supplied_metadata_and_thumbnail() {
342+ let mut harness = start(plain_image()).await;
343+ harness
344+ .server
345+ .mock_authenticated_media_config()
346+ .ok_default()
347+ .mount()
348+ .await;
349+ harness
350+ .server
351+ .mock_upload()
352+ .expect_mime_type("image/png")
353+ .respond_with(|request: &wiremock::Request| {
354+ let uri = if request.body == b"thumbnail bytes" {
355+ "mxc://localhost/image-thumb"
356+ } else {
357+ "mxc://localhost/image-upload"
358+ };
359+ ResponseTemplate::new(200).set_body_json(json!({ "content_uri": uri }))
360+ })
361+ .expect(2)
362+ .mount()
363+ .await;
364+ harness
365+ .server
366+ .mock_room_send()
367+ .ok(event_id!("$image-upload:localhost"))
368+ .expect(1)
369+ .mount()
370+ .await;
371+
372+ let original = b"image bytes".to_vec();
373+ let thumbnail_bytes = b"thumbnail bytes".to_vec();
374+ let mut upload = AttachmentUpload::new(
375+ room_id!("!media:localhost").to_owned(),
376+ "large.png",
377+ "image/png",
378+ original.clone(),
379+ );
380+ upload.details = Some(UploadDetails::Image {
381+ width: Some(900),
382+ height: Some(450),
383+ blurhash: None,
384+ });
385+ upload.thumbnail = Some(UploadThumbnail {
386+ bytes: thumbnail_bytes.clone(),
387+ mimetype: "image/png".to_owned(),
388+ width: 800,
389+ height: 400,
390+ });
391+ let id = harness.handle.upload_attachment(upload).await.unwrap();
392+ receive_until(
393+ &mut harness.events,
394+ |event| matches!(event, Event::CommandSucceeded { id: completed, .. } if *completed == id),
395+ )
396+ .await;
397+
398+ let content = uploaded_content(&harness.server).await;
399+ assert_eq!(content["msgtype"], "m.image");
400+ assert_eq!(content["info"]["w"], 900);
401+ assert_eq!(content["info"]["h"], 450);
402+ assert_eq!(content["info"]["size"], original.len());
403+ assert_eq!(content["info"]["thumbnail_info"]["w"], 800);
404+ assert_eq!(content["info"]["thumbnail_info"]["h"], 400);
405+ assert_eq!(content["info"]["thumbnail_info"]["mimetype"], "image/png");
406+ assert_eq!(
407+ content["info"]["thumbnail_info"]["size"],
408+ thumbnail_bytes.len()
409+ );
410+ assert_eq!(
411+ content["info"]["thumbnail_url"],
412+ "mxc://localhost/image-thumb"
413+ );
414+ assert_eq!(content["url"], "mxc://localhost/image-upload");
415+
416+ let requests = harness.server.received_requests().await.unwrap();
417+ let uploads = requests
418+ .iter()
419+ .filter(|request| request.url.path().ends_with("/_matrix/media/v3/upload"))
420+ .collect::<Vec<_>>();
421+ assert_eq!(uploads.len(), 2);
422+ assert!(uploads.iter().any(|request| request.body == original));
423+ assert!(
424+ uploads
425+ .iter()
426+ .any(|request| request.body == thumbnail_bytes)
427+ );
428+
429+ stop(harness).await;
430+}
431+
432+#[tokio::test]
433+async fn video_upload_preserves_metadata_and_supplied_thumbnail() {
434+ let mut harness = start(plain_image()).await;
435+ harness
436+ .server
437+ .mock_authenticated_media_config()
438+ .ok_default()
439+ .mount()
440+ .await;
441+ harness
442+ .server
443+ .mock_upload()
444+ .expect_mime_type("image/png")
445+ .ok(mxc_uri!("mxc://localhost/video-thumb"))
446+ .expect(1)
447+ .mount()
448+ .await;
449+ harness
450+ .server
451+ .mock_upload()
452+ .expect_mime_type("video/mp4")
453+ .ok(mxc_uri!("mxc://localhost/video-upload"))
454+ .expect(1)
455+ .mount()
456+ .await;
457+ harness
458+ .server
459+ .mock_room_send()
460+ .ok(event_id!("$video-upload:localhost"))
461+ .expect(1)
462+ .mount()
463+ .await;
464+
465+ let mut upload = AttachmentUpload::new(
466+ room_id!("!media:localhost").to_owned(),
467+ "clip.mp4",
468+ "video/mp4",
469+ b"video bytes".to_vec(),
470+ );
471+ upload.details = Some(UploadDetails::Video {
472+ width: Some(1920),
473+ height: Some(1080),
474+ duration_ms: Some(3500),
475+ blurhash: Some("blurhash".to_owned()),
476+ });
477+ upload.thumbnail = Some(UploadThumbnail {
478+ bytes: b"poster bytes".to_vec(),
479+ mimetype: "image/png".to_owned(),
480+ width: 320,
481+ height: 180,
482+ });
483+ let id = harness.handle.upload_attachment(upload).await.unwrap();
484+ receive_until(
485+ &mut harness.events,
486+ |event| matches!(event, Event::CommandSucceeded { id: completed, .. } if *completed == id),
487+ )
488+ .await;
489+
490+ let content = uploaded_content(&harness.server).await;
491+ assert_eq!(content["msgtype"], "m.video");
492+ assert_eq!(content["info"]["w"], 1920);
493+ assert_eq!(content["info"]["h"], 1080);
494+ assert_eq!(content["info"]["duration"], 3500);
495+ assert_eq!(content["info"]["size"], 11);
496+ assert_eq!(content["info"]["thumbnail_info"]["w"], 320);
497+ assert_eq!(content["info"]["thumbnail_info"]["h"], 180);
498+ assert_eq!(content["info"]["thumbnail_info"]["size"], 12);
499+ assert_eq!(
500+ content["info"]["thumbnail_url"],
501+ "mxc://localhost/video-thumb"
502+ );
503+ assert_eq!(content["url"], "mxc://localhost/video-upload");
504+
505+ let requests = harness.server.received_requests().await.unwrap();
506+ assert!(requests.iter().any(|request| {
507+ request.url.path().ends_with("/_matrix/media/v3/upload") && request.body == b"poster bytes"
508+ }));
509+
510+ stop(harness).await;
511+}
512+
513+#[tokio::test]
514+async fn voice_upload_emits_voice_details_and_correlated_progress() {
515+ let mut harness = start(plain_image()).await;
516+ harness
517+ .server
518+ .mock_authenticated_media_config()
519+ .ok_default()
520+ .mount()
521+ .await;
522+ harness
523+ .server
524+ .mock_upload()
525+ .expect_mime_type("audio/ogg")
526+ .respond_with(
527+ ResponseTemplate::new(200)
528+ .set_body_json(json!({ "content_uri": "mxc://localhost/voice-upload" }))
529+ .set_delay(Duration::from_millis(500)),
530+ )
531+ .expect(1)
532+ .mount()
533+ .await;
534+ harness
535+ .server
536+ .mock_room_send()
537+ .ok(event_id!("$voice-upload:localhost"))
538+ .expect(1)
539+ .mount()
540+ .await;
541+
542+ let data = b"voice bytes".to_vec();
543+ let mut upload = AttachmentUpload::new(
544+ room_id!("!media:localhost").to_owned(),
545+ "voice.ogg",
546+ "audio/ogg",
547+ data.clone(),
548+ );
549+ upload.details = Some(UploadDetails::Voice {
550+ duration_ms: 4200,
551+ waveform: vec![0, 512, 1024],
552+ });
553+ let id = harness.handle.upload_attachment(upload).await.unwrap();
554+ receive_until(
555+ &mut harness.events,
556+ |event| matches!(event, Event::CommandSucceeded { id: completed, .. } if *completed == id),
557+ )
558+ .await;
559+
560+ let pending = harness
561+ .handle
562+ .pending_send_ids(room_id!("!media:localhost"))
563+ .await
564+ .unwrap();
565+ assert_eq!(pending.len(), 1);
566+ let transaction_id = pending[0].clone();
567+ let progress = receive_until(&mut harness.events, |event| {
568+ matches!(event, Event::MediaUploadProgress { transaction_id: seen, complete: true, .. } if seen == &transaction_id)
569+ })
570+ .await;
571+ assert!(matches!(progress, Event::MediaUploadProgress {
572+ room_id,
573+ transaction_id: seen,
574+ uploaded_bytes,
575+ total_bytes,
576+ complete: true,
577+ } if room_id == room_id!("!media:localhost") && seen == transaction_id && uploaded_bytes == data.len() as u64 && total_bytes == data.len() as u64));
578+
579+ let content = uploaded_content(&harness.server).await;
580+ assert_eq!(content["msgtype"], "m.audio");
581+ assert_eq!(content["info"]["duration"], 4200);
582+ assert_eq!(content["info"]["size"], data.len());
583+ assert_eq!(content["org.matrix.msc1767.audio"]["duration"], 4200);
584+ assert_eq!(
585+ content["org.matrix.msc1767.audio"]["waveform"],
586+ json!([0, 512, 1024])
587+ );
588+ assert_eq!(content["org.matrix.msc3245.voice"], json!({}));
589+
590+ stop(harness).await;
591+}
592+
593+#[tokio::test]
594+async fn mismatched_upload_metadata_and_invalid_waveform_fail_without_uploading() {
595+ let mut harness = start(plain_image()).await;
596+ let mut mismatched = AttachmentUpload::new(
597+ room_id!("!media:localhost").to_owned(),
598+ "clip.mp4",
599+ "video/mp4",
600+ b"video bytes".to_vec(),
601+ );
602+ mismatched.details = Some(UploadDetails::Image {
603+ width: Some(32),
604+ height: Some(32),
605+ blurhash: None,
606+ });
607+ let id = harness.handle.upload_attachment(mismatched).await.unwrap();
608+ let failure = receive_until(
609+ &mut harness.events,
610+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),
611+ )
612+ .await;
613+ assert!(
614+ matches!(failure, Event::CommandFailed(failure) if failure.kind == CommandErrorKind::Other)
615+ );
616+
617+ let mut voice = AttachmentUpload::new(
618+ room_id!("!media:localhost").to_owned(),
619+ "voice.ogg",
620+ "audio/ogg",
621+ b"voice bytes".to_vec(),
622+ );
623+ voice.details = Some(UploadDetails::Voice {
624+ duration_ms: 1000,
625+ waveform: vec![1025],
626+ });
627+ let id = harness.handle.upload_attachment(voice).await.unwrap();
628+ let failure = receive_until(
629+ &mut harness.events,
630+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),
631+ )
632+ .await;
633+ assert!(
634+ matches!(failure, Event::CommandFailed(failure) if failure.kind == CommandErrorKind::Other)
635+ );
636+
637+ assert!(
638+ !harness
639+ .server
640+ .received_requests()
641+ .await
642+ .unwrap()
643+ .iter()
644+ .any(|request| { request.url.path().ends_with("/_matrix/media/v3/upload") })
645+ );
646+ stop(harness).await;
647+}
648+
649+#[tokio::test]
650+async fn upload_over_media_limit_fails_without_uploading() {
651+ let mut harness = start_with_config(
652+ plain_image(),
653+ EngineConfig::default()
654+ .with_initial_history_limit(0)
655+ .with_max_media_bytes(4),
656+ )
657+ .await;
658+ let upload = AttachmentUpload::new(
659+ room_id!("!media:localhost").to_owned(),
660+ "large.bin",
661+ "application/octet-stream",
662+ b"too large".to_vec(),
663+ );
664+ let id = harness.handle.upload_attachment(upload).await.unwrap();
665+ let failure = receive_until(
666+ &mut harness.events,
667+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),
668+ )
669+ .await;
670+ assert!(matches!(
671+ failure,
672+ Event::CommandFailed(failure) if failure.kind == CommandErrorKind::MediaTooLarge
673+ ));
674+ assert!(
675+ !harness
676+ .server
677+ .received_requests()
678+ .await
679+ .unwrap()
680+ .iter()
681+ .any(|request| { request.url.path().ends_with("/_matrix/media/v3/upload") })
682+ );
683+ stop(harness).await;
684+}
685+
686+#[tokio::test]
687+async fn audio_thumbnail_fails_without_uploading() {
688+ let mut harness = start(plain_image()).await;
689+ let mut upload = AttachmentUpload::new(
690+ room_id!("!media:localhost").to_owned(),
691+ "voice.ogg",
692+ "audio/ogg",
693+ b"voice bytes".to_vec(),
694+ );
695+ upload.thumbnail = Some(UploadThumbnail {
696+ bytes: b"thumbnail".to_vec(),
697+ mimetype: "image/png".to_owned(),
698+ width: 80,
699+ height: 80,
700+ });
701+ let id = harness.handle.upload_attachment(upload).await.unwrap();
702+ let failure = receive_until(
703+ &mut harness.events,
704+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),
705+ )
706+ .await;
707+ assert!(matches!(
708+ failure,
709+ Event::CommandFailed(failure) if failure.kind == CommandErrorKind::MediaUnavailable
710+ ));
711+ assert!(
712+ !harness
713+ .server
714+ .received_requests()
715+ .await
716+ .unwrap()
717+ .iter()
718+ .any(|request| { request.url.path().ends_with("/_matrix/media/v3/upload") })
719+ );
720+ stop(harness).await;
721+}
722+
723+#[tokio::test]
724+async fn invalid_upload_mime_reports_a_command_failure() {
725+ let mut harness = start(plain_image()).await;
726+ let id = harness
727+ .handle
728+ .upload_media(
729+ room_id!("!media:localhost").to_owned().into(),
730+ "photo.jpg".to_owned(),
731+ "not a MIME type".to_owned(),
732+ b"photo data".to_vec(),
733+ None,
734+ )
735+ .await
736+ .unwrap();
737+ let event = receive_until(
738+ &mut harness.events,
739+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),
740+ )
741+ .await;
742+ assert!(
743+ matches!(event, Event::CommandFailed(failure) if failure.kind == CommandErrorKind::Other)
744+ );
745+
746+ stop(harness).await;
747+}
748+
749+async fn start(content: RoomMessageEventContent) -> MediaHarness {
750+ start_with_config(
751+ content,
752+ EngineConfig::default().with_initial_history_limit(0),
753+ )
754+ .await
755+}
756+
757+async fn start_with_config(content: RoomMessageEventContent, config: EngineConfig) -> MediaHarness {
758+ let server = MatrixMockServer::new().await;
759+ server
760+ .mock_sliding_sync()
761+ .ok(v5::Response::new("1".to_owned()))
762+ .mount()
763+ .await;
764+ let client = server.client_builder().build().await;
765+ let room_id = room_id!("!media:localhost");
766+ server.mock_room_state_encryption().plain().mount().await;
767+ server.sync_joined_room(&client, room_id).await;
768+ let sync_client = client.clone();
769+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
770+ let running = tokio::spawn(engine.run());
771+
772+ handle
773+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))
774+ .await
775+ .unwrap();
776+ receive_until(
777+ &mut events,
778+ |event| matches!(event, Event::TimelineReset { timeline_id: reset, .. } if reset.room_id() == room_id),
779+ )
780+ .await;
781+
782+ server
783+ .sync_room(
784+ &sync_client,
785+ JoinedRoomBuilder::new(room_id)
786+ .add_timeline_event(EventFactory::new().event(content).sender(&ALICE)),
787+ )
788+ .await;
789+ let event = receive_until(&mut events, |event| {
790+ event_messages(event).any(|message| message.media.is_some())
791+ })
792+ .await;
793+ let media_id = event_messages(&event)
794+ .find_map(|message| message.media.as_ref().map(|media| media.id.clone()))
795+ .unwrap();
796+
797+ MediaHarness {
798+ server,
799+ client: sync_client,
800+ handle,
801+ events,
802+ running,
803+ media_id,
804+ }
805+}
806+
807+async fn mount_downloads(server: &MatrixMockServer) {
808+ server.mock_media_download().ok_image().mount().await;
809+ server.mock_authed_media_download().ok_image().mount().await;
810+}
811+
812+async fn fetch_ready(harness: &mut MediaHarness, size: MediaSize) -> Arc<[u8]> {
813+ harness
814+ .handle
815+ .send(Command::Media(MediaCommand::Fetch {
816+ id: harness.media_id.clone(),
817+ size,
818+ }))
819+ .await
820+ .unwrap();
821+ match receive_until(&mut harness.events, |event| {
822+ matches!(
823+ event,
824+ Event::MediaReady { media_id, size: received, .. }
825+ if media_id == &harness.media_id && received == &size
826+ )
827+ })
828+ .await
829+ {
830+ Event::MediaReady { bytes, .. } => bytes,
831+ _ => unreachable!(),
832+ }
833+}
834+
835+async fn stop(mut harness: MediaHarness) {
836+ harness
837+ .handle
838+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
839+ .await
840+ .unwrap();
841+ receive_until(&mut harness.events, |event| matches!(event, Event::Stopped)).await;
842+ harness.running.await.unwrap().unwrap();
843+}
844+
845+async fn media_paths(server: &MatrixMockServer) -> Vec<String> {
846+ server
847+ .received_requests()
848+ .await
849+ .unwrap()
850+ .into_iter()
851+ .map(|request| request.url.path().to_owned())
852+ .filter(|path| path.contains("/media/"))
853+ .collect()
854+}
855+
856+async fn uploaded_content(server: &MatrixMockServer) -> Value {
857+ tokio::time::timeout(Duration::from_secs(5), async {
858+ loop {
859+ if let Some(request) = server
860+ .received_requests()
861+ .await
862+ .unwrap()
863+ .iter()
864+ .find(|request| request.url.path().contains("/send/m.room.message/"))
865+ {
866+ return serde_json::from_slice(&request.body).unwrap();
867+ }
868+ tokio::task::yield_now().await;
869+ }
870+ })
871+ .await
872+ .expect("queued media was not sent")
873+}
874+
875+fn file_with_thumbnail() -> RoomMessageEventContent {
876+ let mut info = FileInfo::new();
877+ info.thumbnail_source = Some(MediaSource::Plain(
878+ mxc_uri!("mxc://localhost/explicit-thumbnail").to_owned(),
879+ ));
880+ RoomMessageEventContent::new(MessageType::File(
881+ FileMessageEventContent::plain(
882+ "file.bin".to_owned(),
883+ mxc_uri!("mxc://localhost/full").to_owned(),
884+ )
885+ .info(Box::new(info)),
886+ ))
887+}
888+
889+fn plain_image() -> RoomMessageEventContent {
890+ RoomMessageEventContent::new(MessageType::Image(ImageMessageEventContent::plain(
891+ "image.jpg".to_owned(),
892+ mxc_uri!("mxc://localhost/image").to_owned(),
893+ )))
894+}
895+
896+fn plain_audio() -> RoomMessageEventContent {
897+ RoomMessageEventContent::new(MessageType::Audio(AudioMessageEventContent::plain(
898+ "audio.ogg".to_owned(),
899+ mxc_uri!("mxc://localhost/audio").to_owned(),
900+ )))
901+}
902+
903+fn event_messages(event: &Event) -> std::vec::IntoIter<&macltok::TimelineMessage> {
904+ let items = match event {
905+ Event::TimelineReset { items, .. } => items.iter().collect::<Vec<_>>(),
906+ Event::TimelineChanged { diffs, .. } => {
907+ diffs.iter().flat_map(diff_items).collect::<Vec<_>>()
908+ }
909+ _ => Vec::new(),
910+ };
911+ items
912+ .into_iter()
913+ .filter_map(|item| match item {
914+ TimelineItem::Message(message) => Some(message.as_ref()),
915+ _ => None,
916+ })
917+ .collect::<Vec<_>>()
918+ .into_iter()
919+}
920+
921+fn diff_items(diff: &TimelineDiff) -> Vec<&TimelineItem> {
922+ match diff {
923+ TimelineDiff::Append { values } | TimelineDiff::Reset { values } => values.iter().collect(),
924+ TimelineDiff::PushFront { value }
925+ | TimelineDiff::PushBack { value }
926+ | TimelineDiff::Insert { value, .. }
927+ | TimelineDiff::Set { value, .. } => vec![value],
928+ _ => Vec::new(),
929+ }
930+}
931+
932+async fn receive_until(events: &mut EventStream, predicate: impl Fn(&Event) -> bool) -> Event {
933+ tokio::time::timeout(Duration::from_secs(5), async {
934+ while let Some(event) = events.recv().await {
935+ if predicate(&event) {
936+ return event;
937+ }
938+ }
939+ panic!("event stream closed before the expected event");
940+ })
941+ .await
942+ .expect("expected event did not arrive")
943+}
Added file: tests/members.rs+200 additions
@@ -0,0 +1,200 @@
1+use macltok::{ClientQueryError, Engine, EngineConfig, MemberFilter, MemberMembership};
2+use matrix_sdk::{
3+ ruma::{
4+ api::client::sync::sync_events::v5, events::room::member::MembershipState, room_id, user_id,
5+ },
6+ test_utils::mocks::MatrixMockServer,
7+};
8+use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};
9+
10+#[tokio::test]
11+async fn members_filters_and_normalises_synced_room_members() {
12+ let server = MatrixMockServer::new().await;
13+ server
14+ .mock_sliding_sync()
15+ .ok(v5::Response::new("1".to_owned()))
16+ .mount()
17+ .await;
18+ let client = server.client_builder().build().await;
19+ let room_id = room_id!("!members:localhost");
20+ server.mock_room_state_encryption().plain().mount().await;
21+
22+ let factory = EventFactory::new().room(room_id);
23+ let own = factory
24+ .member(user_id!("@example:localhost"))
25+ .display_name("Example")
26+ .into_raw_sync_state();
27+ let joined = factory
28+ .member(user_id!("@bob:localhost"))
29+ .display_name("Bobby")
30+ .into_raw_sync_state();
31+ let invited = factory
32+ .member(user_id!("@carol:localhost"))
33+ .membership(MembershipState::Invite)
34+ .sender(&ALICE)
35+ .state_key(user_id!("@carol:localhost"))
36+ .display_name("Carol")
37+ .into_raw_sync_state();
38+
39+ let own_member = factory
40+ .member(user_id!("@example:localhost"))
41+ .display_name("Example")
42+ .into_raw();
43+ let joined_member = factory
44+ .member(user_id!("@bob:localhost"))
45+ .display_name("Bobby")
46+ .into_raw();
47+ let invited_member = factory
48+ .member(user_id!("@carol:localhost"))
49+ .membership(MembershipState::Invite)
50+ .sender(&ALICE)
51+ .state_key(user_id!("@carol:localhost"))
52+ .display_name("Carol")
53+ .into_raw();
54+
55+ server
56+ .sync_room(
57+ &client,
58+ JoinedRoomBuilder::new(room_id)
59+ .add_state_event(own.clone())
60+ .add_state_event(joined.clone())
61+ .add_state_event(invited.clone()),
62+ )
63+ .await;
64+ server
65+ .mock_get_members()
66+ .ok(vec![own_member, joined_member, invited_member])
67+ .mock_once()
68+ .mount()
69+ .await;
70+ let (engine, handle, _events) = Engine::from_client(client, EngineConfig::default())
71+ .await
72+ .unwrap();
73+ let joined_members = handle.members(room_id, MemberFilter::Joined).await.unwrap();
74+ assert_eq!(joined_members.len(), 2);
75+ let own_member = joined_members
76+ .iter()
77+ .find(|member| member.own)
78+ .expect("own member missing");
79+ assert_eq!(own_member.user_id, user_id!("@example:localhost"));
80+ assert_eq!(own_member.name, "Example");
81+ assert_eq!(own_member.display_name.as_deref(), Some("Example"));
82+ assert_eq!(own_member.membership, MemberMembership::Joined);
83+ assert!(own_member.own);
84+
85+ let bob = joined_members
86+ .iter()
87+ .find(|member| member.user_id == user_id!("@bob:localhost"))
88+ .expect("joined member missing");
89+ assert_eq!(bob.name, "Bobby");
90+ assert_eq!(bob.display_name.as_deref(), Some("Bobby"));
91+ assert_eq!(bob.membership, MemberMembership::Joined);
92+ assert!(!bob.own);
93+
94+ let invited_members = handle
95+ .members(room_id, MemberFilter::Invited)
96+ .await
97+ .unwrap();
98+ assert_eq!(invited_members.len(), 1);
99+ let carol = &invited_members[0];
100+ assert_eq!(carol.user_id, user_id!("@carol:localhost"));
101+ assert_eq!(carol.name, "Carol");
102+ assert_eq!(carol.display_name.as_deref(), Some("Carol"));
103+ assert_eq!(carol.membership, MemberMembership::Invited);
104+ assert!(!carol.own);
105+
106+ drop(engine);
107+}
108+
109+#[tokio::test]
110+async fn members_all_includes_custom_membership_states() {
111+ let server = MatrixMockServer::new().await;
112+ server
113+ .mock_sliding_sync()
114+ .ok(v5::Response::new("1".to_owned()))
115+ .mount()
116+ .await;
117+ let client = server.client_builder().build().await;
118+ let room_id = room_id!("!members-custom:localhost");
119+ server.mock_room_state_encryption().plain().mount().await;
120+
121+ let factory = EventFactory::new().room(room_id);
122+ let joined = factory
123+ .member(user_id!("@bob:localhost"))
124+ .display_name("Bobby")
125+ .into_raw_sync_state();
126+ let custom = factory
127+ .member(user_id!("@dave:localhost"))
128+ .membership(MembershipState::from("flying"))
129+ .sender(user_id!("@bob:localhost"))
130+ .state_key(user_id!("@dave:localhost"))
131+ .display_name("Dave")
132+ .into_raw_sync_state();
133+
134+ let joined_member = factory
135+ .member(user_id!("@bob:localhost"))
136+ .display_name("Bobby")
137+ .into_raw();
138+ let custom_member = factory
139+ .member(user_id!("@dave:localhost"))
140+ .membership(MembershipState::from("flying"))
141+ .sender(user_id!("@bob:localhost"))
142+ .state_key(user_id!("@dave:localhost"))
143+ .display_name("Dave")
144+ .into_raw();
145+
146+ server
147+ .sync_room(
148+ &client,
149+ JoinedRoomBuilder::new(room_id)
150+ .add_state_event(joined)
151+ .add_state_event(custom),
152+ )
153+ .await;
154+ server
155+ .mock_get_members()
156+ .ok(vec![joined_member, custom_member])
157+ .mock_once()
158+ .mount()
159+ .await;
160+ let (engine, handle, _events) = Engine::from_client(client, EngineConfig::default())
161+ .await
162+ .unwrap();
163+
164+ // `All` maps to the SDK's unfiltered empty mask, so custom states are included.
165+ let all_members = handle.members(room_id, MemberFilter::All).await.unwrap();
166+ assert_eq!(all_members.len(), 2);
167+ let dave = all_members
168+ .iter()
169+ .find(|member| member.user_id == user_id!("@dave:localhost"))
170+ .expect("custom member missing");
171+ assert!(
172+ matches!(&dave.membership, MemberMembership::Custom(state) if state == "flying"),
173+ "unexpected membership: {:?}",
174+ dave.membership
175+ );
176+
177+ // Narrow filters still exclude the custom state.
178+ let joined_members = handle.members(room_id, MemberFilter::Joined).await.unwrap();
179+ assert_eq!(joined_members.len(), 1);
180+ assert_eq!(joined_members[0].user_id, user_id!("@bob:localhost"));
181+
182+ drop(engine);
183+}
184+
185+#[tokio::test]
186+async fn members_rejects_unknown_room() {
187+ let server = MatrixMockServer::new().await;
188+ let client = server.client_builder().build().await;
189+ let (engine, handle, _events) = Engine::from_client(client, EngineConfig::default())
190+ .await
191+ .unwrap();
192+ let room_id = room_id!("!missing:localhost");
193+
194+ assert!(matches!(
195+ handle.members(room_id, MemberFilter::All).await,
196+ Err(ClientQueryError::RoomUnknown(missing)) if missing == room_id
197+ ));
198+
199+ drop(engine);
200+}
Added file: tests/nonblocking.rs+207 additions
@@ -0,0 +1,207 @@
1+use std::time::Duration;
2+
3+use macltok::{
4+ Command, CommandErrorKind, CommandKind, Engine, EngineConfig, Event, LifecycleCommand,
5+ MediaCommand, MediaSize, MessageContent, RoomCommand, TimelineCommand, TimelineDiff,
6+ TimelineItem,
7+};
8+use matrix_sdk::{
9+ ruma::{api::client::sync::sync_events::v5, mxc_uri, room_id},
10+ test_utils::mocks::MatrixMockServer,
11+};
12+use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};
13+use wiremock::ResponseTemplate;
14+
15+#[tokio::test]
16+async fn delayed_media_fetch_does_not_block_timeline_command() {
17+ let server = MatrixMockServer::new().await;
18+ server
19+ .mock_sliding_sync()
20+ .ok(v5::Response::new("1".to_owned()))
21+ .mount()
22+ .await;
23+ let client = server.client_builder().build().await;
24+ let sync_client = client.clone();
25+ let room_id = room_id!("!nonblocking:localhost");
26+ server.mock_room_state_encryption().plain().mount().await;
27+ server.sync_joined_room(&client, room_id).await;
28+ let config = EngineConfig::default().with_initial_history_limit(0);
29+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
30+ let running = tokio::spawn(engine.run());
31+
32+ handle
33+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))
34+ .await
35+ .unwrap();
36+ receive_until(
37+ &mut events,
38+ |event| matches!(event, Event::TimelineReset { timeline_id: reset, .. } if reset.room_id() == room_id),
39+ )
40+ .await;
41+
42+ server
43+ .sync_room(
44+ &sync_client,
45+ JoinedRoomBuilder::new(room_id).add_timeline_event(
46+ EventFactory::new()
47+ .image(
48+ "image.jpg".into(),
49+ mxc_uri!("mxc://localhost/image").to_owned(),
50+ )
51+ .sender(&ALICE),
52+ ),
53+ )
54+ .await;
55+ let media_event = receive_until(&mut events, |event| {
56+ event_messages(event).any(|message| message.media.is_some())
57+ })
58+ .await;
59+ let media_id = event_messages(&media_event)
60+ .find_map(|message| message.media.as_ref().map(|media| media.id.clone()))
61+ .unwrap();
62+
63+ let delayed_image = || {
64+ ResponseTemplate::new(200)
65+ .set_body_raw(b"binaryjpegfullimagedata", "image/jpeg")
66+ .set_delay(Duration::from_millis(500))
67+ };
68+ server
69+ .mock_media_download()
70+ .respond_with(delayed_image())
71+ .mount()
72+ .await;
73+ server
74+ .mock_authed_media_download()
75+ .respond_with(delayed_image())
76+ .mount()
77+ .await;
78+
79+ let media_command_id = handle
80+ .send(Command::Media(MediaCommand::Fetch {
81+ id: media_id.clone(),
82+ size: MediaSize::Full,
83+ }))
84+ .await
85+ .unwrap();
86+ wait_for_media_request(&server).await;
87+ let timeline_command_id = handle
88+ .send(Command::Timeline(TimelineCommand::Send {
89+ timeline_id: room_id.to_owned().into(),
90+ content: MessageContent::text(String::new()),
91+ }))
92+ .await
93+ .unwrap();
94+
95+ tokio::time::timeout(Duration::from_secs(2), async {
96+ loop {
97+ match events.recv().await.expect("event stream closed") {
98+ Event::CommandFailed(failure) if failure.id == timeline_command_id => {
99+ assert_eq!(failure.command, CommandKind::Timeline);
100+ assert_eq!(failure.kind, CommandErrorKind::EmptyBody);
101+ break;
102+ }
103+ Event::CommandSucceeded { id, .. } if id == media_command_id => {
104+ panic!("media command completed before the timeline command")
105+ }
106+ Event::CommandFailed(failure) if failure.id == media_command_id => {
107+ panic!("media command failed before the timeline command {failure:?}")
108+ }
109+ Event::MediaReady {
110+ media_id: ready_id, ..
111+ } if ready_id == media_id => {
112+ panic!("media became ready before the timeline command completed")
113+ }
114+ _ => {}
115+ }
116+ }
117+ })
118+ .await
119+ .expect("timeline command was blocked by media fetch");
120+
121+ receive_until(&mut events, |event| {
122+ matches!(
123+ event,
124+ Event::CommandSucceeded {
125+ id,
126+ command: CommandKind::Media,
127+ } if id == &media_command_id
128+ )
129+ })
130+ .await;
131+
132+ handle
133+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
134+ .await
135+ .unwrap();
136+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;
137+ running.await.unwrap().unwrap();
138+}
139+
140+async fn wait_for_media_request(server: &MatrixMockServer) {
141+ tokio::time::timeout(Duration::from_secs(2), async {
142+ loop {
143+ if server
144+ .received_requests()
145+ .await
146+ .unwrap()
147+ .iter()
148+ .any(|request| request.url.path().ends_with("/download/localhost/image"))
149+ {
150+ break;
151+ }
152+ tokio::task::yield_now().await;
153+ }
154+ })
155+ .await
156+ .expect("media request did not reach the homeserver");
157+}
158+
159+fn event_messages(event: &Event) -> std::vec::IntoIter<&macltok::TimelineMessage> {
160+ let items = match event {
161+ Event::TimelineReset { items, .. } => items.iter().collect::<Vec<_>>(),
162+ Event::TimelineChanged { diffs, .. } => {
163+ diffs.iter().flat_map(diff_items).collect::<Vec<_>>()
164+ }
165+ _ => Vec::new(),
166+ };
167+ items
168+ .into_iter()
169+ .filter_map(|item| match item {
170+ TimelineItem::Message(message) => Some(message.as_ref()),
171+ _ => None,
172+ })
173+ .collect::<Vec<_>>()
174+ .into_iter()
175+}
176+
177+fn diff_items(diff: &TimelineDiff) -> Vec<&TimelineItem> {
178+ match diff {
179+ TimelineDiff::Append { values } | TimelineDiff::Reset { values } => values.iter().collect(),
180+ TimelineDiff::PushFront { value }
181+ | TimelineDiff::PushBack { value }
182+ | TimelineDiff::Insert { value, .. }
183+ | TimelineDiff::Set { value, .. } => vec![value],
184+ TimelineDiff::Clear
185+ | TimelineDiff::PopFront
186+ | TimelineDiff::PopBack
187+ | TimelineDiff::Remove { .. }
188+ | TimelineDiff::Truncate { .. } => Vec::new(),
189+ _ => Vec::new(),
190+ }
191+}
192+
193+async fn receive_until(
194+ events: &mut macltok::EventStream,
195+ predicate: impl Fn(&Event) -> bool,
196+) -> Event {
197+ tokio::time::timeout(Duration::from_secs(2), async {
198+ while let Some(event) = events.recv().await {
199+ if predicate(&event) {
200+ return event;
201+ }
202+ }
203+ panic!("event stream closed before the expected event")
204+ })
205+ .await
206+ .expect("expected event did not arrive")
207+}
Added file: tests/send_queue.rs+251 additions
@@ -0,0 +1,251 @@
1+use std::time::Duration;
2+
3+use macltok::{
4+ ClientHandle, Command, CommandErrorKind, CommandId, CommandKind, Engine, EngineConfig, Event,
5+ EventStream, LifecycleCommand, MessageContent, RoomCommand, TimelineCommand, TimelineItem,
6+};
7+use matrix_sdk::{
8+ Client,
9+ ruma::{OwnedTransactionId, RoomId, api::client::sync::sync_events::v5, event_id, room_id},
10+ test_utils::mocks::MatrixMockServer,
11+};
12+
13+#[tokio::test]
14+async fn cancel_send_uses_transaction_id_from_public_timeline() {
15+ let (server, client, handle, mut events, running) = setup().await;
16+ let room_id = room_id!("!queue-controls:localhost");
17+ client.send_queue().set_enabled(false).await;
18+
19+ let transaction_id = queue_message(&handle, &mut events, room_id, "discard me").await;
20+ assert!(
21+ handle
22+ .pending_send_ids(room_id)
23+ .await
24+ .unwrap()
25+ .contains(&transaction_id)
26+ );
27+ let id = handle
28+ .cancel_send(room_id.to_owned(), transaction_id.clone())
29+ .await
30+ .unwrap();
31+ expect_success(&mut events, id, CommandKind::Room).await;
32+
33+ let (echoes, _) = client
34+ .get_room(room_id)
35+ .unwrap()
36+ .send_queue()
37+ .subscribe()
38+ .await
39+ .unwrap();
40+ assert!(
41+ !echoes
42+ .iter()
43+ .any(|echo| echo.transaction_id == transaction_id)
44+ );
45+
46+ let id = handle
47+ .cancel_send(room_id.to_owned(), transaction_id)
48+ .await
49+ .unwrap();
50+ let failure = receive_until(
51+ &mut events,
52+ |event| matches!(event, Event::CommandFailed(failure) if failure.id == id),
53+ )
54+ .await;
55+ assert!(
56+ matches!(failure, Event::CommandFailed(failure) if failure.kind == CommandErrorKind::Other)
57+ );
58+
59+ stop(handle, &mut events, running).await;
60+ drop(server);
61+}
62+
63+#[tokio::test]
64+async fn cancel_queued_attachment_by_pending_send_id() {
65+ let (server, client, handle, mut events, running) = setup().await;
66+ let room_id = room_id!("!queue-controls:localhost");
67+ client.send_queue().set_enabled(false).await;
68+
69+ let id = handle
70+ .upload_media(
71+ room_id.to_owned().into(),
72+ "note.txt".to_owned(),
73+ "text/plain".to_owned(),
74+ b"attachment".to_vec(),
75+ None,
76+ )
77+ .await
78+ .unwrap();
79+ expect_success(&mut events, id, CommandKind::Media).await;
80+
81+ let pending = handle.pending_send_ids(room_id).await.unwrap();
82+ assert_eq!(pending.len(), 1);
83+ let id = handle
84+ .cancel_send(room_id.to_owned(), pending[0].clone())
85+ .await
86+ .unwrap();
87+ expect_success(&mut events, id, CommandKind::Room).await;
88+ assert!(handle.pending_send_ids(room_id).await.unwrap().is_empty());
89+
90+ stop(handle, &mut events, running).await;
91+ drop(server);
92+}
93+
94+#[tokio::test]
95+async fn retry_send_wakes_paused_queue() {
96+ let (server, client, handle, mut events, running) = setup().await;
97+ let room_id = room_id!("!queue-controls:localhost");
98+ client.send_queue().set_enabled(false).await;
99+ server
100+ .mock_room_send()
101+ .body_matches_partial_json(serde_json::json!({ "body": "retry me" }))
102+ .ok(event_id!("$retried:localhost"))
103+ .expect(1)
104+ .mount()
105+ .await;
106+
107+ let transaction_id = queue_message(&handle, &mut events, room_id, "retry me").await;
108+ let id = handle
109+ .retry_send(room_id.to_owned(), transaction_id)
110+ .await
111+ .unwrap();
112+ expect_success(&mut events, id, CommandKind::Room).await;
113+ assert!(client.send_queue().is_enabled());
114+
115+ tokio::time::timeout(Duration::from_secs(5), async {
116+ loop {
117+ let (echoes, _) = client
118+ .get_room(room_id)
119+ .unwrap()
120+ .send_queue()
121+ .subscribe()
122+ .await
123+ .unwrap();
124+ if echoes.is_empty() {
125+ break;
126+ }
127+ tokio::task::yield_now().await;
128+ }
129+ })
130+ .await
131+ .expect("retried message was not sent");
132+
133+ stop(handle, &mut events, running).await;
134+ drop(server);
135+}
136+
137+async fn setup() -> (
138+ MatrixMockServer,
139+ Client,
140+ ClientHandle,
141+ EventStream,
142+ tokio::task::JoinHandle<Result<(), macltok::EngineError>>,
143+) {
144+ let server = MatrixMockServer::new().await;
145+ server
146+ .mock_sliding_sync()
147+ .ok(v5::Response::new("1".to_owned()))
148+ .mount()
149+ .await;
150+ let client = server.client_builder().build().await;
151+ let room_id = room_id!("!queue-controls:localhost");
152+ server.mock_room_state_encryption().plain().mount().await;
153+ server.sync_joined_room(&client, room_id).await;
154+ let (engine, handle, events) = Engine::from_client(
155+ client.clone(),
156+ EngineConfig::default().with_initial_history_limit(0),
157+ )
158+ .await
159+ .unwrap();
160+ let running = tokio::spawn(engine.run());
161+ let mut events = events;
162+ let id = handle
163+ .send(Command::Room(RoomCommand::Open(room_id.to_owned())))
164+ .await
165+ .unwrap();
166+ expect_success(&mut events, id, CommandKind::Room).await;
167+ (server, client, handle, events, running)
168+}
169+
170+async fn queue_message(
171+ handle: &ClientHandle,
172+ events: &mut EventStream,
173+ room_id: &RoomId,
174+ body: &str,
175+) -> OwnedTransactionId {
176+ let id = handle
177+ .send(Command::Timeline(TimelineCommand::Send {
178+ timeline_id: room_id.to_owned().into(),
179+ content: MessageContent::text(body),
180+ }))
181+ .await
182+ .unwrap();
183+ let mut succeeded = false;
184+ let mut items = Vec::new();
185+ tokio::time::timeout(Duration::from_secs(5), async {
186+ loop {
187+ match events.recv().await.expect("event stream closed") {
188+ Event::TimelineChanged { diffs, .. } => {
189+ for diff in diffs {
190+ diff.try_apply(&mut items).unwrap();
191+ }
192+ let transaction = items.iter().find_map(|item| match item {
193+ TimelineItem::Message(message) if message.body == body => {
194+ message.transaction_id.clone()
195+ }
196+ _ => None,
197+ });
198+ if let Some(transaction) = transaction {
199+ if !succeeded {
200+ expect_success(events, id, CommandKind::Timeline).await;
201+ }
202+ return transaction;
203+ }
204+ }
205+ Event::CommandSucceeded {
206+ id: completed,
207+ command: CommandKind::Timeline,
208+ } if completed == id => succeeded = true,
209+ Event::CommandFailed(failure) if failure.id == id => {
210+ panic!("send failed {failure:?}")
211+ }
212+ _ => {}
213+ }
214+ }
215+ })
216+ .await
217+ .expect("local echo did not appear")
218+}
219+
220+async fn expect_success(events: &mut EventStream, id: CommandId, command: CommandKind) {
221+ receive_until(events, |event| {
222+ matches!(event, Event::CommandSucceeded { id: completed, command: kind } if *completed == id && *kind == command)
223+ })
224+ .await;
225+}
226+
227+async fn receive_until(events: &mut EventStream, predicate: impl Fn(&Event) -> bool) -> Event {
228+ tokio::time::timeout(Duration::from_secs(5), async {
229+ loop {
230+ let event = events.recv().await.expect("event stream closed");
231+ if predicate(&event) {
232+ return event;
233+ }
234+ }
235+ })
236+ .await
237+ .expect("expected event did not arrive")
238+}
239+
240+async fn stop(
241+ handle: ClientHandle,
242+ events: &mut EventStream,
243+ running: tokio::task::JoinHandle<Result<(), macltok::EngineError>>,
244+) {
245+ let id = handle
246+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
247+ .await
248+ .unwrap();
249+ expect_success(events, id, CommandKind::Lifecycle).await;
250+ running.await.unwrap().unwrap();
251+}
Added file: tests/state.rs+147 additions
@@ -0,0 +1,147 @@
1+use std::time::Duration;
2+
3+use macltok::{
4+ Command, ConnectionState, Engine, EngineConfig, EngineError, Event, LifecycleCommand,
5+ RoomMembership,
6+};
7+use matrix_sdk::{
8+ ruma::{api::client::sync::sync_events::v5, room_id},
9+ test_utils::mocks::MatrixMockServer,
10+};
11+use tokio::sync::watch;
12+
13+#[tokio::test]
14+async fn rooms_changed_invalidates_room_summaries() {
15+ let server = MatrixMockServer::new().await;
16+ server
17+ .mock_sliding_sync()
18+ .ok(v5::Response::new("1".to_owned()))
19+ .mount()
20+ .await;
21+ let client = server.client_builder().build().await;
22+ let room_id = room_id!("!state:localhost");
23+ server.mock_room_state_encryption().plain().mount().await;
24+ server.sync_joined_room(&client, room_id).await;
25+ let config = EngineConfig::default().with_initial_history_limit(0);
26+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
27+ let running = tokio::spawn(engine.run());
28+
29+ receive_until(&mut events, |event| matches!(event, Event::RoomsChanged)).await;
30+ let rooms = handle.rooms().await;
31+ let summary = rooms
32+ .iter()
33+ .find(|summary| summary.room_id == room_id)
34+ .expect("synced room was absent from the room summaries");
35+ assert_eq!(summary.membership, RoomMembership::Joined);
36+ assert!(!summary.name.is_empty());
37+
38+ handle
39+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
40+ .await
41+ .unwrap();
42+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;
43+ running.await.unwrap().unwrap();
44+}
45+
46+#[tokio::test]
47+async fn connection_watch_tracks_the_engine_lifecycle() {
48+ let server = MatrixMockServer::new().await;
49+ server
50+ .mock_sliding_sync()
51+ .ok(v5::Response::new("1".to_owned()))
52+ .mount()
53+ .await;
54+ let client = server.client_builder().build().await;
55+ let config = EngineConfig::default().with_initial_history_limit(0);
56+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
57+ let mut connection = handle.connection_state();
58+ let connection_peer = connection.clone();
59+ assert_eq!(*connection.borrow(), ConnectionState::Idle);
60+ assert_eq!(*connection_peer.borrow(), ConnectionState::Idle);
61+
62+ let running = tokio::spawn(engine.run());
63+ receive_until(&mut events, |event| {
64+ matches!(event, Event::ConnectionChanged(ConnectionState::Connecting))
65+ })
66+ .await;
67+ assert_ne!(*connection.borrow(), ConnectionState::Idle);
68+ receive_until(&mut events, |event| {
69+ matches!(event, Event::ConnectionChanged(ConnectionState::Online))
70+ })
71+ .await;
72+ wait_for_connection(&mut connection, ConnectionState::Online).await;
73+ assert_eq!(*connection_peer.borrow(), ConnectionState::Online);
74+
75+ handle
76+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
77+ .await
78+ .unwrap();
79+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;
80+ running.await.unwrap().unwrap();
81+ wait_for_connection(&mut connection, ConnectionState::Stopped).await;
82+ assert_eq!(*connection_peer.borrow(), ConnectionState::Stopped);
83+}
84+
85+#[tokio::test]
86+async fn closing_the_event_stream_stops_an_idle_engine() {
87+ let server = MatrixMockServer::new().await;
88+ server
89+ .mock_sliding_sync()
90+ .ok(v5::Response::new("1".to_owned()))
91+ .mount()
92+ .await;
93+ let client = server.client_builder().build().await;
94+ let (engine, handle, mut events) = Engine::from_client(
95+ client,
96+ EngineConfig::default().with_initial_history_limit(0),
97+ )
98+ .await
99+ .unwrap();
100+ let running = tokio::spawn(engine.run());
101+ receive_until(&mut events, |event| {
102+ matches!(event, Event::ConnectionChanged(ConnectionState::Online))
103+ })
104+ .await;
105+ drop(events);
106+ let result = tokio::time::timeout(Duration::from_secs(2), running)
107+ .await
108+ .expect("engine did not stop after its event receiver closed")
109+ .unwrap();
110+ assert!(matches!(result, Err(EngineError::EventReceiverClosed)));
111+ drop(handle);
112+}
113+
114+async fn wait_for_connection(
115+ connection: &mut watch::Receiver<ConnectionState>,
116+ expected: ConnectionState,
117+) {
118+ tokio::time::timeout(Duration::from_secs(2), async {
119+ loop {
120+ if *connection.borrow_and_update() == expected {
121+ return;
122+ }
123+ connection
124+ .changed()
125+ .await
126+ .expect("connection watch closed before the expected state");
127+ }
128+ })
129+ .await
130+ .expect("expected connection state did not arrive");
131+}
132+
133+async fn receive_until(
134+ events: &mut macltok::EventStream,
135+ predicate: impl Fn(&Event) -> bool,
136+) -> Event {
137+ tokio::time::timeout(Duration::from_secs(2), async {
138+ while let Some(event) = events.recv().await {
139+ if predicate(&event) {
140+ return event;
141+ }
142+ }
143+ panic!("event stream closed before the expected event");
144+ })
145+ .await
146+ .expect("expected event did not arrive")
147+}
Added file: tests/tier0.rs+401 additions
@@ -0,0 +1,401 @@
1+use std::time::Duration;
2+
3+use macltok::{
4+ ClientHandle, ClientQueryError, Command, CommandErrorKind, CommandId, CommandKind,
5+ CreateRoomOptions, Engine, EngineConfig, EngineError, Event, EventStream, LifecycleCommand,
6+ RoomCommand, RoomPreset, RoomVisibility,
7+};
8+use matrix_sdk::{
9+ ruma::{
10+ api::client::sync::sync_events::v5,
11+ events::{GlobalAccountDataEventType, RoomAccountDataEventType},
12+ room_id,
13+ serde::Raw,
14+ server_name, user_id,
15+ },
16+ test_utils::mocks::MatrixMockServer,
17+};
18+use matrix_sdk_test::{InvitedRoomBuilder, JoinedRoomBuilder};
19+use serde_json::json;
20+use tokio::task::JoinHandle;
21+use wiremock::{
22+ Mock, ResponseTemplate,
23+ matchers::{body_partial_json, method, path, path_regex, query_param},
24+};
25+
26+#[tokio::test]
27+async fn joining_by_room_id_forwards_via_servers() {
28+ let room_id = room_id!("!join:localhost");
29+ let (server, handle, mut events, running) = start_engine().await;
30+ Mock::given(method("POST"))
31+ .and(path(format!("/_matrix/client/v3/join/{room_id}")))
32+ .and(query_param("via", "relay.example"))
33+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "room_id": room_id })))
34+ .expect(1)
35+ .mount(server.server())
36+ .await;
37+
38+ let joined_room = handle
39+ .join_room(
40+ room_id.to_owned().into(),
41+ vec![server_name!("relay.example").to_owned()],
42+ )
43+ .await
44+ .unwrap();
45+ assert_eq!(joined_room, room_id);
46+ stop_engine(handle, &mut events, running).await;
47+}
48+
49+#[tokio::test]
50+async fn leave_and_forget_report_correlated_success() {
51+ let room_id = room_id!("!leave:localhost");
52+ let (server, client) = configured_client().await;
53+ server.sync_joined_room(&client, room_id).await;
54+ server.mock_room_leave().ok(room_id).expect(1).mount().await;
55+ server.mock_room_forget().ok().expect(1).mount().await;
56+ let (handle, mut events, running) = run_client(client).await;
57+
58+ let leave_id = handle.leave_room(room_id.to_owned()).await.unwrap();
59+ receive_room_success(&mut events, leave_id).await;
60+ let forget_id = handle
61+ .send(Command::Room(RoomCommand::Forget(room_id.to_owned())))
62+ .await
63+ .unwrap();
64+ receive_room_success(&mut events, forget_id).await;
65+ assert_ne!(leave_id, forget_id);
66+ stop_engine(handle, &mut events, running).await;
67+}
68+
69+#[tokio::test]
70+async fn accepting_and_rejecting_invites_use_the_expected_endpoints() {
71+ let accepted = room_id!("!accepted:localhost");
72+ let rejected = room_id!("!rejected:localhost");
73+ let (server, client) = configured_client().await;
74+ server
75+ .sync_room(&client, InvitedRoomBuilder::new(accepted))
76+ .await;
77+ server
78+ .sync_room(&client, InvitedRoomBuilder::new(rejected))
79+ .await;
80+ server.mock_room_join(accepted).ok().expect(1).mount().await;
81+ server
82+ .mock_room_leave()
83+ .ok(rejected)
84+ .expect(1)
85+ .mount()
86+ .await;
87+ let (handle, mut events, running) = run_client(client).await;
88+
89+ let accept_id = handle.accept_invite(accepted.to_owned()).await.unwrap();
90+ receive_room_success(&mut events, accept_id).await;
91+ let reject_id = handle.reject_invite(rejected.to_owned()).await.unwrap();
92+ receive_room_success(&mut events, reject_id).await;
93+ stop_engine(handle, &mut events, running).await;
94+}
95+
96+#[tokio::test]
97+async fn creating_a_room_forwards_options() {
98+ let (server, handle, mut events, running) = start_engine().await;
99+ Mock::given(method("POST"))
100+ .and(path("/_matrix/client/v3/createRoom"))
101+ .and(body_partial_json(json!({
102+ "name": "Planning",
103+ "topic": "Next release",
104+ "room_alias_name": "planning",
105+ "invite": ["@partner:localhost"],
106+ "visibility": "public",
107+ "preset": "public_chat"
108+ })))
109+ .respond_with(
110+ ResponseTemplate::new(200).set_body_json(json!({ "room_id": "!created:localhost" })),
111+ )
112+ .expect(1)
113+ .mount(server.server())
114+ .await;
115+
116+ let created_room = handle
117+ .create_room(CreateRoomOptions {
118+ name: Some("Planning".to_owned()),
119+ topic: Some("Next release".to_owned()),
120+ alias_localpart: Some("planning".to_owned()),
121+ invite: vec![user_id!("@partner:localhost").to_owned()],
122+ visibility: RoomVisibility::Public,
123+ preset: Some(RoomPreset::PublicChat),
124+ ..Default::default()
125+ })
126+ .await
127+ .unwrap();
128+ assert_eq!(created_room, room_id!("!created:localhost").to_owned());
129+ stop_engine(handle, &mut events, running).await;
130+}
131+
132+#[tokio::test]
133+async fn creating_a_direct_room_invites_the_other_user() {
134+ let (server, handle, mut events, running) = start_engine().await;
135+ Mock::given(method("POST"))
136+ .and(path("/_matrix/client/v3/createRoom"))
137+ .and(body_partial_json(json!({
138+ "invite": ["@partner:localhost"],
139+ "is_direct": true,
140+ "preset": "trusted_private_chat"
141+ })))
142+ .respond_with(
143+ ResponseTemplate::new(200).set_body_json(json!({ "room_id": "!direct:localhost" })),
144+ )
145+ .expect(1)
146+ .mount(server.server())
147+ .await;
148+ Mock::given(method("GET"))
149+ .and(path_regex(
150+ r"^/_matrix/client/v3/user/.*/account_data/m\.direct$",
151+ ))
152+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
153+ .expect(1)
154+ .mount(server.server())
155+ .await;
156+ Mock::given(method("PUT"))
157+ .and(path_regex(
158+ r"^/_matrix/client/v3/user/.*/account_data/m\.direct$",
159+ ))
160+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
161+ .expect(1)
162+ .mount(server.server())
163+ .await;
164+
165+ let direct_room = handle
166+ .create_direct(user_id!("@partner:localhost").to_owned())
167+ .await
168+ .unwrap();
169+ assert_eq!(direct_room, room_id!("!direct:localhost").to_owned());
170+ stop_engine(handle, &mut events, running).await;
171+}
172+
173+#[tokio::test]
174+async fn unknown_rooms_fail_membership_commands_with_their_ids() {
175+ let (server, handle, mut events, running) = start_engine().await;
176+ let unknown = room_id!("!unknown:localhost");
177+ for command in [
178+ RoomCommand::Leave(unknown.to_owned()),
179+ RoomCommand::Forget(unknown.to_owned()),
180+ RoomCommand::AcceptInvite(unknown.to_owned()),
181+ RoomCommand::RejectInvite(unknown.to_owned()),
182+ ] {
183+ let id = handle.send(Command::Room(command)).await.unwrap();
184+ receive_room_failure(&mut events, id, CommandErrorKind::RoomUnknown).await;
185+ }
186+ stop_engine(handle, &mut events, running).await;
187+ drop(server);
188+}
189+
190+#[tokio::test]
191+async fn account_data_reads_synced_values_and_writes_raw_content() {
192+ let room_id = room_id!("!account-data:localhost");
193+ let (server, client) = configured_client().await;
194+ server
195+ .sync_room(
196+ &client,
197+ JoinedRoomBuilder::new(room_id).add_account_data(
198+ Raw::new(&json!({ "type": "m.fully_read", "content": { "event_id": "$seen:localhost" } }))
199+ .unwrap()
200+ .cast_unchecked(),
201+ ),
202+ )
203+ .await;
204+ server
205+ .mock_sync()
206+ .ok_and_run(&client, |builder| {
207+ builder.add_custom_global_account_data(json!({
208+ "type": "com.example.preferences",
209+ "content": { "theme": "dark" }
210+ }));
211+ })
212+ .await;
213+ let (engine, handle, mut events) = Engine::from_client(client, EngineConfig::default())
214+ .await
215+ .unwrap();
216+ let running = tokio::spawn(engine.run());
217+ let global_type = GlobalAccountDataEventType::from("com.example.preferences".to_owned());
218+ let room_type = RoomAccountDataEventType::FullyRead;
219+
220+ let global = handle
221+ .global_account_data(global_type.clone())
222+ .await
223+ .unwrap()
224+ .unwrap();
225+ assert_eq!(
226+ global.deserialize_as::<serde_json::Value>().unwrap()["theme"],
227+ "dark"
228+ );
229+ let room = handle
230+ .room_account_data(room_id, room_type.clone())
231+ .await
232+ .unwrap()
233+ .unwrap();
234+ assert_eq!(
235+ room.deserialize_as::<serde_json::Value>().unwrap()["content"]["event_id"],
236+ "$seen:localhost"
237+ );
238+ assert!(matches!(
239+ handle
240+ .room_account_data(room_id!("!missing:localhost"), room_type.clone())
241+ .await,
242+ Err(ClientQueryError::RoomUnknown(_))
243+ ));
244+
245+ Mock::given(method("PUT"))
246+ .and(path(
247+ "/_matrix/client/v3/user/@example:localhost/account_data/com.example.preferences",
248+ ))
249+ .and(body_partial_json(json!({ "theme": "light" })))
250+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
251+ .expect(1)
252+ .mount(server.server())
253+ .await;
254+ server
255+ .mock_set_room_account_data(room_type.clone())
256+ .ok()
257+ .expect(1)
258+ .mount()
259+ .await;
260+ handle
261+ .set_global_account_data(
262+ global_type,
263+ Raw::new(&json!({ "theme": "light" }))
264+ .unwrap()
265+ .cast_unchecked(),
266+ )
267+ .await
268+ .unwrap();
269+ handle
270+ .set_room_account_data(
271+ room_id,
272+ room_type,
273+ Raw::new(&json!({ "event_id": "$later:localhost" }))
274+ .unwrap()
275+ .cast_unchecked(),
276+ )
277+ .await
278+ .unwrap();
279+ assert!(matches!(
280+ handle
281+ .set_room_account_data(
282+ room_id!("!missing:localhost"),
283+ RoomAccountDataEventType::FullyRead,
284+ Raw::new(&json!({ "event_id": "$later:localhost" }))
285+ .unwrap()
286+ .cast_unchecked()
287+ )
288+ .await,
289+ Err(ClientQueryError::RoomUnknown(_))
290+ ));
291+ stop_engine(handle, &mut events, running).await;
292+}
293+
294+async fn configured_client() -> (MatrixMockServer, matrix_sdk::Client) {
295+ let server = MatrixMockServer::new().await;
296+ server
297+ .mock_sliding_sync()
298+ .ok(v5::Response::new("1".to_owned()))
299+ .mount()
300+ .await;
301+ let client = server.client_builder().build().await;
302+ server.mock_room_state_encryption().plain().mount().await;
303+ (server, client)
304+}
305+
306+async fn start_engine() -> (
307+ MatrixMockServer,
308+ ClientHandle,
309+ EventStream,
310+ JoinHandle<Result<(), EngineError>>,
311+) {
312+ let (server, client) = configured_client().await;
313+ let (handle, events, running) = run_client(client).await;
314+ (server, handle, events, running)
315+}
316+
317+async fn run_client(
318+ client: matrix_sdk::Client,
319+) -> (
320+ ClientHandle,
321+ EventStream,
322+ JoinHandle<Result<(), EngineError>>,
323+) {
324+ let config = EngineConfig::default().with_initial_history_limit(0);
325+ let (engine, handle, events) = Engine::from_client(client, config).await.unwrap();
326+ let running = tokio::spawn(engine.run());
327+ (handle, events, running)
328+}
329+
330+async fn receive_room_success(events: &mut EventStream, expected_id: CommandId) {
331+ tokio::time::timeout(Duration::from_secs(3), async {
332+ loop {
333+ match events.recv().await.expect("event stream closed") {
334+ Event::CommandSucceeded {
335+ id,
336+ command: CommandKind::Room,
337+ } if id == expected_id => break,
338+ Event::CommandFailed(failure) if failure.id == expected_id => {
339+ panic!("room command failed {failure:?}")
340+ }
341+ _ => {}
342+ }
343+ }
344+ })
345+ .await
346+ .expect("room command did not complete");
347+}
348+
349+async fn receive_room_failure(
350+ events: &mut EventStream,
351+ expected_id: CommandId,
352+ expected_kind: CommandErrorKind,
353+) {
354+ tokio::time::timeout(Duration::from_secs(3), async {
355+ loop {
356+ match events.recv().await.expect("event stream closed") {
357+ Event::CommandFailed(failure) if failure.id == expected_id => {
358+ assert_eq!(failure.command, CommandKind::Room);
359+ assert_eq!(failure.kind, expected_kind);
360+ break;
361+ }
362+ Event::CommandSucceeded { id, .. } if id == expected_id => {
363+ panic!("room command unexpectedly succeeded")
364+ }
365+ _ => {}
366+ }
367+ }
368+ })
369+ .await
370+ .expect("room command did not fail");
371+}
372+
373+async fn stop_engine(
374+ handle: ClientHandle,
375+ events: &mut EventStream,
376+ running: JoinHandle<Result<(), EngineError>>,
377+) {
378+ let id = handle
379+ .send(Command::Lifecycle(LifecycleCommand::Shutdown))
380+ .await
381+ .unwrap();
382+ tokio::time::timeout(Duration::from_secs(3), async {
383+ let mut succeeded = false;
384+ while let Some(event) = events.recv().await {
385+ match event {
386+ Event::CommandSucceeded {
387+ id: completed,
388+ command: CommandKind::Lifecycle,
389+ } if completed == id => succeeded = true,
390+ Event::Stopped => {
391+ assert!(succeeded);
392+ break;
393+ }
394+ _ => {}
395+ }
396+ }
397+ })
398+ .await
399+ .expect("engine did not stop");
400+ running.await.unwrap().unwrap();
401+}

show truncated diff