atagen

malice

public
Star0
Watch0

Commit 973f8fbdaf80

tarball

atagen <boss@atagen.co>2026-10-22 13:19 UTC

105 files changed+33707-0
init
Added file: .cade+1 additions
@@ -0,0 +1 @@
1+load flake
Added file: .cargo/config.toml+2 additions
@@ -0,0 +1,2 @@
1+[target.wasm32-unknown-unknown]
2+rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]
Added file: .gitignore+12 additions
@@ -0,0 +1,12 @@
1+/target
2+/crates/malice-worker/target
3+/web/node_modules
4+/web/dist
5+/web/dist-device
6+/web/src/worker/pkg
7+/web/.test-dist
8+/result
9+/.direnv
10+/.env
11+/local-data/
12+/data
Added file: Cargo.lock+5013 additions
@@ -0,0 +1,5013 @@
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.1.0",
12+ "proc-macro2",
13+ "quote",
14+ "syn 3.0.6",
15+]
16+
17+[[package]]
18+name = "adler2"
19+version = "2.0.1"
20+source = "registry+https://github.com/rust-lang/crates.io-index"
21+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
22+
23+[[package]]
24+name = "aead"
25+version = "0.5.2"
26+source = "registry+https://github.com/rust-lang/crates.io-index"
27+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
28+dependencies = [
29+ "crypto-common",
30+ "generic-array",
31+]
32+
33+[[package]]
34+name = "aes"
35+version = "0.8.4"
36+source = "registry+https://github.com/rust-lang/crates.io-index"
37+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
38+dependencies = [
39+ "cfg-if",
40+ "cipher",
41+ "cpufeatures 0.2.17",
42+]
43+
44+[[package]]
45+name = "aho-corasick"
46+version = "1.1.5"
47+source = "registry+https://github.com/rust-lang/crates.io-index"
48+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
49+dependencies = [
50+ "memchr",
51+]
52+
53+[[package]]
54+name = "android_system_properties"
55+version = "0.1.6"
56+source = "registry+https://github.com/rust-lang/crates.io-index"
57+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
58+dependencies = [
59+ "libc",
60+]
61+
62+[[package]]
63+name = "anyhow"
64+version = "1.0.104"
65+source = "registry+https://github.com/rust-lang/crates.io-index"
66+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
67+
68+[[package]]
69+name = "anymap2"
70+version = "0.13.0"
71+source = "registry+https://github.com/rust-lang/crates.io-index"
72+checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
73+
74+[[package]]
75+name = "aquamarine"
76+version = "0.6.0"
77+source = "registry+https://github.com/rust-lang/crates.io-index"
78+checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2"
79+dependencies = [
80+ "include_dir",
81+ "itertools 0.10.5",
82+ "proc-macro-error2",
83+ "proc-macro2",
84+ "quote",
85+ "syn 2.0.119",
86+]
87+
88+[[package]]
89+name = "archery"
90+version = "1.2.3"
91+source = "registry+https://github.com/rust-lang/crates.io-index"
92+checksum = "33ca55ee147b1926dbea904f50fe4902494e97bc742205abbbf10c709e43815f"
93+
94+[[package]]
95+name = "arrayvec"
96+version = "0.7.8"
97+source = "registry+https://github.com/rust-lang/crates.io-index"
98+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
99+dependencies = [
100+ "serde",
101+]
102+
103+[[package]]
104+name = "as_variant"
105+version = "1.3.0"
106+source = "registry+https://github.com/rust-lang/crates.io-index"
107+checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a"
108+
109+[[package]]
110+name = "assert-json-diff"
111+version = "2.0.2"
112+source = "registry+https://github.com/rust-lang/crates.io-index"
113+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
114+dependencies = [
115+ "serde",
116+ "serde_json",
117+]
118+
119+[[package]]
120+name = "assert_matches"
121+version = "1.5.0"
122+source = "registry+https://github.com/rust-lang/crates.io-index"
123+checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
124+
125+[[package]]
126+name = "assert_matches2"
127+version = "0.1.4"
128+source = "registry+https://github.com/rust-lang/crates.io-index"
129+checksum = "4b220e7e9afc9b4ebf03f061f1a3f8e09a8aeee303bfab530bff54cfdfe311d6"
130+
131+[[package]]
132+name = "assign"
133+version = "1.1.1"
134+source = "registry+https://github.com/rust-lang/crates.io-index"
135+checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002"
136+
137+[[package]]
138+name = "async-channel"
139+version = "2.5.0"
140+source = "registry+https://github.com/rust-lang/crates.io-index"
141+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
142+dependencies = [
143+ "concurrent-queue",
144+ "event-listener-strategy",
145+ "futures-core",
146+ "pin-project-lite",
147+]
148+
149+[[package]]
150+name = "async-compression"
151+version = "0.4.48"
152+source = "registry+https://github.com/rust-lang/crates.io-index"
153+checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
154+dependencies = [
155+ "compression-codecs",
156+ "compression-core",
157+ "pin-project-lite",
158+ "tokio",
159+]
160+
161+[[package]]
162+name = "async-once-cell"
163+version = "0.5.4"
164+source = "registry+https://github.com/rust-lang/crates.io-index"
165+checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"
166+
167+[[package]]
168+name = "async-rx"
169+version = "0.2.1"
170+source = "registry+https://github.com/rust-lang/crates.io-index"
171+checksum = "a82c1a761cd7b772912fd2500f2c0a850c0c72f5f66543038393ac6cfaa3659a"
172+dependencies = [
173+ "futures-core",
174+ "pin-project-lite",
175+]
176+
177+[[package]]
178+name = "async-stream"
179+version = "0.3.6"
180+source = "registry+https://github.com/rust-lang/crates.io-index"
181+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
182+dependencies = [
183+ "async-stream-impl",
184+ "futures-core",
185+ "pin-project-lite",
186+]
187+
188+[[package]]
189+name = "async-stream-impl"
190+version = "0.3.6"
191+source = "registry+https://github.com/rust-lang/crates.io-index"
192+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
193+dependencies = [
194+ "proc-macro2",
195+ "quote",
196+ "syn 2.0.119",
197+]
198+
199+[[package]]
200+name = "async-trait"
201+version = "0.1.92"
202+source = "registry+https://github.com/rust-lang/crates.io-index"
203+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
204+dependencies = [
205+ "proc-macro2",
206+ "quote",
207+ "syn 3.0.6",
208+]
209+
210+[[package]]
211+name = "async_cell"
212+version = "0.2.3"
213+source = "registry+https://github.com/rust-lang/crates.io-index"
214+checksum = "447ab28afbb345f5408b120702a44e5529ebf90b1796ec76e9528df8e288e6c2"
215+dependencies = [
216+ "loom",
217+]
218+
219+[[package]]
220+name = "atomic-waker"
221+version = "1.1.2"
222+source = "registry+https://github.com/rust-lang/crates.io-index"
223+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
224+
225+[[package]]
226+name = "autocfg"
227+version = "1.5.1"
228+source = "registry+https://github.com/rust-lang/crates.io-index"
229+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
230+
231+[[package]]
232+name = "aws-lc-rs"
233+version = "1.18.1"
234+source = "registry+https://github.com/rust-lang/crates.io-index"
235+checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
236+dependencies = [
237+ "aws-lc-sys",
238+ "zeroize",
239+]
240+
241+[[package]]
242+name = "aws-lc-sys"
243+version = "0.45.0"
244+source = "registry+https://github.com/rust-lang/crates.io-index"
245+checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
246+dependencies = [
247+ "cc",
248+ "cmake",
249+ "dunce",
250+ "fs_extra",
251+ "pkg-config",
252+]
253+
254+[[package]]
255+name = "axum"
256+version = "0.8.9"
257+source = "registry+https://github.com/rust-lang/crates.io-index"
258+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
259+dependencies = [
260+ "axum-core",
261+ "axum-macros",
262+ "bytes",
263+ "form_urlencoded",
264+ "futures-util",
265+ "http",
266+ "http-body",
267+ "http-body-util",
268+ "hyper",
269+ "hyper-util",
270+ "itoa",
271+ "matchit",
272+ "memchr",
273+ "mime",
274+ "multer",
275+ "percent-encoding",
276+ "pin-project-lite",
277+ "serde_core",
278+ "serde_json",
279+ "serde_path_to_error",
280+ "serde_urlencoded",
281+ "sync_wrapper",
282+ "tokio",
283+ "tower",
284+ "tower-layer",
285+ "tower-service",
286+ "tracing",
287+]
288+
289+[[package]]
290+name = "axum-core"
291+version = "0.5.6"
292+source = "registry+https://github.com/rust-lang/crates.io-index"
293+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
294+dependencies = [
295+ "bytes",
296+ "futures-core",
297+ "http",
298+ "http-body",
299+ "http-body-util",
300+ "mime",
301+ "pin-project-lite",
302+ "sync_wrapper",
303+ "tower-layer",
304+ "tower-service",
305+ "tracing",
306+]
307+
308+[[package]]
309+name = "axum-macros"
310+version = "0.5.1"
311+source = "registry+https://github.com/rust-lang/crates.io-index"
312+checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
313+dependencies = [
314+ "proc-macro2",
315+ "quote",
316+ "syn 2.0.119",
317+]
318+
319+[[package]]
320+name = "backon"
321+version = "1.6.0"
322+source = "registry+https://github.com/rust-lang/crates.io-index"
323+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
324+dependencies = [
325+ "fastrand",
326+ "gloo-timers",
327+ "tokio",
328+]
329+
330+[[package]]
331+name = "base64"
332+version = "0.22.1"
333+source = "registry+https://github.com/rust-lang/crates.io-index"
334+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
335+
336+[[package]]
337+name = "base64"
338+version = "0.23.1"
339+source = "registry+https://github.com/rust-lang/crates.io-index"
340+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
341+
342+[[package]]
343+name = "base64ct"
344+version = "1.8.3"
345+source = "registry+https://github.com/rust-lang/crates.io-index"
346+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
347+
348+[[package]]
349+name = "bitflags"
350+version = "2.13.2"
351+source = "registry+https://github.com/rust-lang/crates.io-index"
352+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
353+dependencies = [
354+ "serde_core",
355+]
356+
357+[[package]]
358+name = "bitmaps"
359+version = "3.2.1"
360+source = "registry+https://github.com/rust-lang/crates.io-index"
361+checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"
362+
363+[[package]]
364+name = "blake3"
365+version = "1.8.7"
366+source = "registry+https://github.com/rust-lang/crates.io-index"
367+checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
368+dependencies = [
369+ "arrayvec",
370+ "cc",
371+ "cfg-if",
372+ "constant_time_eq",
373+ "cpufeatures 0.3.1",
374+]
375+
376+[[package]]
377+name = "block-buffer"
378+version = "0.10.4"
379+source = "registry+https://github.com/rust-lang/crates.io-index"
380+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
381+dependencies = [
382+ "generic-array",
383+]
384+
385+[[package]]
386+name = "block-padding"
387+version = "0.3.3"
388+source = "registry+https://github.com/rust-lang/crates.io-index"
389+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
390+dependencies = [
391+ "generic-array",
392+]
393+
394+[[package]]
395+name = "bs58"
396+version = "0.5.1"
397+source = "registry+https://github.com/rust-lang/crates.io-index"
398+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
399+dependencies = [
400+ "tinyvec",
401+]
402+
403+[[package]]
404+name = "bumpalo"
405+version = "3.20.3"
406+source = "registry+https://github.com/rust-lang/crates.io-index"
407+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
408+
409+[[package]]
410+name = "byteorder"
411+version = "1.5.0"
412+source = "registry+https://github.com/rust-lang/crates.io-index"
413+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
414+
415+[[package]]
416+name = "bytes"
417+version = "1.12.1"
418+source = "registry+https://github.com/rust-lang/crates.io-index"
419+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
420+
421+[[package]]
422+name = "bytesize"
423+version = "2.7.0"
424+source = "registry+https://github.com/rust-lang/crates.io-index"
425+checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b"
426+
427+[[package]]
428+name = "cast"
429+version = "0.3.0"
430+source = "registry+https://github.com/rust-lang/crates.io-index"
431+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
432+
433+[[package]]
434+name = "cbc"
435+version = "0.1.2"
436+source = "registry+https://github.com/rust-lang/crates.io-index"
437+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
438+dependencies = [
439+ "cipher",
440+]
441+
442+[[package]]
443+name = "cc"
444+version = "1.4.7"
445+source = "registry+https://github.com/rust-lang/crates.io-index"
446+checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
447+dependencies = [
448+ "find-msvc-tools",
449+ "jobserver",
450+ "libc",
451+ "shlex",
452+]
453+
454+[[package]]
455+name = "cfg-if"
456+version = "1.0.5"
457+source = "registry+https://github.com/rust-lang/crates.io-index"
458+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
459+
460+[[package]]
461+name = "cfg_aliases"
462+version = "0.2.2"
463+source = "registry+https://github.com/rust-lang/crates.io-index"
464+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
465+
466+[[package]]
467+name = "chacha20"
468+version = "0.9.1"
469+source = "registry+https://github.com/rust-lang/crates.io-index"
470+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
471+dependencies = [
472+ "cfg-if",
473+ "cipher",
474+ "cpufeatures 0.2.17",
475+]
476+
477+[[package]]
478+name = "chacha20"
479+version = "0.10.2"
480+source = "registry+https://github.com/rust-lang/crates.io-index"
481+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
482+dependencies = [
483+ "cfg-if",
484+ "cpufeatures 0.3.1",
485+ "rand_core 0.10.1",
486+]
487+
488+[[package]]
489+name = "chacha20poly1305"
490+version = "0.10.1"
491+source = "registry+https://github.com/rust-lang/crates.io-index"
492+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
493+dependencies = [
494+ "aead",
495+ "chacha20 0.9.1",
496+ "cipher",
497+ "poly1305",
498+ "zeroize",
499+]
500+
501+[[package]]
502+name = "chrono"
503+version = "0.4.45"
504+source = "registry+https://github.com/rust-lang/crates.io-index"
505+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
506+dependencies = [
507+ "iana-time-zone",
508+ "js-sys",
509+ "num-traits",
510+ "serde",
511+ "wasm-bindgen",
512+ "windows-link",
513+]
514+
515+[[package]]
516+name = "cipher"
517+version = "0.4.4"
518+source = "registry+https://github.com/rust-lang/crates.io-index"
519+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
520+dependencies = [
521+ "crypto-common",
522+ "inout",
523+ "zeroize",
524+]
525+
526+[[package]]
527+name = "cmake"
528+version = "0.1.58"
529+source = "registry+https://github.com/rust-lang/crates.io-index"
530+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
531+dependencies = [
532+ "cc",
533+]
534+
535+[[package]]
536+name = "combine"
537+version = "4.6.8"
538+source = "registry+https://github.com/rust-lang/crates.io-index"
539+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
540+dependencies = [
541+ "bytes",
542+ "memchr",
543+]
544+
545+[[package]]
546+name = "compression-codecs"
547+version = "0.4.43"
548+source = "registry+https://github.com/rust-lang/crates.io-index"
549+checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
550+dependencies = [
551+ "compression-core",
552+ "flate2",
553+ "memchr",
554+]
555+
556+[[package]]
557+name = "compression-core"
558+version = "0.4.33"
559+source = "registry+https://github.com/rust-lang/crates.io-index"
560+checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
561+
562+[[package]]
563+name = "concurrent-queue"
564+version = "2.5.0"
565+source = "registry+https://github.com/rust-lang/crates.io-index"
566+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
567+dependencies = [
568+ "crossbeam-utils",
569+]
570+
571+[[package]]
572+name = "console"
573+version = "0.16.6"
574+source = "registry+https://github.com/rust-lang/crates.io-index"
575+checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
576+dependencies = [
577+ "encode_unicode",
578+ "libc",
579+ "windows-sys 0.61.2",
580+]
581+
582+[[package]]
583+name = "const-oid"
584+version = "0.9.6"
585+source = "registry+https://github.com/rust-lang/crates.io-index"
586+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
587+
588+[[package]]
589+name = "const_panic"
590+version = "0.2.17"
591+source = "registry+https://github.com/rust-lang/crates.io-index"
592+checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3"
593+dependencies = [
594+ "typewit",
595+]
596+
597+[[package]]
598+name = "constant_time_eq"
599+version = "0.4.2"
600+source = "registry+https://github.com/rust-lang/crates.io-index"
601+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
602+
603+[[package]]
604+name = "core-foundation"
605+version = "0.10.1"
606+source = "registry+https://github.com/rust-lang/crates.io-index"
607+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
608+dependencies = [
609+ "core-foundation-sys",
610+ "libc",
611+]
612+
613+[[package]]
614+name = "core-foundation-sys"
615+version = "0.8.7"
616+source = "registry+https://github.com/rust-lang/crates.io-index"
617+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
618+
619+[[package]]
620+name = "core_detect"
621+version = "1.0.0"
622+source = "registry+https://github.com/rust-lang/crates.io-index"
623+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
624+
625+[[package]]
626+name = "cpufeatures"
627+version = "0.2.17"
628+source = "registry+https://github.com/rust-lang/crates.io-index"
629+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
630+dependencies = [
631+ "libc",
632+]
633+
634+[[package]]
635+name = "cpufeatures"
636+version = "0.3.1"
637+source = "registry+https://github.com/rust-lang/crates.io-index"
638+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
639+dependencies = [
640+ "libc",
641+]
642+
643+[[package]]
644+name = "crc32fast"
645+version = "1.5.2"
646+source = "registry+https://github.com/rust-lang/crates.io-index"
647+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
648+dependencies = [
649+ "cfg-if",
650+]
651+
652+[[package]]
653+name = "crossbeam-utils"
654+version = "0.8.23"
655+source = "registry+https://github.com/rust-lang/crates.io-index"
656+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
657+
658+[[package]]
659+name = "crypto-common"
660+version = "0.1.7"
661+source = "registry+https://github.com/rust-lang/crates.io-index"
662+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
663+dependencies = [
664+ "generic-array",
665+ "rand_core 0.6.4",
666+ "typenum",
667+]
668+
669+[[package]]
670+name = "ctor"
671+version = "0.2.9"
672+source = "registry+https://github.com/rust-lang/crates.io-index"
673+checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
674+dependencies = [
675+ "quote",
676+ "syn 2.0.119",
677+]
678+
679+[[package]]
680+name = "ctr"
681+version = "0.9.2"
682+source = "registry+https://github.com/rust-lang/crates.io-index"
683+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
684+dependencies = [
685+ "cipher",
686+]
687+
688+[[package]]
689+name = "curve25519-dalek"
690+version = "4.1.3"
691+source = "registry+https://github.com/rust-lang/crates.io-index"
692+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
693+dependencies = [
694+ "cfg-if",
695+ "cpufeatures 0.2.17",
696+ "curve25519-dalek-derive",
697+ "digest",
698+ "fiat-crypto",
699+ "rustc_version",
700+ "serde",
701+ "subtle",
702+ "zeroize",
703+]
704+
705+[[package]]
706+name = "curve25519-dalek-derive"
707+version = "0.1.1"
708+source = "registry+https://github.com/rust-lang/crates.io-index"
709+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
710+dependencies = [
711+ "proc-macro2",
712+ "quote",
713+ "syn 2.0.119",
714+]
715+
716+[[package]]
717+name = "date_header"
718+version = "1.0.5"
719+source = "registry+https://github.com/rust-lang/crates.io-index"
720+checksum = "0c03c416ed1a30fbb027ef484ba6ab6f80e1eada675e1a2b92fd673c045a1f1d"
721+
722+[[package]]
723+name = "deadpool"
724+version = "0.12.3"
725+source = "registry+https://github.com/rust-lang/crates.io-index"
726+checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
727+dependencies = [
728+ "deadpool-runtime 0.1.4",
729+ "lazy_static",
730+ "num_cpus",
731+ "tokio",
732+]
733+
734+[[package]]
735+name = "deadpool"
736+version = "0.13.1"
737+source = "registry+https://github.com/rust-lang/crates.io-index"
738+checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e"
739+dependencies = [
740+ "deadpool-runtime 0.3.1",
741+ "tokio",
742+]
743+
744+[[package]]
745+name = "deadpool-runtime"
746+version = "0.1.4"
747+source = "registry+https://github.com/rust-lang/crates.io-index"
748+checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
749+
750+[[package]]
751+name = "deadpool-runtime"
752+version = "0.3.1"
753+source = "registry+https://github.com/rust-lang/crates.io-index"
754+checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae"
755+dependencies = [
756+ "tokio",
757+]
758+
759+[[package]]
760+name = "deadpool-sync"
761+version = "0.2.0"
762+source = "registry+https://github.com/rust-lang/crates.io-index"
763+checksum = "e385cc95d3d582c328b36d1ff90feac061102b001894b555e6b465a2e0eaabbf"
764+dependencies = [
765+ "deadpool-runtime 0.3.1",
766+]
767+
768+[[package]]
769+name = "decancer"
770+version = "3.3.3"
771+source = "registry+https://github.com/rust-lang/crates.io-index"
772+checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"
773+
774+[[package]]
775+name = "delegate-display"
776+version = "3.0.1"
777+source = "registry+https://github.com/rust-lang/crates.io-index"
778+checksum = "54e3a499943fd5180aeffcb708164ffb22125ceeccb05491b5297981d37d6249"
779+dependencies = [
780+ "impartial-ord",
781+ "itoa",
782+ "macroific 3.1.0",
783+ "proc-macro2",
784+ "quote",
785+ "syn 3.0.6",
786+]
787+
788+[[package]]
789+name = "der"
790+version = "0.7.10"
791+source = "registry+https://github.com/rust-lang/crates.io-index"
792+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
793+dependencies = [
794+ "const-oid",
795+ "zeroize",
796+]
797+
798+[[package]]
799+name = "deranged"
800+version = "0.5.8"
801+source = "registry+https://github.com/rust-lang/crates.io-index"
802+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
803+
804+[[package]]
805+name = "derivative"
806+version = "2.2.0"
807+source = "registry+https://github.com/rust-lang/crates.io-index"
808+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
809+dependencies = [
810+ "proc-macro2",
811+ "quote",
812+ "syn 1.0.109",
813+]
814+
815+[[package]]
816+name = "derive_more"
817+version = "1.0.0"
818+source = "registry+https://github.com/rust-lang/crates.io-index"
819+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
820+dependencies = [
821+ "derive_more-impl 1.0.0",
822+]
823+
824+[[package]]
825+name = "derive_more"
826+version = "2.1.1"
827+source = "registry+https://github.com/rust-lang/crates.io-index"
828+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
829+dependencies = [
830+ "derive_more-impl 2.1.1",
831+]
832+
833+[[package]]
834+name = "derive_more-impl"
835+version = "1.0.0"
836+source = "registry+https://github.com/rust-lang/crates.io-index"
837+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
838+dependencies = [
839+ "proc-macro2",
840+ "quote",
841+ "syn 2.0.119",
842+]
843+
844+[[package]]
845+name = "derive_more-impl"
846+version = "2.1.1"
847+source = "registry+https://github.com/rust-lang/crates.io-index"
848+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
849+dependencies = [
850+ "proc-macro2",
851+ "quote",
852+ "rustc_version",
853+ "syn 2.0.119",
854+ "unicode-xid",
855+]
856+
857+[[package]]
858+name = "digest"
859+version = "0.10.7"
860+source = "registry+https://github.com/rust-lang/crates.io-index"
861+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
862+dependencies = [
863+ "block-buffer",
864+ "crypto-common",
865+ "subtle",
866+]
867+
868+[[package]]
869+name = "displaydoc"
870+version = "0.2.7"
871+source = "registry+https://github.com/rust-lang/crates.io-index"
872+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
873+dependencies = [
874+ "proc-macro2",
875+ "quote",
876+ "syn 3.0.6",
877+]
878+
879+[[package]]
880+name = "dunce"
881+version = "1.0.5"
882+source = "registry+https://github.com/rust-lang/crates.io-index"
883+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
884+
885+[[package]]
886+name = "ed25519"
887+version = "2.2.3"
888+source = "registry+https://github.com/rust-lang/crates.io-index"
889+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
890+dependencies = [
891+ "pkcs8",
892+ "serde",
893+ "signature",
894+]
895+
896+[[package]]
897+name = "ed25519-dalek"
898+version = "2.2.0"
899+source = "registry+https://github.com/rust-lang/crates.io-index"
900+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
901+dependencies = [
902+ "curve25519-dalek",
903+ "ed25519",
904+ "rand_core 0.6.4",
905+ "serde",
906+ "sha2",
907+ "subtle",
908+ "zeroize",
909+]
910+
911+[[package]]
912+name = "either"
913+version = "1.18.0"
914+source = "registry+https://github.com/rust-lang/crates.io-index"
915+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
916+
917+[[package]]
918+name = "emojis"
919+version = "0.8.2"
920+source = "registry+https://github.com/rust-lang/crates.io-index"
921+checksum = "0a4d5d50b0b58df5173d8ff1192b4d1422ceae5d981b30d4b6f8ed1d673a2bc4"
922+dependencies = [
923+ "phf",
924+]
925+
926+[[package]]
927+name = "encode_unicode"
928+version = "1.0.0"
929+source = "registry+https://github.com/rust-lang/crates.io-index"
930+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
931+
932+[[package]]
933+name = "encoding_rs"
934+version = "0.8.41"
935+source = "registry+https://github.com/rust-lang/crates.io-index"
936+checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a"
937+dependencies = [
938+ "cfg-if",
939+ "core_detect",
940+ "multiversion",
941+ "multiversion_no_op",
942+ "rustversion",
943+ "scopeguard",
944+ "simdutf8",
945+]
946+
947+[[package]]
948+name = "equivalent"
949+version = "1.0.2"
950+source = "registry+https://github.com/rust-lang/crates.io-index"
951+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
952+
953+[[package]]
954+name = "errno"
955+version = "0.3.14"
956+source = "registry+https://github.com/rust-lang/crates.io-index"
957+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
958+dependencies = [
959+ "libc",
960+ "windows-sys 0.61.2",
961+]
962+
963+[[package]]
964+name = "event-listener"
965+version = "5.4.2"
966+source = "registry+https://github.com/rust-lang/crates.io-index"
967+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
968+dependencies = [
969+ "parking",
970+ "pin-project-lite",
971+]
972+
973+[[package]]
974+name = "event-listener-strategy"
975+version = "0.5.4"
976+source = "registry+https://github.com/rust-lang/crates.io-index"
977+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
978+dependencies = [
979+ "event-listener",
980+ "pin-project-lite",
981+]
982+
983+[[package]]
984+name = "eyeball"
985+version = "0.8.8"
986+source = "registry+https://github.com/rust-lang/crates.io-index"
987+checksum = "d93bd0ebf93d61d6332d3c09a96e97975968a44e19a64c947bde06e6baff383f"
988+dependencies = [
989+ "futures-core",
990+ "readlock",
991+ "readlock-tokio",
992+ "tokio",
993+ "tokio-util",
994+ "tracing",
995+]
996+
997+[[package]]
998+name = "eyeball-im"
999+version = "0.8.0"
1000+source = "registry+https://github.com/rust-lang/crates.io-index"
1001+checksum = "4790c03df183c2b46665c1a58118c04fd3e3976ec2fe16a0aa00e00c9eea7754"
1002+dependencies = [
1003+ "futures-core",
1004+ "imbl",
1005+ "tokio",
1006+ "tracing",
1007+]
1008+
1009+[[package]]
1010+name = "eyeball-im-util"
1011+version = "0.10.0"
1012+source = "registry+https://github.com/rust-lang/crates.io-index"
1013+checksum = "809fa2e8e59c25a41f5b2440aa937f1e84df6ae48b707e65f69ce259b1c44bda"
1014+dependencies = [
1015+ "arrayvec",
1016+ "eyeball-im",
1017+ "futures-core",
1018+ "imbl",
1019+ "pin-project-lite",
1020+ "smallvec",
1021+]
1022+
1023+[[package]]
1024+name = "fallible-iterator"
1025+version = "0.3.0"
1026+source = "registry+https://github.com/rust-lang/crates.io-index"
1027+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
1028+
1029+[[package]]
1030+name = "fallible-streaming-iterator"
1031+version = "0.1.9"
1032+source = "registry+https://github.com/rust-lang/crates.io-index"
1033+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
1034+
1035+[[package]]
1036+name = "fancy_constructor"
1037+version = "2.1.1"
1038+source = "registry+https://github.com/rust-lang/crates.io-index"
1039+checksum = "4a8211ab12c36b63c269e17873331dc1da196a3edb7a5c79015677ef972da34a"
1040+dependencies = [
1041+ "macroific 3.1.0",
1042+ "proc-macro2",
1043+ "quote",
1044+ "syn 3.0.6",
1045+]
1046+
1047+[[package]]
1048+name = "fastrand"
1049+version = "2.5.0"
1050+source = "registry+https://github.com/rust-lang/crates.io-index"
1051+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
1052+
1053+[[package]]
1054+name = "fiat-crypto"
1055+version = "0.2.9"
1056+source = "registry+https://github.com/rust-lang/crates.io-index"
1057+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
1058+
1059+[[package]]
1060+name = "find-msvc-tools"
1061+version = "0.1.13"
1062+source = "registry+https://github.com/rust-lang/crates.io-index"
1063+checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
1064+
1065+[[package]]
1066+name = "flate2"
1067+version = "1.1.10"
1068+source = "registry+https://github.com/rust-lang/crates.io-index"
1069+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
1070+dependencies = [
1071+ "crc32fast",
1072+ "miniz_oxide",
1073+ "zlib-rs",
1074+]
1075+
1076+[[package]]
1077+name = "fnv"
1078+version = "1.0.7"
1079+source = "registry+https://github.com/rust-lang/crates.io-index"
1080+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
1081+
1082+[[package]]
1083+name = "foldhash"
1084+version = "0.1.5"
1085+source = "registry+https://github.com/rust-lang/crates.io-index"
1086+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
1087+
1088+[[package]]
1089+name = "form_urlencoded"
1090+version = "1.2.2"
1091+source = "registry+https://github.com/rust-lang/crates.io-index"
1092+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
1093+dependencies = [
1094+ "percent-encoding",
1095+]
1096+
1097+[[package]]
1098+name = "fs_extra"
1099+version = "1.3.0"
1100+source = "registry+https://github.com/rust-lang/crates.io-index"
1101+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
1102+
1103+[[package]]
1104+name = "futures"
1105+version = "0.3.34"
1106+source = "registry+https://github.com/rust-lang/crates.io-index"
1107+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
1108+dependencies = [
1109+ "futures-channel",
1110+ "futures-core",
1111+ "futures-executor",
1112+ "futures-io",
1113+ "futures-sink",
1114+ "futures-task",
1115+ "futures-util",
1116+]
1117+
1118+[[package]]
1119+name = "futures-channel"
1120+version = "0.3.34"
1121+source = "registry+https://github.com/rust-lang/crates.io-index"
1122+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
1123+dependencies = [
1124+ "futures-core",
1125+ "futures-sink",
1126+]
1127+
1128+[[package]]
1129+name = "futures-core"
1130+version = "0.3.34"
1131+source = "registry+https://github.com/rust-lang/crates.io-index"
1132+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
1133+
1134+[[package]]
1135+name = "futures-executor"
1136+version = "0.3.34"
1137+source = "registry+https://github.com/rust-lang/crates.io-index"
1138+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
1139+dependencies = [
1140+ "futures-core",
1141+ "futures-task",
1142+ "futures-util",
1143+]
1144+
1145+[[package]]
1146+name = "futures-io"
1147+version = "0.3.34"
1148+source = "registry+https://github.com/rust-lang/crates.io-index"
1149+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
1150+
1151+[[package]]
1152+name = "futures-macro"
1153+version = "0.3.34"
1154+source = "registry+https://github.com/rust-lang/crates.io-index"
1155+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
1156+dependencies = [
1157+ "proc-macro2",
1158+ "quote",
1159+ "syn 3.0.6",
1160+]
1161+
1162+[[package]]
1163+name = "futures-sink"
1164+version = "0.3.34"
1165+source = "registry+https://github.com/rust-lang/crates.io-index"
1166+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
1167+
1168+[[package]]
1169+name = "futures-task"
1170+version = "0.3.34"
1171+source = "registry+https://github.com/rust-lang/crates.io-index"
1172+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
1173+
1174+[[package]]
1175+name = "futures-util"
1176+version = "0.3.34"
1177+source = "registry+https://github.com/rust-lang/crates.io-index"
1178+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
1179+dependencies = [
1180+ "futures-channel",
1181+ "futures-core",
1182+ "futures-io",
1183+ "futures-macro",
1184+ "futures-sink",
1185+ "futures-task",
1186+ "memchr",
1187+ "pin-project-lite",
1188+ "slab",
1189+]
1190+
1191+[[package]]
1192+name = "fuzzy-matcher"
1193+version = "0.3.7"
1194+source = "registry+https://github.com/rust-lang/crates.io-index"
1195+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
1196+dependencies = [
1197+ "thread_local",
1198+]
1199+
1200+[[package]]
1201+name = "generator"
1202+version = "0.8.10"
1203+source = "registry+https://github.com/rust-lang/crates.io-index"
1204+checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551"
1205+dependencies = [
1206+ "cc",
1207+ "cfg-if",
1208+ "libc",
1209+ "log",
1210+ "rustversion",
1211+ "windows-link",
1212+ "windows-result",
1213+]
1214+
1215+[[package]]
1216+name = "generic-array"
1217+version = "0.14.7"
1218+source = "registry+https://github.com/rust-lang/crates.io-index"
1219+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1220+dependencies = [
1221+ "typenum",
1222+ "version_check",
1223+]
1224+
1225+[[package]]
1226+name = "getrandom"
1227+version = "0.2.17"
1228+source = "registry+https://github.com/rust-lang/crates.io-index"
1229+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
1230+dependencies = [
1231+ "cfg-if",
1232+ "js-sys",
1233+ "libc",
1234+ "wasi",
1235+ "wasm-bindgen",
1236+]
1237+
1238+[[package]]
1239+name = "getrandom"
1240+version = "0.3.4"
1241+source = "registry+https://github.com/rust-lang/crates.io-index"
1242+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1243+dependencies = [
1244+ "cfg-if",
1245+ "js-sys",
1246+ "libc",
1247+ "r-efi 5.3.0",
1248+ "wasip2",
1249+ "wasm-bindgen",
1250+]
1251+
1252+[[package]]
1253+name = "getrandom"
1254+version = "0.4.3"
1255+source = "registry+https://github.com/rust-lang/crates.io-index"
1256+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
1257+dependencies = [
1258+ "cfg-if",
1259+ "js-sys",
1260+ "libc",
1261+ "r-efi 6.0.0",
1262+ "rand_core 0.10.1",
1263+ "wasm-bindgen",
1264+]
1265+
1266+[[package]]
1267+name = "gloo-timers"
1268+version = "0.3.0"
1269+source = "registry+https://github.com/rust-lang/crates.io-index"
1270+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
1271+dependencies = [
1272+ "futures-channel",
1273+ "futures-core",
1274+ "js-sys",
1275+ "wasm-bindgen",
1276+]
1277+
1278+[[package]]
1279+name = "gloo-utils"
1280+version = "0.2.0"
1281+source = "registry+https://github.com/rust-lang/crates.io-index"
1282+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
1283+dependencies = [
1284+ "js-sys",
1285+ "serde",
1286+ "serde_json",
1287+ "wasm-bindgen",
1288+ "web-sys",
1289+]
1290+
1291+[[package]]
1292+name = "growable-bloom-filter"
1293+version = "2.1.1"
1294+source = "registry+https://github.com/rust-lang/crates.io-index"
1295+checksum = "d174ccb4ba660d431329e7f0797870d0a4281e36353ec4b4a3c5eab6c2cfb6f1"
1296+dependencies = [
1297+ "serde",
1298+ "serde_bytes",
1299+ "serde_derive",
1300+ "xxhash-rust",
1301+]
1302+
1303+[[package]]
1304+name = "h2"
1305+version = "0.4.19"
1306+source = "registry+https://github.com/rust-lang/crates.io-index"
1307+checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
1308+dependencies = [
1309+ "atomic-waker",
1310+ "bytes",
1311+ "fnv",
1312+ "futures-core",
1313+ "futures-sink",
1314+ "http",
1315+ "indexmap",
1316+ "slab",
1317+ "tokio",
1318+ "tokio-util",
1319+ "tracing",
1320+]
1321+
1322+[[package]]
1323+name = "hashbrown"
1324+version = "0.15.5"
1325+source = "registry+https://github.com/rust-lang/crates.io-index"
1326+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
1327+dependencies = [
1328+ "foldhash",
1329+]
1330+
1331+[[package]]
1332+name = "hashbrown"
1333+version = "0.17.1"
1334+source = "registry+https://github.com/rust-lang/crates.io-index"
1335+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
1336+
1337+[[package]]
1338+name = "hashlink"
1339+version = "0.10.0"
1340+source = "registry+https://github.com/rust-lang/crates.io-index"
1341+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
1342+dependencies = [
1343+ "hashbrown 0.15.5",
1344+]
1345+
1346+[[package]]
1347+name = "hermit-abi"
1348+version = "0.5.3"
1349+source = "registry+https://github.com/rust-lang/crates.io-index"
1350+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
1351+
1352+[[package]]
1353+name = "hex"
1354+version = "0.4.3"
1355+source = "registry+https://github.com/rust-lang/crates.io-index"
1356+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1357+
1358+[[package]]
1359+name = "hkdf"
1360+version = "0.12.4"
1361+source = "registry+https://github.com/rust-lang/crates.io-index"
1362+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
1363+dependencies = [
1364+ "hmac",
1365+]
1366+
1367+[[package]]
1368+name = "hmac"
1369+version = "0.12.1"
1370+source = "registry+https://github.com/rust-lang/crates.io-index"
1371+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
1372+dependencies = [
1373+ "digest",
1374+]
1375+
1376+[[package]]
1377+name = "html5ever"
1378+version = "0.39.0"
1379+source = "registry+https://github.com/rust-lang/crates.io-index"
1380+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
1381+dependencies = [
1382+ "log",
1383+ "markup5ever",
1384+]
1385+
1386+[[package]]
1387+name = "http"
1388+version = "1.5.0"
1389+source = "registry+https://github.com/rust-lang/crates.io-index"
1390+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
1391+dependencies = [
1392+ "bytes",
1393+ "itoa",
1394+]
1395+
1396+[[package]]
1397+name = "http-body"
1398+version = "1.1.0"
1399+source = "registry+https://github.com/rust-lang/crates.io-index"
1400+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
1401+dependencies = [
1402+ "bytes",
1403+ "http",
1404+]
1405+
1406+[[package]]
1407+name = "http-body-util"
1408+version = "0.1.5"
1409+source = "registry+https://github.com/rust-lang/crates.io-index"
1410+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
1411+dependencies = [
1412+ "bytes",
1413+ "futures-core",
1414+ "http",
1415+ "http-body",
1416+ "pin-project-lite",
1417+]
1418+
1419+[[package]]
1420+name = "http-range-header"
1421+version = "0.4.2"
1422+source = "registry+https://github.com/rust-lang/crates.io-index"
1423+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
1424+
1425+[[package]]
1426+name = "httparse"
1427+version = "1.10.1"
1428+source = "registry+https://github.com/rust-lang/crates.io-index"
1429+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
1430+
1431+[[package]]
1432+name = "httpdate"
1433+version = "1.0.3"
1434+source = "registry+https://github.com/rust-lang/crates.io-index"
1435+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
1436+
1437+[[package]]
1438+name = "hyper"
1439+version = "1.11.1"
1440+source = "registry+https://github.com/rust-lang/crates.io-index"
1441+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
1442+dependencies = [
1443+ "atomic-waker",
1444+ "bytes",
1445+ "futures-channel",
1446+ "futures-core",
1447+ "h2",
1448+ "http",
1449+ "http-body",
1450+ "httparse",
1451+ "httpdate",
1452+ "itoa",
1453+ "pin-project-lite",
1454+ "smallvec",
1455+ "tokio",
1456+ "want",
1457+]
1458+
1459+[[package]]
1460+name = "hyper-rustls"
1461+version = "0.27.10"
1462+source = "registry+https://github.com/rust-lang/crates.io-index"
1463+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
1464+dependencies = [
1465+ "http",
1466+ "hyper",
1467+ "hyper-util",
1468+ "rustls",
1469+ "tokio",
1470+ "tokio-rustls",
1471+ "tower-service",
1472+]
1473+
1474+[[package]]
1475+name = "hyper-util"
1476+version = "0.1.20"
1477+source = "registry+https://github.com/rust-lang/crates.io-index"
1478+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
1479+dependencies = [
1480+ "base64 0.22.1",
1481+ "bytes",
1482+ "futures-channel",
1483+ "futures-util",
1484+ "http",
1485+ "http-body",
1486+ "hyper",
1487+ "ipnet",
1488+ "libc",
1489+ "percent-encoding",
1490+ "pin-project-lite",
1491+ "socket2",
1492+ "tokio",
1493+ "tower-service",
1494+ "tracing",
1495+]
1496+
1497+[[package]]
1498+name = "iana-time-zone"
1499+version = "0.1.65"
1500+source = "registry+https://github.com/rust-lang/crates.io-index"
1501+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
1502+dependencies = [
1503+ "android_system_properties",
1504+ "core-foundation-sys",
1505+ "iana-time-zone-haiku",
1506+ "js-sys",
1507+ "log",
1508+ "wasm-bindgen",
1509+ "windows-core",
1510+]
1511+
1512+[[package]]
1513+name = "iana-time-zone-haiku"
1514+version = "0.1.2"
1515+source = "registry+https://github.com/rust-lang/crates.io-index"
1516+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1517+dependencies = [
1518+ "cc",
1519+]
1520+
1521+[[package]]
1522+name = "icu_collections"
1523+version = "2.3.0"
1524+source = "registry+https://github.com/rust-lang/crates.io-index"
1525+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
1526+dependencies = [
1527+ "displaydoc",
1528+ "potential_utf",
1529+ "utf8_iter",
1530+ "yoke",
1531+ "zerofrom",
1532+ "zerovec",
1533+]
1534+
1535+[[package]]
1536+name = "icu_locale_core"
1537+version = "2.3.0"
1538+source = "registry+https://github.com/rust-lang/crates.io-index"
1539+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
1540+dependencies = [
1541+ "displaydoc",
1542+ "litemap",
1543+ "tinystr",
1544+ "writeable",
1545+ "zerovec",
1546+]
1547+
1548+[[package]]
1549+name = "icu_normalizer"
1550+version = "2.3.0"
1551+source = "registry+https://github.com/rust-lang/crates.io-index"
1552+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
1553+dependencies = [
1554+ "icu_collections",
1555+ "icu_normalizer_data",
1556+ "icu_properties",
1557+ "icu_provider",
1558+ "smallvec",
1559+ "zerovec",
1560+]
1561+
1562+[[package]]
1563+name = "icu_normalizer_data"
1564+version = "2.3.0"
1565+source = "registry+https://github.com/rust-lang/crates.io-index"
1566+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
1567+
1568+[[package]]
1569+name = "icu_properties"
1570+version = "2.3.0"
1571+source = "registry+https://github.com/rust-lang/crates.io-index"
1572+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
1573+dependencies = [
1574+ "displaydoc",
1575+ "icu_collections",
1576+ "icu_locale_core",
1577+ "icu_properties_data",
1578+ "icu_provider",
1579+ "zerotrie",
1580+ "zerovec",
1581+]
1582+
1583+[[package]]
1584+name = "icu_properties_data"
1585+version = "2.3.0"
1586+source = "registry+https://github.com/rust-lang/crates.io-index"
1587+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
1588+
1589+[[package]]
1590+name = "icu_provider"
1591+version = "2.3.1"
1592+source = "registry+https://github.com/rust-lang/crates.io-index"
1593+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
1594+dependencies = [
1595+ "displaydoc",
1596+ "icu_locale_core",
1597+ "writeable",
1598+ "yoke",
1599+ "zerofrom",
1600+ "zerotrie",
1601+ "zerovec",
1602+]
1603+
1604+[[package]]
1605+name = "idna"
1606+version = "1.1.0"
1607+source = "registry+https://github.com/rust-lang/crates.io-index"
1608+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
1609+dependencies = [
1610+ "idna_adapter",
1611+ "smallvec",
1612+ "utf8_iter",
1613+]
1614+
1615+[[package]]
1616+name = "idna_adapter"
1617+version = "1.2.2"
1618+source = "registry+https://github.com/rust-lang/crates.io-index"
1619+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
1620+dependencies = [
1621+ "icu_normalizer",
1622+ "icu_properties",
1623+]
1624+
1625+[[package]]
1626+name = "imbl"
1627+version = "6.1.0"
1628+source = "registry+https://github.com/rust-lang/crates.io-index"
1629+checksum = "0fade8ae6828627ad1fa094a891eccfb25150b383047190a3648d66d06186501"
1630+dependencies = [
1631+ "archery",
1632+ "bitmaps",
1633+ "imbl-sized-chunks",
1634+ "rand_core 0.9.5",
1635+ "rand_xoshiro",
1636+ "serde",
1637+ "version_check",
1638+]
1639+
1640+[[package]]
1641+name = "imbl-sized-chunks"
1642+version = "0.1.3"
1643+source = "registry+https://github.com/rust-lang/crates.io-index"
1644+checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d"
1645+dependencies = [
1646+ "bitmaps",
1647+]
1648+
1649+[[package]]
1650+name = "impartial-ord"
1651+version = "2.0.0"
1652+source = "registry+https://github.com/rust-lang/crates.io-index"
1653+checksum = "bc680e03d1a08bdc1c01b60e0c39723430ced63d8cabd93cee0449b6ba916a25"
1654+dependencies = [
1655+ "proc-macro2",
1656+ "quote",
1657+ "syn 3.0.6",
1658+]
1659+
1660+[[package]]
1661+name = "include_dir"
1662+version = "0.7.4"
1663+source = "registry+https://github.com/rust-lang/crates.io-index"
1664+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
1665+dependencies = [
1666+ "include_dir_macros",
1667+]
1668+
1669+[[package]]
1670+name = "include_dir_macros"
1671+version = "0.7.4"
1672+source = "registry+https://github.com/rust-lang/crates.io-index"
1673+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
1674+dependencies = [
1675+ "proc-macro2",
1676+ "quote",
1677+]
1678+
1679+[[package]]
1680+name = "indexmap"
1681+version = "2.14.2"
1682+source = "registry+https://github.com/rust-lang/crates.io-index"
1683+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
1684+dependencies = [
1685+ "equivalent",
1686+ "hashbrown 0.17.1",
1687+ "serde",
1688+ "serde_core",
1689+]
1690+
1691+[[package]]
1692+name = "inout"
1693+version = "0.1.4"
1694+source = "registry+https://github.com/rust-lang/crates.io-index"
1695+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
1696+dependencies = [
1697+ "block-padding",
1698+ "generic-array",
1699+]
1700+
1701+[[package]]
1702+name = "insta"
1703+version = "1.48.0"
1704+source = "registry+https://github.com/rust-lang/crates.io-index"
1705+checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
1706+dependencies = [
1707+ "console",
1708+ "once_cell",
1709+ "pest",
1710+ "pest_derive",
1711+ "serde",
1712+ "similar",
1713+ "tempfile",
1714+]
1715+
1716+[[package]]
1717+name = "ipnet"
1718+version = "2.12.2"
1719+source = "registry+https://github.com/rust-lang/crates.io-index"
1720+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
1721+
1722+[[package]]
1723+name = "itertools"
1724+version = "0.10.5"
1725+source = "registry+https://github.com/rust-lang/crates.io-index"
1726+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
1727+dependencies = [
1728+ "either",
1729+]
1730+
1731+[[package]]
1732+name = "itertools"
1733+version = "0.14.0"
1734+source = "registry+https://github.com/rust-lang/crates.io-index"
1735+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
1736+dependencies = [
1737+ "either",
1738+]
1739+
1740+[[package]]
1741+name = "itoa"
1742+version = "1.0.18"
1743+source = "registry+https://github.com/rust-lang/crates.io-index"
1744+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1745+
1746+[[package]]
1747+name = "jni"
1748+version = "0.22.4"
1749+source = "registry+https://github.com/rust-lang/crates.io-index"
1750+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
1751+dependencies = [
1752+ "cfg-if",
1753+ "combine",
1754+ "jni-macros",
1755+ "jni-sys",
1756+ "log",
1757+ "simd_cesu8",
1758+ "thiserror 2.0.21",
1759+ "walkdir",
1760+ "windows-link",
1761+]
1762+
1763+[[package]]
1764+name = "jni-macros"
1765+version = "0.22.4"
1766+source = "registry+https://github.com/rust-lang/crates.io-index"
1767+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
1768+dependencies = [
1769+ "proc-macro2",
1770+ "quote",
1771+ "rustc_version",
1772+ "simd_cesu8",
1773+ "syn 2.0.119",
1774+]
1775+
1776+[[package]]
1777+name = "jni-sys"
1778+version = "0.4.1"
1779+source = "registry+https://github.com/rust-lang/crates.io-index"
1780+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
1781+dependencies = [
1782+ "jni-sys-macros",
1783+]
1784+
1785+[[package]]
1786+name = "jni-sys-macros"
1787+version = "0.4.1"
1788+source = "registry+https://github.com/rust-lang/crates.io-index"
1789+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
1790+dependencies = [
1791+ "quote",
1792+ "syn 2.0.119",
1793+]
1794+
1795+[[package]]
1796+name = "jobserver"
1797+version = "0.1.35"
1798+source = "registry+https://github.com/rust-lang/crates.io-index"
1799+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
1800+dependencies = [
1801+ "getrandom 0.4.3",
1802+ "libc",
1803+]
1804+
1805+[[package]]
1806+name = "js-sys"
1807+version = "0.3.105"
1808+source = "registry+https://github.com/rust-lang/crates.io-index"
1809+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
1810+dependencies = [
1811+ "cfg-if",
1812+ "futures-util",
1813+ "wasm-bindgen",
1814+]
1815+
1816+[[package]]
1817+name = "js_int"
1818+version = "0.2.2"
1819+source = "registry+https://github.com/rust-lang/crates.io-index"
1820+checksum = "d937f95470b270ce8b8950207715d71aa8e153c0d44c6684d59397ed4949160a"
1821+dependencies = [
1822+ "serde",
1823+]
1824+
1825+[[package]]
1826+name = "js_option"
1827+version = "0.2.0"
1828+source = "registry+https://github.com/rust-lang/crates.io-index"
1829+checksum = "c7dd3e281add16813cf673bf74a32249b0aa0d1c8117519a17b3ada5e8552b3c"
1830+dependencies = [
1831+ "serde_core",
1832+]
1833+
1834+[[package]]
1835+name = "konst"
1836+version = "0.4.3"
1837+source = "registry+https://github.com/rust-lang/crates.io-index"
1838+checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d"
1839+dependencies = [
1840+ "const_panic",
1841+ "typewit",
1842+]
1843+
1844+[[package]]
1845+name = "language-tags"
1846+version = "0.3.2"
1847+source = "registry+https://github.com/rust-lang/crates.io-index"
1848+checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
1849+
1850+[[package]]
1851+name = "lazy_static"
1852+version = "1.5.0"
1853+source = "registry+https://github.com/rust-lang/crates.io-index"
1854+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1855+
1856+[[package]]
1857+name = "libc"
1858+version = "0.2.189"
1859+source = "registry+https://github.com/rust-lang/crates.io-index"
1860+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
1861+
1862+[[package]]
1863+name = "libm"
1864+version = "0.2.16"
1865+source = "registry+https://github.com/rust-lang/crates.io-index"
1866+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1867+
1868+[[package]]
1869+name = "libsqlite3-sys"
1870+version = "0.35.0"
1871+source = "registry+https://github.com/rust-lang/crates.io-index"
1872+checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
1873+dependencies = [
1874+ "cc",
1875+ "pkg-config",
1876+ "vcpkg",
1877+]
1878+
1879+[[package]]
1880+name = "linux-raw-sys"
1881+version = "0.12.1"
1882+source = "registry+https://github.com/rust-lang/crates.io-index"
1883+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
1884+
1885+[[package]]
1886+name = "listenfd"
1887+version = "1.0.2"
1888+source = "registry+https://github.com/rust-lang/crates.io-index"
1889+checksum = "b87bc54a4629b4294d0b3ef041b64c40c611097a677d9dc07b2c67739fe39dba"
1890+dependencies = [
1891+ "libc",
1892+ "uuid",
1893+ "winapi",
1894+]
1895+
1896+[[package]]
1897+name = "litemap"
1898+version = "0.8.3"
1899+source = "registry+https://github.com/rust-lang/crates.io-index"
1900+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
1901+
1902+[[package]]
1903+name = "lock_api"
1904+version = "0.4.14"
1905+source = "registry+https://github.com/rust-lang/crates.io-index"
1906+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1907+dependencies = [
1908+ "scopeguard",
1909+]
1910+
1911+[[package]]
1912+name = "log"
1913+version = "0.4.34"
1914+source = "registry+https://github.com/rust-lang/crates.io-index"
1915+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
1916+
1917+[[package]]
1918+name = "loom"
1919+version = "0.7.2"
1920+source = "registry+https://github.com/rust-lang/crates.io-index"
1921+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
1922+dependencies = [
1923+ "cfg-if",
1924+ "generator",
1925+ "scoped-tls",
1926+ "tracing",
1927+ "tracing-subscriber",
1928+]
1929+
1930+[[package]]
1931+name = "lru-slab"
1932+version = "0.1.3"
1933+source = "registry+https://github.com/rust-lang/crates.io-index"
1934+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
1935+
1936+[[package]]
1937+name = "macltok"
1938+version = "0.1.0"
1939+dependencies = [
1940+ "futures-util",
1941+ "getrandom 0.2.17",
1942+ "getrandom 0.3.4",
1943+ "getrandom 0.4.3",
1944+ "http",
1945+ "imbl",
1946+ "js-sys",
1947+ "matrix-sdk",
1948+ "matrix-sdk-crypto",
1949+ "matrix-sdk-store-encryption",
1950+ "matrix-sdk-test",
1951+ "matrix-sdk-ui",
1952+ "matrix_indexed_db_futures",
1953+ "mime",
1954+ "reqwest",
1955+ "ruma",
1956+ "serde",
1957+ "serde_json",
1958+ "sha2",
1959+ "thiserror 2.0.21",
1960+ "tokio",
1961+ "tracing",
1962+ "url",
1963+ "uuid",
1964+ "wasm-bindgen",
1965+ "wasm-bindgen-futures",
1966+ "web-sys",
1967+ "wiremock",
1968+]
1969+
1970+[[package]]
1971+name = "macroific"
1972+version = "2.0.0"
1973+source = "registry+https://github.com/rust-lang/crates.io-index"
1974+checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285"
1975+dependencies = [
1976+ "macroific_attr_parse 2.0.0",
1977+ "macroific_core 2.0.0",
1978+ "macroific_macro 2.0.0",
1979+]
1980+
1981+[[package]]
1982+name = "macroific"
1983+version = "3.1.0"
1984+source = "registry+https://github.com/rust-lang/crates.io-index"
1985+checksum = "cc8ab95c98294764c0f1eacebdb5b027849201b71fbc383871fe991a1de7eb83"
1986+dependencies = [
1987+ "macroific_attr_parse 3.1.0",
1988+ "macroific_core 3.1.0",
1989+ "macroific_macro 3.1.0",
1990+]
1991+
1992+[[package]]
1993+name = "macroific_attr_parse"
1994+version = "2.0.0"
1995+source = "registry+https://github.com/rust-lang/crates.io-index"
1996+checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9"
1997+dependencies = [
1998+ "proc-macro2",
1999+ "quote",
2000+ "sealed 0.6.0",
2001+ "syn 2.0.119",
2002+]
2003+
2004+[[package]]
2005+name = "macroific_attr_parse"
2006+version = "3.1.0"
2007+source = "registry+https://github.com/rust-lang/crates.io-index"
2008+checksum = "bf9b7bdb92825e6a507392b12f0570ade17480be20f4ca951b9071b40deba1d1"
2009+dependencies = [
2010+ "proc-macro2",
2011+ "quote",
2012+ "sealed 0.7.0",
2013+ "syn 3.0.6",
2014+]
2015+
2016+[[package]]
2017+name = "macroific_core"
2018+version = "2.0.0"
2019+source = "registry+https://github.com/rust-lang/crates.io-index"
2020+checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3"
2021+dependencies = [
2022+ "proc-macro2",
2023+ "quote",
2024+ "sealed 0.6.0",
2025+ "syn 2.0.119",
2026+]
2027+
2028+[[package]]
2029+name = "macroific_core"
2030+version = "3.1.0"
2031+source = "registry+https://github.com/rust-lang/crates.io-index"
2032+checksum = "edc880da2bc00d6d87f748805e242d670e248d1b9b6fcef3190503210b6cf056"
2033+dependencies = [
2034+ "proc-macro2",
2035+ "quote",
2036+ "sealed 0.7.0",
2037+ "syn 3.0.6",
2038+]
2039+
2040+[[package]]
2041+name = "macroific_macro"
2042+version = "2.0.0"
2043+source = "registry+https://github.com/rust-lang/crates.io-index"
2044+checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b"
2045+dependencies = [
2046+ "macroific_attr_parse 2.0.0",
2047+ "macroific_core 2.0.0",
2048+ "proc-macro2",
2049+ "quote",
2050+ "syn 2.0.119",
2051+]
2052+
2053+[[package]]
2054+name = "macroific_macro"
2055+version = "3.1.0"
2056+source = "registry+https://github.com/rust-lang/crates.io-index"
2057+checksum = "201faf668c264432727fb64e44407bcfcf1f5f8153bf4ec248eb6bbe4a39b144"
2058+dependencies = [
2059+ "macroific_attr_parse 3.1.0",
2060+ "macroific_core 3.1.0",
2061+ "proc-macro2",
2062+ "quote",
2063+ "syn 3.0.6",
2064+]
2065+
2066+[[package]]
2067+name = "malice"
2068+version = "0.1.0"
2069+dependencies = [
2070+ "axum",
2071+ "futures-util",
2072+ "hex",
2073+ "http-body-util",
2074+ "listenfd",
2075+ "macltok",
2076+ "malice-core",
2077+ "matrix-sdk",
2078+ "rand_core 0.6.4",
2079+ "reqwest",
2080+ "ruma",
2081+ "serde",
2082+ "serde_json",
2083+ "sha2",
2084+ "tempfile",
2085+ "tokio",
2086+ "tower",
2087+ "tower-http",
2088+ "tracing",
2089+ "tracing-subscriber",
2090+ "wiremock",
2091+]
2092+
2093+[[package]]
2094+name = "malice-core"
2095+version = "0.1.0"
2096+dependencies = [
2097+ "futures-util",
2098+ "http",
2099+ "macltok",
2100+ "ruma",
2101+ "serde",
2102+ "serde_json",
2103+]
2104+
2105+[[package]]
2106+name = "maplit"
2107+version = "1.0.2"
2108+source = "registry+https://github.com/rust-lang/crates.io-index"
2109+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
2110+
2111+[[package]]
2112+name = "markup5ever"
2113+version = "0.39.0"
2114+source = "registry+https://github.com/rust-lang/crates.io-index"
2115+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
2116+dependencies = [
2117+ "log",
2118+ "tendril",
2119+ "web_atoms",
2120+]
2121+
2122+[[package]]
2123+name = "matchers"
2124+version = "0.2.0"
2125+source = "registry+https://github.com/rust-lang/crates.io-index"
2126+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
2127+dependencies = [
2128+ "regex-automata",
2129+]
2130+
2131+[[package]]
2132+name = "matchit"
2133+version = "0.8.4"
2134+source = "registry+https://github.com/rust-lang/crates.io-index"
2135+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
2136+
2137+[[package]]
2138+name = "matrix-pickle"
2139+version = "0.2.3"
2140+source = "registry+https://github.com/rust-lang/crates.io-index"
2141+checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0"
2142+dependencies = [
2143+ "matrix-pickle-derive",
2144+ "thiserror 2.0.21",
2145+]
2146+
2147+[[package]]
2148+name = "matrix-pickle-derive"
2149+version = "0.2.3"
2150+source = "registry+https://github.com/rust-lang/crates.io-index"
2151+checksum = "414b5e4c34009f2bc3fe35dd018f25755ca38858096574841c7332f99e2c7e77"
2152+dependencies = [
2153+ "proc-macro-crate",
2154+ "proc-macro2",
2155+ "quote",
2156+ "syn 2.0.119",
2157+]
2158+
2159+[[package]]
2160+name = "matrix-sdk"
2161+version = "0.18.0"
2162+source = "registry+https://github.com/rust-lang/crates.io-index"
2163+checksum = "7083d580527511ac5d9369e03b9f2b20902e76949f1b3964051f978e4d3756ae"
2164+dependencies = [
2165+ "anymap2",
2166+ "aquamarine",
2167+ "as_variant",
2168+ "assert_matches2",
2169+ "async-channel",
2170+ "async-once-cell",
2171+ "async-stream",
2172+ "async-trait",
2173+ "backon",
2174+ "bytes",
2175+ "bytesize",
2176+ "cfg-if",
2177+ "event-listener",
2178+ "eyeball",
2179+ "eyeball-im",
2180+ "futures-core",
2181+ "futures-util",
2182+ "gloo-timers",
2183+ "http",
2184+ "imbl",
2185+ "indexmap",
2186+ "itertools 0.14.0",
2187+ "js_int",
2188+ "language-tags",
2189+ "matrix-sdk-base",
2190+ "matrix-sdk-common",
2191+ "matrix-sdk-indexeddb",
2192+ "matrix-sdk-sqlite",
2193+ "matrix-sdk-test",
2194+ "mime",
2195+ "mime2ext",
2196+ "oauth2",
2197+ "oauth2-reqwest",
2198+ "percent-encoding",
2199+ "pin-project-lite",
2200+ "reqwest",
2201+ "ruma",
2202+ "rustls",
2203+ "rustls-native-certs",
2204+ "rustls-pki-types",
2205+ "serde",
2206+ "serde_html_form",
2207+ "serde_json",
2208+ "sha2",
2209+ "tempfile",
2210+ "thiserror 2.0.21",
2211+ "tokio",
2212+ "tokio-stream",
2213+ "tokio-util",
2214+ "tracing",
2215+ "url",
2216+ "urlencoding",
2217+ "vodozemac",
2218+ "webpki-roots",
2219+ "wiremock",
2220+ "zeroize",
2221+]
2222+
2223+[[package]]
2224+name = "matrix-sdk-base"
2225+version = "0.18.0"
2226+source = "registry+https://github.com/rust-lang/crates.io-index"
2227+checksum = "e09a917eb1f7643d9d9a06b2f131e2ceb39df6910a7b830dac18bfd6db37a1f5"
2228+dependencies = [
2229+ "as_variant",
2230+ "assert_matches",
2231+ "assert_matches2",
2232+ "async-trait",
2233+ "bitflags",
2234+ "decancer",
2235+ "eyeball",
2236+ "eyeball-im",
2237+ "futures-util",
2238+ "growable-bloom-filter",
2239+ "http",
2240+ "matrix-sdk-common",
2241+ "matrix-sdk-crypto",
2242+ "matrix-sdk-store-encryption",
2243+ "matrix-sdk-test",
2244+ "regex",
2245+ "ruma",
2246+ "serde",
2247+ "serde_json",
2248+ "thiserror 2.0.21",
2249+ "tokio",
2250+ "tracing",
2251+ "unicode-normalization",
2252+]
2253+
2254+[[package]]
2255+name = "matrix-sdk-common"
2256+version = "0.18.0"
2257+source = "registry+https://github.com/rust-lang/crates.io-index"
2258+checksum = "2b9d1e0fee0f090180ef9457034adc547e80de9e6e3eb2e63aaac68a8476f836"
2259+dependencies = [
2260+ "eyeball-im",
2261+ "futures-core",
2262+ "futures-executor",
2263+ "futures-util",
2264+ "gloo-timers",
2265+ "imbl",
2266+ "ruma",
2267+ "serde",
2268+ "serde_json",
2269+ "thiserror 2.0.21",
2270+ "tokio",
2271+ "tracing",
2272+ "tracing-subscriber",
2273+ "wasm-bindgen",
2274+ "wasm-bindgen-futures",
2275+ "web-sys",
2276+]
2277+
2278+[[package]]
2279+name = "matrix-sdk-crypto"
2280+version = "0.18.0"
2281+source = "registry+https://github.com/rust-lang/crates.io-index"
2282+checksum = "c54afd2a326f51c13a6ad44ec86315a688fffeb3f1e287fb343e0e1836a3bdaf"
2283+dependencies = [
2284+ "aes",
2285+ "aquamarine",
2286+ "as_variant",
2287+ "async-trait",
2288+ "bs58",
2289+ "byteorder",
2290+ "cfg-if",
2291+ "ctr",
2292+ "eyeball",
2293+ "futures-core",
2294+ "futures-util",
2295+ "hkdf",
2296+ "hmac",
2297+ "itertools 0.14.0",
2298+ "js_option",
2299+ "matrix-sdk-common",
2300+ "matrix-sdk-test",
2301+ "pbkdf2",
2302+ "rand 0.10.3",
2303+ "rmp-serde",
2304+ "ruma",
2305+ "serde",
2306+ "serde_json",
2307+ "sha2",
2308+ "subtle",
2309+ "thiserror 2.0.21",
2310+ "time",
2311+ "tokio",
2312+ "tokio-stream",
2313+ "tracing",
2314+ "ulid",
2315+ "url",
2316+ "vodozemac",
2317+ "zeroize",
2318+]
2319+
2320+[[package]]
2321+name = "matrix-sdk-indexeddb"
2322+version = "0.18.0"
2323+source = "registry+https://github.com/rust-lang/crates.io-index"
2324+checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47"
2325+dependencies = [
2326+ "async-trait",
2327+ "base64 0.22.1",
2328+ "futures-util",
2329+ "getrandom 0.4.3",
2330+ "gloo-utils",
2331+ "growable-bloom-filter",
2332+ "hkdf",
2333+ "js-sys",
2334+ "matrix-sdk-base",
2335+ "matrix-sdk-crypto",
2336+ "matrix-sdk-store-encryption",
2337+ "matrix_indexed_db_futures",
2338+ "rmp-serde",
2339+ "ruma",
2340+ "serde",
2341+ "serde-wasm-bindgen",
2342+ "serde_json",
2343+ "sha2",
2344+ "thiserror 2.0.21",
2345+ "tokio",
2346+ "tracing",
2347+ "uuid",
2348+ "wasm-bindgen",
2349+ "web-sys",
2350+ "zeroize",
2351+]
2352+
2353+[[package]]
2354+name = "matrix-sdk-sqlite"
2355+version = "0.18.0"
2356+source = "registry+https://github.com/rust-lang/crates.io-index"
2357+checksum = "a49133429271005745f8d5a05362d2ba6567274777250236799927ca30dc0670"
2358+dependencies = [
2359+ "as_variant",
2360+ "async-trait",
2361+ "deadpool 0.13.1",
2362+ "deadpool-sync",
2363+ "itertools 0.14.0",
2364+ "matrix-sdk-base",
2365+ "matrix-sdk-crypto",
2366+ "matrix-sdk-store-encryption",
2367+ "num_cpus",
2368+ "rmp-serde",
2369+ "ruma",
2370+ "rusqlite",
2371+ "serde",
2372+ "serde_json",
2373+ "serde_path_to_error",
2374+ "thiserror 2.0.21",
2375+ "tokio",
2376+ "tracing",
2377+ "vodozemac",
2378+ "zeroize",
2379+]
2380+
2381+[[package]]
2382+name = "matrix-sdk-store-encryption"
2383+version = "0.18.0"
2384+source = "registry+https://github.com/rust-lang/crates.io-index"
2385+checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e"
2386+dependencies = [
2387+ "base64 0.22.1",
2388+ "blake3",
2389+ "chacha20poly1305",
2390+ "getrandom 0.2.17",
2391+ "getrandom 0.4.3",
2392+ "hmac",
2393+ "pbkdf2",
2394+ "rand 0.10.3",
2395+ "rmp-serde",
2396+ "serde",
2397+ "serde_json",
2398+ "sha2",
2399+ "thiserror 2.0.21",
2400+ "zeroize",
2401+]
2402+
2403+[[package]]
2404+name = "matrix-sdk-test"
2405+version = "0.18.0"
2406+source = "registry+https://github.com/rust-lang/crates.io-index"
2407+checksum = "8023d4315fff35598c2d4a955920c1273c6aaab4dcd9ed405238a882581649bf"
2408+dependencies = [
2409+ "as_variant",
2410+ "ctor",
2411+ "getrandom 0.4.3",
2412+ "http",
2413+ "insta",
2414+ "matrix-sdk-common",
2415+ "matrix-sdk-test-macros",
2416+ "ruma",
2417+ "serde",
2418+ "serde_json",
2419+ "sha2",
2420+ "tokio",
2421+ "tracing-subscriber",
2422+ "vodozemac",
2423+ "wasm-bindgen-test",
2424+ "wiremock",
2425+]
2426+
2427+[[package]]
2428+name = "matrix-sdk-test-macros"
2429+version = "0.18.0"
2430+source = "registry+https://github.com/rust-lang/crates.io-index"
2431+checksum = "d97d6a0e5b1401c530b3c5d18cb98257d255d396bacf3903beda6718a2f4c4c5"
2432+dependencies = [
2433+ "quote",
2434+ "syn 2.0.119",
2435+]
2436+
2437+[[package]]
2438+name = "matrix-sdk-ui"
2439+version = "0.18.0"
2440+source = "registry+https://github.com/rust-lang/crates.io-index"
2441+checksum = "6d1f2b8a8cf57164d7cee4eb61c0b9f074b2e11d766a9391b7db0b9f2cbd83d8"
2442+dependencies = [
2443+ "as_variant",
2444+ "async-rx",
2445+ "async-stream",
2446+ "async_cell",
2447+ "bitflags",
2448+ "chrono",
2449+ "emojis",
2450+ "eyeball",
2451+ "eyeball-im",
2452+ "eyeball-im-util",
2453+ "futures-core",
2454+ "futures-util",
2455+ "fuzzy-matcher",
2456+ "growable-bloom-filter",
2457+ "imbl",
2458+ "indexmap",
2459+ "itertools 0.14.0",
2460+ "matrix-sdk",
2461+ "matrix-sdk-base",
2462+ "matrix-sdk-common",
2463+ "mime",
2464+ "pin-project-lite",
2465+ "ruma",
2466+ "serde",
2467+ "serde_json",
2468+ "thiserror 2.0.21",
2469+ "tokio",
2470+ "tokio-stream",
2471+ "tracing",
2472+ "unicode-normalization",
2473+ "unicode-segmentation",
2474+]
2475+
2476+[[package]]
2477+name = "matrix_indexed_db_futures"
2478+version = "0.7.0"
2479+source = "registry+https://github.com/rust-lang/crates.io-index"
2480+checksum = "245ff6a224b4df7b0c90dda2dd5a6eb46112708d49e8bdd8b007fccb09fea8e4"
2481+dependencies = [
2482+ "accessory",
2483+ "cfg-if",
2484+ "delegate-display",
2485+ "derive_more 2.1.1",
2486+ "fancy_constructor",
2487+ "futures-core",
2488+ "js-sys",
2489+ "matrix_indexed_db_futures_macros_internal",
2490+ "sealed 0.6.0",
2491+ "serde",
2492+ "serde-wasm-bindgen",
2493+ "smallvec",
2494+ "thiserror 2.0.21",
2495+ "tokio",
2496+ "wasm-bindgen",
2497+ "wasm-bindgen-futures",
2498+ "wasm_evt_listener",
2499+ "web-sys",
2500+ "web-time",
2501+]
2502+
2503+[[package]]
2504+name = "matrix_indexed_db_futures_macros_internal"
2505+version = "1.0.0"
2506+source = "registry+https://github.com/rust-lang/crates.io-index"
2507+checksum = "b428aee5c0fe9e5babd29e99d289b7f64718c444989aac0442d1fd6d3e3f66d1"
2508+dependencies = [
2509+ "macroific 2.0.0",
2510+ "proc-macro2",
2511+ "quote",
2512+ "syn 2.0.119",
2513+]
2514+
2515+[[package]]
2516+name = "memchr"
2517+version = "2.8.3"
2518+source = "registry+https://github.com/rust-lang/crates.io-index"
2519+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
2520+
2521+[[package]]
2522+name = "mime"
2523+version = "0.3.17"
2524+source = "registry+https://github.com/rust-lang/crates.io-index"
2525+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
2526+
2527+[[package]]
2528+name = "mime2ext"
2529+version = "0.1.54"
2530+source = "registry+https://github.com/rust-lang/crates.io-index"
2531+checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc"
2532+
2533+[[package]]
2534+name = "mime_guess"
2535+version = "2.0.5"
2536+source = "registry+https://github.com/rust-lang/crates.io-index"
2537+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
2538+dependencies = [
2539+ "mime",
2540+ "unicase",
2541+]
2542+
2543+[[package]]
2544+name = "minicov"
2545+version = "0.3.8"
2546+source = "registry+https://github.com/rust-lang/crates.io-index"
2547+checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
2548+dependencies = [
2549+ "cc",
2550+ "walkdir",
2551+]
2552+
2553+[[package]]
2554+name = "miniz_oxide"
2555+version = "0.9.1"
2556+source = "registry+https://github.com/rust-lang/crates.io-index"
2557+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
2558+dependencies = [
2559+ "adler2",
2560+ "simd-adler32",
2561+]
2562+
2563+[[package]]
2564+name = "mio"
2565+version = "1.2.3"
2566+source = "registry+https://github.com/rust-lang/crates.io-index"
2567+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
2568+dependencies = [
2569+ "libc",
2570+ "wasi",
2571+ "windows-sys 0.61.2",
2572+]
2573+
2574+[[package]]
2575+name = "multer"
2576+version = "3.1.0"
2577+source = "registry+https://github.com/rust-lang/crates.io-index"
2578+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
2579+dependencies = [
2580+ "bytes",
2581+ "encoding_rs",
2582+ "futures-util",
2583+ "http",
2584+ "httparse",
2585+ "memchr",
2586+ "mime",
2587+ "spin",
2588+ "version_check",
2589+]
2590+
2591+[[package]]
2592+name = "multiversion"
2593+version = "0.9.0"
2594+source = "registry+https://github.com/rust-lang/crates.io-index"
2595+checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c"
2596+dependencies = [
2597+ "multiversion-macros",
2598+]
2599+
2600+[[package]]
2601+name = "multiversion-macros"
2602+version = "0.9.0"
2603+source = "registry+https://github.com/rust-lang/crates.io-index"
2604+checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83"
2605+dependencies = [
2606+ "proc-macro2",
2607+ "quote",
2608+ "rustversion",
2609+ "syn 3.0.6",
2610+]
2611+
2612+[[package]]
2613+name = "multiversion_no_op"
2614+version = "1.0.0"
2615+source = "registry+https://github.com/rust-lang/crates.io-index"
2616+checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
2617+
2618+[[package]]
2619+name = "new_debug_unreachable"
2620+version = "1.0.6"
2621+source = "registry+https://github.com/rust-lang/crates.io-index"
2622+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
2623+
2624+[[package]]
2625+name = "nu-ansi-term"
2626+version = "0.50.3"
2627+source = "registry+https://github.com/rust-lang/crates.io-index"
2628+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
2629+dependencies = [
2630+ "windows-sys 0.61.2",
2631+]
2632+
2633+[[package]]
2634+name = "num-conv"
2635+version = "0.2.2"
2636+source = "registry+https://github.com/rust-lang/crates.io-index"
2637+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
2638+
2639+[[package]]
2640+name = "num-traits"
2641+version = "0.2.19"
2642+source = "registry+https://github.com/rust-lang/crates.io-index"
2643+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
2644+dependencies = [
2645+ "autocfg",
2646+ "libm",
2647+]
2648+
2649+[[package]]
2650+name = "num_cpus"
2651+version = "1.17.0"
2652+source = "registry+https://github.com/rust-lang/crates.io-index"
2653+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
2654+dependencies = [
2655+ "hermit-abi",
2656+ "libc",
2657+]
2658+
2659+[[package]]
2660+name = "oauth2"
2661+version = "5.0.0"
2662+source = "registry+https://github.com/rust-lang/crates.io-index"
2663+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
2664+dependencies = [
2665+ "base64 0.22.1",
2666+ "chrono",
2667+ "getrandom 0.2.17",
2668+ "http",
2669+ "rand 0.8.8",
2670+ "serde",
2671+ "serde_json",
2672+ "serde_path_to_error",
2673+ "sha2",
2674+ "thiserror 1.0.69",
2675+ "url",
2676+]
2677+
2678+[[package]]
2679+name = "oauth2-reqwest"
2680+version = "0.1.0-alpha.3"
2681+source = "registry+https://github.com/rust-lang/crates.io-index"
2682+checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2"
2683+dependencies = [
2684+ "oauth2",
2685+ "reqwest",
2686+]
2687+
2688+[[package]]
2689+name = "once_cell"
2690+version = "1.21.4"
2691+source = "registry+https://github.com/rust-lang/crates.io-index"
2692+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
2693+
2694+[[package]]
2695+name = "oorandom"
2696+version = "11.1.5"
2697+source = "registry+https://github.com/rust-lang/crates.io-index"
2698+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
2699+
2700+[[package]]
2701+name = "opaque-debug"
2702+version = "0.3.1"
2703+source = "registry+https://github.com/rust-lang/crates.io-index"
2704+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
2705+
2706+[[package]]
2707+name = "openssl-probe"
2708+version = "0.2.1"
2709+source = "registry+https://github.com/rust-lang/crates.io-index"
2710+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
2711+
2712+[[package]]
2713+name = "parking"
2714+version = "2.2.1"
2715+source = "registry+https://github.com/rust-lang/crates.io-index"
2716+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2717+
2718+[[package]]
2719+name = "parking_lot"
2720+version = "0.12.5"
2721+source = "registry+https://github.com/rust-lang/crates.io-index"
2722+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
2723+dependencies = [
2724+ "lock_api",
2725+ "parking_lot_core",
2726+]
2727+
2728+[[package]]
2729+name = "parking_lot_core"
2730+version = "0.9.12"
2731+source = "registry+https://github.com/rust-lang/crates.io-index"
2732+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
2733+dependencies = [
2734+ "cfg-if",
2735+ "libc",
2736+ "redox_syscall",
2737+ "smallvec",
2738+ "windows-link",
2739+]
2740+
2741+[[package]]
2742+name = "pbkdf2"
2743+version = "0.12.2"
2744+source = "registry+https://github.com/rust-lang/crates.io-index"
2745+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
2746+dependencies = [
2747+ "digest",
2748+]
2749+
2750+[[package]]
2751+name = "percent-encoding"
2752+version = "2.3.2"
2753+source = "registry+https://github.com/rust-lang/crates.io-index"
2754+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
2755+
2756+[[package]]
2757+name = "pest"
2758+version = "2.9.2"
2759+source = "registry+https://github.com/rust-lang/crates.io-index"
2760+checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
2761+dependencies = [
2762+ "memchr",
2763+ "ucd-trie",
2764+]
2765+
2766+[[package]]
2767+name = "pest_derive"
2768+version = "2.9.2"
2769+source = "registry+https://github.com/rust-lang/crates.io-index"
2770+checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
2771+dependencies = [
2772+ "pest",
2773+ "pest_generator",
2774+]
2775+
2776+[[package]]
2777+name = "pest_generator"
2778+version = "2.9.2"
2779+source = "registry+https://github.com/rust-lang/crates.io-index"
2780+checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
2781+dependencies = [
2782+ "pest",
2783+ "pest_meta",
2784+ "proc-macro2",
2785+ "quote",
2786+ "syn 2.0.119",
2787+]
2788+
2789+[[package]]
2790+name = "pest_meta"
2791+version = "2.9.2"
2792+source = "registry+https://github.com/rust-lang/crates.io-index"
2793+checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
2794+dependencies = [
2795+ "pest",
2796+]
2797+
2798+[[package]]
2799+name = "phf"
2800+version = "0.13.1"
2801+source = "registry+https://github.com/rust-lang/crates.io-index"
2802+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
2803+dependencies = [
2804+ "phf_shared",
2805+ "serde",
2806+]
2807+
2808+[[package]]
2809+name = "phf_codegen"
2810+version = "0.13.1"
2811+source = "registry+https://github.com/rust-lang/crates.io-index"
2812+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
2813+dependencies = [
2814+ "phf_generator",
2815+ "phf_shared",
2816+]
2817+
2818+[[package]]
2819+name = "phf_generator"
2820+version = "0.13.1"
2821+source = "registry+https://github.com/rust-lang/crates.io-index"
2822+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
2823+dependencies = [
2824+ "fastrand",
2825+ "phf_shared",
2826+]
2827+
2828+[[package]]
2829+name = "phf_shared"
2830+version = "0.13.1"
2831+source = "registry+https://github.com/rust-lang/crates.io-index"
2832+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
2833+dependencies = [
2834+ "siphasher",
2835+]
2836+
2837+[[package]]
2838+name = "pin-project-lite"
2839+version = "0.2.17"
2840+source = "registry+https://github.com/rust-lang/crates.io-index"
2841+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
2842+
2843+[[package]]
2844+name = "pkcs8"
2845+version = "0.10.2"
2846+source = "registry+https://github.com/rust-lang/crates.io-index"
2847+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
2848+dependencies = [
2849+ "der",
2850+ "spki",
2851+]
2852+
2853+[[package]]
2854+name = "pkg-config"
2855+version = "0.3.34"
2856+source = "registry+https://github.com/rust-lang/crates.io-index"
2857+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
2858+
2859+[[package]]
2860+name = "poly1305"
2861+version = "0.8.0"
2862+source = "registry+https://github.com/rust-lang/crates.io-index"
2863+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
2864+dependencies = [
2865+ "cpufeatures 0.2.17",
2866+ "opaque-debug",
2867+ "universal-hash",
2868+]
2869+
2870+[[package]]
2871+name = "potential_utf"
2872+version = "0.1.6"
2873+source = "registry+https://github.com/rust-lang/crates.io-index"
2874+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
2875+dependencies = [
2876+ "zerovec",
2877+]
2878+
2879+[[package]]
2880+name = "powerfmt"
2881+version = "0.2.0"
2882+source = "registry+https://github.com/rust-lang/crates.io-index"
2883+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
2884+
2885+[[package]]
2886+name = "ppv-lite86"
2887+version = "0.2.21"
2888+source = "registry+https://github.com/rust-lang/crates.io-index"
2889+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
2890+dependencies = [
2891+ "zerocopy",
2892+]
2893+
2894+[[package]]
2895+name = "precomputed-hash"
2896+version = "0.1.1"
2897+source = "registry+https://github.com/rust-lang/crates.io-index"
2898+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
2899+
2900+[[package]]
2901+name = "proc-macro-crate"
2902+version = "3.5.0"
2903+source = "registry+https://github.com/rust-lang/crates.io-index"
2904+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
2905+dependencies = [
2906+ "toml_edit",
2907+]
2908+
2909+[[package]]
2910+name = "proc-macro-error-attr2"
2911+version = "2.0.0"
2912+source = "registry+https://github.com/rust-lang/crates.io-index"
2913+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
2914+dependencies = [
2915+ "proc-macro2",
2916+ "quote",
2917+]
2918+
2919+[[package]]
2920+name = "proc-macro-error2"
2921+version = "2.0.1"
2922+source = "registry+https://github.com/rust-lang/crates.io-index"
2923+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
2924+dependencies = [
2925+ "proc-macro-error-attr2",
2926+ "proc-macro2",
2927+ "quote",
2928+]
2929+
2930+[[package]]
2931+name = "proc-macro2"
2932+version = "1.0.107"
2933+source = "registry+https://github.com/rust-lang/crates.io-index"
2934+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
2935+dependencies = [
2936+ "unicode-ident",
2937+]
2938+
2939+[[package]]
2940+name = "prost"
2941+version = "0.14.4"
2942+source = "registry+https://github.com/rust-lang/crates.io-index"
2943+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
2944+dependencies = [
2945+ "bytes",
2946+ "prost-derive",
2947+]
2948+
2949+[[package]]
2950+name = "prost-derive"
2951+version = "0.14.4"
2952+source = "registry+https://github.com/rust-lang/crates.io-index"
2953+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
2954+dependencies = [
2955+ "anyhow",
2956+ "itertools 0.14.0",
2957+ "proc-macro2",
2958+ "quote",
2959+ "syn 2.0.119",
2960+]
2961+
2962+[[package]]
2963+name = "quinn"
2964+version = "0.11.12"
2965+source = "registry+https://github.com/rust-lang/crates.io-index"
2966+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
2967+dependencies = [
2968+ "bytes",
2969+ "cfg_aliases",
2970+ "pin-project-lite",
2971+ "quinn-proto",
2972+ "quinn-udp",
2973+ "rustc-hash",
2974+ "rustls",
2975+ "socket2",
2976+ "thiserror 2.0.21",
2977+ "tokio",
2978+ "tracing",
2979+ "web-time",
2980+]
2981+
2982+[[package]]
2983+name = "quinn-proto"
2984+version = "0.11.18"
2985+source = "registry+https://github.com/rust-lang/crates.io-index"
2986+checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
2987+dependencies = [
2988+ "aws-lc-rs",
2989+ "bytes",
2990+ "getrandom 0.4.3",
2991+ "lru-slab",
2992+ "rand 0.10.3",
2993+ "rand_pcg",
2994+ "ring",
2995+ "rustc-hash",
2996+ "rustls",
2997+ "rustls-pki-types",
2998+ "slab",
2999+ "thiserror 2.0.21",
3000+ "tinyvec",
3001+ "tracing",
3002+ "web-time",
3003+]
3004+
3005+[[package]]
3006+name = "quinn-udp"
3007+version = "0.5.15"
3008+source = "registry+https://github.com/rust-lang/crates.io-index"
3009+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
3010+dependencies = [
3011+ "cfg_aliases",
3012+ "libc",
3013+ "once_cell",
3014+ "socket2",
3015+ "tracing",
3016+ "windows-sys 0.61.2",
3017+]
3018+
3019+[[package]]
3020+name = "quote"
3021+version = "1.0.47"
3022+source = "registry+https://github.com/rust-lang/crates.io-index"
3023+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
3024+dependencies = [
3025+ "proc-macro2",
3026+]
3027+
3028+[[package]]
3029+name = "r-efi"
3030+version = "5.3.0"
3031+source = "registry+https://github.com/rust-lang/crates.io-index"
3032+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
3033+
3034+[[package]]
3035+name = "r-efi"
3036+version = "6.0.0"
3037+source = "registry+https://github.com/rust-lang/crates.io-index"
3038+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
3039+
3040+[[package]]
3041+name = "rand"
3042+version = "0.8.8"
3043+source = "registry+https://github.com/rust-lang/crates.io-index"
3044+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
3045+dependencies = [
3046+ "libc",
3047+ "rand_chacha 0.3.1",
3048+ "rand_core 0.6.4",
3049+]
3050+
3051+[[package]]
3052+name = "rand"
3053+version = "0.9.5"
3054+source = "registry+https://github.com/rust-lang/crates.io-index"
3055+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
3056+dependencies = [
3057+ "rand_chacha 0.9.0",
3058+ "rand_core 0.9.5",
3059+]
3060+
3061+[[package]]
3062+name = "rand"
3063+version = "0.10.3"
3064+source = "registry+https://github.com/rust-lang/crates.io-index"
3065+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
3066+dependencies = [
3067+ "chacha20 0.10.2",
3068+ "getrandom 0.4.3",
3069+ "rand_core 0.10.1",
3070+]
3071+
3072+[[package]]
3073+name = "rand_chacha"
3074+version = "0.3.1"
3075+source = "registry+https://github.com/rust-lang/crates.io-index"
3076+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
3077+dependencies = [
3078+ "ppv-lite86",
3079+ "rand_core 0.6.4",
3080+]
3081+
3082+[[package]]
3083+name = "rand_chacha"
3084+version = "0.9.0"
3085+source = "registry+https://github.com/rust-lang/crates.io-index"
3086+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
3087+dependencies = [
3088+ "ppv-lite86",
3089+ "rand_core 0.9.5",
3090+]
3091+
3092+[[package]]
3093+name = "rand_core"
3094+version = "0.6.4"
3095+source = "registry+https://github.com/rust-lang/crates.io-index"
3096+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
3097+dependencies = [
3098+ "getrandom 0.2.17",
3099+]
3100+
3101+[[package]]
3102+name = "rand_core"
3103+version = "0.9.5"
3104+source = "registry+https://github.com/rust-lang/crates.io-index"
3105+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
3106+dependencies = [
3107+ "getrandom 0.3.4",
3108+]
3109+
3110+[[package]]
3111+name = "rand_core"
3112+version = "0.10.1"
3113+source = "registry+https://github.com/rust-lang/crates.io-index"
3114+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
3115+
3116+[[package]]
3117+name = "rand_pcg"
3118+version = "0.10.2"
3119+source = "registry+https://github.com/rust-lang/crates.io-index"
3120+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
3121+dependencies = [
3122+ "rand_core 0.10.1",
3123+]
3124+
3125+[[package]]
3126+name = "rand_xoshiro"
3127+version = "0.7.0"
3128+source = "registry+https://github.com/rust-lang/crates.io-index"
3129+checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
3130+dependencies = [
3131+ "rand_core 0.9.5",
3132+]
3133+
3134+[[package]]
3135+name = "readlock"
3136+version = "0.1.11"
3137+source = "registry+https://github.com/rust-lang/crates.io-index"
3138+checksum = "6da6f291b23556edd9edaf655a0be2ad8ef8002ff5f1bca62b264f3f58b53f34"
3139+
3140+[[package]]
3141+name = "readlock-tokio"
3142+version = "0.1.6"
3143+source = "registry+https://github.com/rust-lang/crates.io-index"
3144+checksum = "fc7e264f9ec4f3d112e8e2f214e8e7cb5cf3b83278f3570b7e00bfe13d3bd8ff"
3145+dependencies = [
3146+ "tokio",
3147+]
3148+
3149+[[package]]
3150+name = "redox_syscall"
3151+version = "0.5.18"
3152+source = "registry+https://github.com/rust-lang/crates.io-index"
3153+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
3154+dependencies = [
3155+ "bitflags",
3156+]
3157+
3158+[[package]]
3159+name = "regex"
3160+version = "1.13.1"
3161+source = "registry+https://github.com/rust-lang/crates.io-index"
3162+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
3163+dependencies = [
3164+ "aho-corasick",
3165+ "memchr",
3166+ "regex-automata",
3167+ "regex-syntax",
3168+]
3169+
3170+[[package]]
3171+name = "regex-automata"
3172+version = "0.4.18"
3173+source = "registry+https://github.com/rust-lang/crates.io-index"
3174+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
3175+dependencies = [
3176+ "aho-corasick",
3177+ "memchr",
3178+ "regex-syntax",
3179+]
3180+
3181+[[package]]
3182+name = "regex-syntax"
3183+version = "0.8.11"
3184+source = "registry+https://github.com/rust-lang/crates.io-index"
3185+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
3186+
3187+[[package]]
3188+name = "reqwest"
3189+version = "0.13.5"
3190+source = "registry+https://github.com/rust-lang/crates.io-index"
3191+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
3192+dependencies = [
3193+ "base64 0.23.1",
3194+ "bytes",
3195+ "futures-core",
3196+ "futures-util",
3197+ "h2",
3198+ "http",
3199+ "http-body",
3200+ "http-body-util",
3201+ "hyper",
3202+ "hyper-rustls",
3203+ "hyper-util",
3204+ "js-sys",
3205+ "log",
3206+ "percent-encoding",
3207+ "pin-project-lite",
3208+ "quinn",
3209+ "rustls",
3210+ "rustls-pki-types",
3211+ "rustls-platform-verifier",
3212+ "serde",
3213+ "serde_json",
3214+ "serde_urlencoded",
3215+ "sync_wrapper",
3216+ "tokio",
3217+ "tokio-rustls",
3218+ "tokio-util",
3219+ "tower",
3220+ "tower-http",
3221+ "tower-service",
3222+ "url",
3223+ "wasm-bindgen",
3224+ "wasm-bindgen-futures",
3225+ "wasm-streams",
3226+ "web-sys",
3227+]
3228+
3229+[[package]]
3230+name = "ring"
3231+version = "0.17.14"
3232+source = "registry+https://github.com/rust-lang/crates.io-index"
3233+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
3234+dependencies = [
3235+ "cc",
3236+ "cfg-if",
3237+ "getrandom 0.2.17",
3238+ "libc",
3239+ "untrusted",
3240+ "windows-sys 0.52.0",
3241+]
3242+
3243+[[package]]
3244+name = "rmp"
3245+version = "0.8.15"
3246+source = "registry+https://github.com/rust-lang/crates.io-index"
3247+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
3248+dependencies = [
3249+ "num-traits",
3250+]
3251+
3252+[[package]]
3253+name = "rmp-serde"
3254+version = "1.3.1"
3255+source = "registry+https://github.com/rust-lang/crates.io-index"
3256+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
3257+dependencies = [
3258+ "rmp",
3259+ "serde",
3260+]
3261+
3262+[[package]]
3263+name = "ruma"
3264+version = "0.16.0"
3265+source = "registry+https://github.com/rust-lang/crates.io-index"
3266+checksum = "ee4fe5bfacdb0e95e733da3b6c37d98edf46447a4a8e8dea824e0da266d8ad59"
3267+dependencies = [
3268+ "assign",
3269+ "js_int",
3270+ "js_option",
3271+ "ruma-client-api",
3272+ "ruma-common",
3273+ "ruma-events",
3274+ "ruma-html",
3275+ "web-time",
3276+]
3277+
3278+[[package]]
3279+name = "ruma-client-api"
3280+version = "0.24.0"
3281+source = "registry+https://github.com/rust-lang/crates.io-index"
3282+checksum = "bf7ca43a888ca569168d7e3901f4dd14a777b860bb19f4c08e35414162eb261c"
3283+dependencies = [
3284+ "as_variant",
3285+ "assign",
3286+ "bytes",
3287+ "http",
3288+ "js_int",
3289+ "js_option",
3290+ "maplit",
3291+ "ruma-common",
3292+ "ruma-events",
3293+ "serde",
3294+ "serde_html_form",
3295+ "serde_json",
3296+ "thiserror 2.0.21",
3297+ "url",
3298+ "web-time",
3299+]
3300+
3301+[[package]]
3302+name = "ruma-common"
3303+version = "0.19.0"
3304+source = "registry+https://github.com/rust-lang/crates.io-index"
3305+checksum = "2c3b4f00112791b490acce57df1ce3eb3f88899b045bebcff8a29f75369640cc"
3306+dependencies = [
3307+ "as_variant",
3308+ "base64 0.22.1",
3309+ "bytes",
3310+ "date_header",
3311+ "form_urlencoded",
3312+ "getrandom 0.4.3",
3313+ "http",
3314+ "indexmap",
3315+ "js_int",
3316+ "konst",
3317+ "percent-encoding",
3318+ "rand 0.10.3",
3319+ "regex",
3320+ "ruma-identifiers-validation",
3321+ "ruma-macros",
3322+ "serde",
3323+ "serde_html_form",
3324+ "serde_json",
3325+ "thiserror 2.0.21",
3326+ "time",
3327+ "tracing",
3328+ "url",
3329+ "uuid",
3330+ "web-time",
3331+ "wildmatch",
3332+ "zeroize",
3333+]
3334+
3335+[[package]]
3336+name = "ruma-events"
3337+version = "0.34.0"
3338+source = "registry+https://github.com/rust-lang/crates.io-index"
3339+checksum = "85d2f90830fc131691349b96a69ff53444eb6c3e8dc7869c77961b43cfaf3344"
3340+dependencies = [
3341+ "as_variant",
3342+ "indexmap",
3343+ "js_int",
3344+ "js_option",
3345+ "ruma-common",
3346+ "ruma-html",
3347+ "ruma-macros",
3348+ "serde",
3349+ "serde_json",
3350+ "thiserror 2.0.21",
3351+ "tracing",
3352+ "web-time",
3353+ "wildmatch",
3354+ "zeroize",
3355+]
3356+
3357+[[package]]
3358+name = "ruma-html"
3359+version = "0.8.0"
3360+source = "registry+https://github.com/rust-lang/crates.io-index"
3361+checksum = "48d33a944650f4bbd2188dd204d39dd87a9a1498f14b3a13252910c90ed7cd43"
3362+dependencies = [
3363+ "as_variant",
3364+ "html5ever",
3365+ "tracing",
3366+ "wildmatch",
3367+]
3368+
3369+[[package]]
3370+name = "ruma-identifiers-validation"
3371+version = "0.12.1"
3372+source = "registry+https://github.com/rust-lang/crates.io-index"
3373+checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432"
3374+dependencies = [
3375+ "js_int",
3376+ "thiserror 2.0.21",
3377+]
3378+
3379+[[package]]
3380+name = "ruma-macros"
3381+version = "0.19.0"
3382+source = "registry+https://github.com/rust-lang/crates.io-index"
3383+checksum = "c8cfb39eaa9b9fd389126ff941e060b496add5cbbfef559d80c46e571dda459c"
3384+dependencies = [
3385+ "as_variant",
3386+ "cfg-if",
3387+ "proc-macro-crate",
3388+ "proc-macro2",
3389+ "quote",
3390+ "ruma-identifiers-validation",
3391+ "serde",
3392+ "syn 2.0.119",
3393+ "toml",
3394+]
3395+
3396+[[package]]
3397+name = "rusqlite"
3398+version = "0.37.0"
3399+source = "registry+https://github.com/rust-lang/crates.io-index"
3400+checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
3401+dependencies = [
3402+ "bitflags",
3403+ "fallible-iterator",
3404+ "fallible-streaming-iterator",
3405+ "hashlink",
3406+ "libsqlite3-sys",
3407+ "smallvec",
3408+]
3409+
3410+[[package]]
3411+name = "rustc-hash"
3412+version = "2.1.3"
3413+source = "registry+https://github.com/rust-lang/crates.io-index"
3414+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
3415+
3416+[[package]]
3417+name = "rustc_version"
3418+version = "0.4.1"
3419+source = "registry+https://github.com/rust-lang/crates.io-index"
3420+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
3421+dependencies = [
3422+ "semver",
3423+]
3424+
3425+[[package]]
3426+name = "rustix"
3427+version = "1.1.5"
3428+source = "registry+https://github.com/rust-lang/crates.io-index"
3429+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
3430+dependencies = [
3431+ "bitflags",
3432+ "errno",
3433+ "libc",
3434+ "linux-raw-sys",
3435+ "windows-sys 0.61.2",
3436+]
3437+
3438+[[package]]
3439+name = "rustls"
3440+version = "0.23.45"
3441+source = "registry+https://github.com/rust-lang/crates.io-index"
3442+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
3443+dependencies = [
3444+ "aws-lc-rs",
3445+ "log",
3446+ "once_cell",
3447+ "rustls-pki-types",
3448+ "rustls-webpki",
3449+ "subtle",
3450+ "zeroize",
3451+]
3452+
3453+[[package]]
3454+name = "rustls-native-certs"
3455+version = "0.8.4"
3456+source = "registry+https://github.com/rust-lang/crates.io-index"
3457+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
3458+dependencies = [
3459+ "openssl-probe",
3460+ "rustls-pki-types",
3461+ "schannel",
3462+ "security-framework",
3463+]
3464+
3465+[[package]]
3466+name = "rustls-pki-types"
3467+version = "1.15.1"
3468+source = "registry+https://github.com/rust-lang/crates.io-index"
3469+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
3470+dependencies = [
3471+ "web-time",
3472+ "zeroize",
3473+]
3474+
3475+[[package]]
3476+name = "rustls-platform-verifier"
3477+version = "0.7.0"
3478+source = "registry+https://github.com/rust-lang/crates.io-index"
3479+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
3480+dependencies = [
3481+ "core-foundation",
3482+ "core-foundation-sys",
3483+ "jni",
3484+ "log",
3485+ "once_cell",
3486+ "rustls",
3487+ "rustls-native-certs",
3488+ "rustls-platform-verifier-android",
3489+ "rustls-webpki",
3490+ "security-framework",
3491+ "security-framework-sys",
3492+ "webpki-root-certs",
3493+ "windows-sys 0.61.2",
3494+]
3495+
3496+[[package]]
3497+name = "rustls-platform-verifier-android"
3498+version = "0.1.1"
3499+source = "registry+https://github.com/rust-lang/crates.io-index"
3500+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
3501+
3502+[[package]]
3503+name = "rustls-webpki"
3504+version = "0.103.15"
3505+source = "registry+https://github.com/rust-lang/crates.io-index"
3506+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
3507+dependencies = [
3508+ "aws-lc-rs",
3509+ "ring",
3510+ "rustls-pki-types",
3511+ "untrusted",
3512+]
3513+
3514+[[package]]
3515+name = "rustversion"
3516+version = "1.0.23"
3517+source = "registry+https://github.com/rust-lang/crates.io-index"
3518+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
3519+
3520+[[package]]
3521+name = "ryu"
3522+version = "1.0.23"
3523+source = "registry+https://github.com/rust-lang/crates.io-index"
3524+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
3525+
3526+[[package]]
3527+name = "same-file"
3528+version = "1.0.6"
3529+source = "registry+https://github.com/rust-lang/crates.io-index"
3530+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
3531+dependencies = [
3532+ "winapi-util",
3533+]
3534+
3535+[[package]]
3536+name = "schannel"
3537+version = "0.1.29"
3538+source = "registry+https://github.com/rust-lang/crates.io-index"
3539+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
3540+dependencies = [
3541+ "windows-sys 0.61.2",
3542+]
3543+
3544+[[package]]
3545+name = "scoped-tls"
3546+version = "1.0.1"
3547+source = "registry+https://github.com/rust-lang/crates.io-index"
3548+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
3549+
3550+[[package]]
3551+name = "scopeguard"
3552+version = "1.2.0"
3553+source = "registry+https://github.com/rust-lang/crates.io-index"
3554+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
3555+
3556+[[package]]
3557+name = "sealed"
3558+version = "0.6.0"
3559+source = "registry+https://github.com/rust-lang/crates.io-index"
3560+checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
3561+dependencies = [
3562+ "proc-macro2",
3563+ "quote",
3564+ "syn 2.0.119",
3565+]
3566+
3567+[[package]]
3568+name = "sealed"
3569+version = "0.7.0"
3570+source = "registry+https://github.com/rust-lang/crates.io-index"
3571+checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"
3572+dependencies = [
3573+ "proc-macro2",
3574+ "quote",
3575+ "syn 3.0.6",
3576+]
3577+
3578+[[package]]
3579+name = "security-framework"
3580+version = "3.7.0"
3581+source = "registry+https://github.com/rust-lang/crates.io-index"
3582+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
3583+dependencies = [
3584+ "bitflags",
3585+ "core-foundation",
3586+ "core-foundation-sys",
3587+ "libc",
3588+ "security-framework-sys",
3589+]
3590+
3591+[[package]]
3592+name = "security-framework-sys"
3593+version = "2.17.0"
3594+source = "registry+https://github.com/rust-lang/crates.io-index"
3595+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
3596+dependencies = [
3597+ "core-foundation-sys",
3598+ "libc",
3599+]
3600+
3601+[[package]]
3602+name = "semver"
3603+version = "1.0.28"
3604+source = "registry+https://github.com/rust-lang/crates.io-index"
3605+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
3606+
3607+[[package]]
3608+name = "serde"
3609+version = "1.0.229"
3610+source = "registry+https://github.com/rust-lang/crates.io-index"
3611+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
3612+dependencies = [
3613+ "serde_core",
3614+ "serde_derive",
3615+]
3616+
3617+[[package]]
3618+name = "serde-wasm-bindgen"
3619+version = "0.6.5"
3620+source = "registry+https://github.com/rust-lang/crates.io-index"
3621+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
3622+dependencies = [
3623+ "js-sys",
3624+ "serde",
3625+ "wasm-bindgen",
3626+]
3627+
3628+[[package]]
3629+name = "serde_bytes"
3630+version = "0.11.19"
3631+source = "registry+https://github.com/rust-lang/crates.io-index"
3632+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
3633+dependencies = [
3634+ "serde",
3635+ "serde_core",
3636+]
3637+
3638+[[package]]
3639+name = "serde_core"
3640+version = "1.0.229"
3641+source = "registry+https://github.com/rust-lang/crates.io-index"
3642+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
3643+dependencies = [
3644+ "serde_derive",
3645+]
3646+
3647+[[package]]
3648+name = "serde_derive"
3649+version = "1.0.229"
3650+source = "registry+https://github.com/rust-lang/crates.io-index"
3651+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
3652+dependencies = [
3653+ "proc-macro2",
3654+ "quote",
3655+ "syn 3.0.6",
3656+]
3657+
3658+[[package]]
3659+name = "serde_html_form"
3660+version = "0.4.1"
3661+source = "registry+https://github.com/rust-lang/crates.io-index"
3662+checksum = "8f0346d7a342ab90f405cfc08f25d15075f944f42fcabbc5eac923829fa6d228"
3663+dependencies = [
3664+ "form_urlencoded",
3665+ "indexmap",
3666+ "itoa",
3667+ "serde_core",
3668+ "zmij",
3669+]
3670+
3671+[[package]]
3672+name = "serde_json"
3673+version = "1.0.151"
3674+source = "registry+https://github.com/rust-lang/crates.io-index"
3675+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
3676+dependencies = [
3677+ "itoa",
3678+ "memchr",
3679+ "serde",
3680+ "serde_core",
3681+ "zmij",
3682+]
3683+
3684+[[package]]
3685+name = "serde_path_to_error"
3686+version = "0.1.20"
3687+source = "registry+https://github.com/rust-lang/crates.io-index"
3688+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
3689+dependencies = [
3690+ "itoa",
3691+ "serde",
3692+ "serde_core",
3693+]
3694+
3695+[[package]]
3696+name = "serde_spanned"
3697+version = "1.1.1"
3698+source = "registry+https://github.com/rust-lang/crates.io-index"
3699+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
3700+dependencies = [
3701+ "serde_core",
3702+]
3703+
3704+[[package]]
3705+name = "serde_urlencoded"
3706+version = "0.7.1"
3707+source = "registry+https://github.com/rust-lang/crates.io-index"
3708+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
3709+dependencies = [
3710+ "form_urlencoded",
3711+ "itoa",
3712+ "ryu",
3713+ "serde",
3714+]
3715+
3716+[[package]]
3717+name = "sha2"
3718+version = "0.10.9"
3719+source = "registry+https://github.com/rust-lang/crates.io-index"
3720+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
3721+dependencies = [
3722+ "cfg-if",
3723+ "cpufeatures 0.2.17",
3724+ "digest",
3725+]
3726+
3727+[[package]]
3728+name = "sharded-slab"
3729+version = "0.1.7"
3730+source = "registry+https://github.com/rust-lang/crates.io-index"
3731+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
3732+dependencies = [
3733+ "lazy_static",
3734+]
3735+
3736+[[package]]
3737+name = "shlex"
3738+version = "2.0.1"
3739+source = "registry+https://github.com/rust-lang/crates.io-index"
3740+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
3741+
3742+[[package]]
3743+name = "signal-hook-registry"
3744+version = "1.4.8"
3745+source = "registry+https://github.com/rust-lang/crates.io-index"
3746+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
3747+dependencies = [
3748+ "errno",
3749+ "libc",
3750+]
3751+
3752+[[package]]
3753+name = "signature"
3754+version = "2.2.0"
3755+source = "registry+https://github.com/rust-lang/crates.io-index"
3756+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
3757+dependencies = [
3758+ "rand_core 0.6.4",
3759+]
3760+
3761+[[package]]
3762+name = "simd-adler32"
3763+version = "0.3.10"
3764+source = "registry+https://github.com/rust-lang/crates.io-index"
3765+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
3766+
3767+[[package]]
3768+name = "simd_cesu8"
3769+version = "1.2.0"
3770+source = "registry+https://github.com/rust-lang/crates.io-index"
3771+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
3772+dependencies = [
3773+ "rustc_version",
3774+ "simdutf8",
3775+]
3776+
3777+[[package]]
3778+name = "simdutf8"
3779+version = "0.1.5"
3780+source = "registry+https://github.com/rust-lang/crates.io-index"
3781+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
3782+
3783+[[package]]
3784+name = "similar"
3785+version = "2.7.0"
3786+source = "registry+https://github.com/rust-lang/crates.io-index"
3787+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
3788+
3789+[[package]]
3790+name = "siphasher"
3791+version = "1.0.3"
3792+source = "registry+https://github.com/rust-lang/crates.io-index"
3793+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
3794+
3795+[[package]]
3796+name = "slab"
3797+version = "0.4.12"
3798+source = "registry+https://github.com/rust-lang/crates.io-index"
3799+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
3800+
3801+[[package]]
3802+name = "smallvec"
3803+version = "1.16.1"
3804+source = "registry+https://github.com/rust-lang/crates.io-index"
3805+checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
3806+dependencies = [
3807+ "serde",
3808+]
3809+
3810+[[package]]
3811+name = "socket2"
3812+version = "0.6.5"
3813+source = "registry+https://github.com/rust-lang/crates.io-index"
3814+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
3815+dependencies = [
3816+ "libc",
3817+ "windows-sys 0.61.2",
3818+]
3819+
3820+[[package]]
3821+name = "spin"
3822+version = "0.9.9"
3823+source = "registry+https://github.com/rust-lang/crates.io-index"
3824+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
3825+
3826+[[package]]
3827+name = "spki"
3828+version = "0.7.3"
3829+source = "registry+https://github.com/rust-lang/crates.io-index"
3830+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
3831+dependencies = [
3832+ "base64ct",
3833+ "der",
3834+]
3835+
3836+[[package]]
3837+name = "stable_deref_trait"
3838+version = "1.2.1"
3839+source = "registry+https://github.com/rust-lang/crates.io-index"
3840+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
3841+
3842+[[package]]
3843+name = "string_cache"
3844+version = "0.9.0"
3845+source = "registry+https://github.com/rust-lang/crates.io-index"
3846+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
3847+dependencies = [
3848+ "new_debug_unreachable",
3849+ "parking_lot",
3850+ "phf_shared",
3851+ "precomputed-hash",
3852+]
3853+
3854+[[package]]
3855+name = "string_cache_codegen"
3856+version = "0.6.1"
3857+source = "registry+https://github.com/rust-lang/crates.io-index"
3858+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
3859+dependencies = [
3860+ "phf_generator",
3861+ "phf_shared",
3862+ "proc-macro2",
3863+ "quote",
3864+]
3865+
3866+[[package]]
3867+name = "subtle"
3868+version = "2.6.1"
3869+source = "registry+https://github.com/rust-lang/crates.io-index"
3870+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
3871+
3872+[[package]]
3873+name = "syn"
3874+version = "1.0.109"
3875+source = "registry+https://github.com/rust-lang/crates.io-index"
3876+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
3877+dependencies = [
3878+ "proc-macro2",
3879+ "quote",
3880+ "unicode-ident",
3881+]
3882+
3883+[[package]]
3884+name = "syn"
3885+version = "2.0.119"
3886+source = "registry+https://github.com/rust-lang/crates.io-index"
3887+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
3888+dependencies = [
3889+ "proc-macro2",
3890+ "quote",
3891+ "unicode-ident",
3892+]
3893+
3894+[[package]]
3895+name = "syn"
3896+version = "3.0.6"
3897+source = "registry+https://github.com/rust-lang/crates.io-index"
3898+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
3899+dependencies = [
3900+ "proc-macro2",
3901+ "quote",
3902+ "unicode-ident",
3903+]
3904+
3905+[[package]]
3906+name = "sync_wrapper"
3907+version = "1.0.2"
3908+source = "registry+https://github.com/rust-lang/crates.io-index"
3909+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
3910+dependencies = [
3911+ "futures-core",
3912+]
3913+
3914+[[package]]
3915+name = "synstructure"
3916+version = "0.14.0"
3917+source = "registry+https://github.com/rust-lang/crates.io-index"
3918+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
3919+dependencies = [
3920+ "proc-macro2",
3921+ "quote",
3922+ "syn 3.0.6",
3923+]
3924+
3925+[[package]]
3926+name = "tempfile"
3927+version = "3.27.0"
3928+source = "registry+https://github.com/rust-lang/crates.io-index"
3929+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
3930+dependencies = [
3931+ "fastrand",
3932+ "getrandom 0.4.3",
3933+ "once_cell",
3934+ "rustix",
3935+ "windows-sys 0.61.2",
3936+]
3937+
3938+[[package]]
3939+name = "tendril"
3940+version = "0.5.1"
3941+source = "registry+https://github.com/rust-lang/crates.io-index"
3942+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
3943+dependencies = [
3944+ "new_debug_unreachable",
3945+]
3946+
3947+[[package]]
3948+name = "thiserror"
3949+version = "1.0.69"
3950+source = "registry+https://github.com/rust-lang/crates.io-index"
3951+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
3952+dependencies = [
3953+ "thiserror-impl 1.0.69",
3954+]
3955+
3956+[[package]]
3957+name = "thiserror"
3958+version = "2.0.21"
3959+source = "registry+https://github.com/rust-lang/crates.io-index"
3960+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
3961+dependencies = [
3962+ "thiserror-impl 2.0.21",
3963+]
3964+
3965+[[package]]
3966+name = "thiserror-impl"
3967+version = "1.0.69"
3968+source = "registry+https://github.com/rust-lang/crates.io-index"
3969+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
3970+dependencies = [
3971+ "proc-macro2",
3972+ "quote",
3973+ "syn 2.0.119",
3974+]
3975+
3976+[[package]]
3977+name = "thiserror-impl"
3978+version = "2.0.21"
3979+source = "registry+https://github.com/rust-lang/crates.io-index"
3980+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
3981+dependencies = [
3982+ "proc-macro2",
3983+ "quote",
3984+ "syn 3.0.6",
3985+]
3986+
3987+[[package]]
3988+name = "thread_local"
3989+version = "1.1.10"
3990+source = "registry+https://github.com/rust-lang/crates.io-index"
3991+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
3992+dependencies = [
3993+ "cfg-if",
3994+]
3995+
3996+[[package]]
3997+name = "time"
3998+version = "0.3.55"
3999+source = "registry+https://github.com/rust-lang/crates.io-index"
4000+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
4001+dependencies = [
4002+ "deranged",
4003+ "num-conv",
4004+ "powerfmt",
4005+ "serde_core",
4006+ "time-core",
4007+ "time-macros",
4008+]
4009+
4010+[[package]]
4011+name = "time-core"
4012+version = "0.1.9"
4013+source = "registry+https://github.com/rust-lang/crates.io-index"
4014+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
4015+
4016+[[package]]
4017+name = "time-macros"
4018+version = "0.2.32"
4019+source = "registry+https://github.com/rust-lang/crates.io-index"
4020+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
4021+dependencies = [
4022+ "num-conv",
4023+ "time-core",
4024+]
4025+
4026+[[package]]
4027+name = "tinystr"
4028+version = "0.8.4"
4029+source = "registry+https://github.com/rust-lang/crates.io-index"
4030+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
4031+dependencies = [
4032+ "displaydoc",
4033+ "zerovec",
4034+]
4035+
4036+[[package]]
4037+name = "tinyvec"
4038+version = "1.13.3"
4039+source = "registry+https://github.com/rust-lang/crates.io-index"
4040+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
4041+
4042+[[package]]
4043+name = "tokio"
4044+version = "1.53.1"
4045+source = "registry+https://github.com/rust-lang/crates.io-index"
4046+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
4047+dependencies = [
4048+ "bytes",
4049+ "libc",
4050+ "mio",
4051+ "parking_lot",
4052+ "pin-project-lite",
4053+ "signal-hook-registry",
4054+ "socket2",
4055+ "tokio-macros",
4056+ "windows-sys 0.61.2",
4057+]
4058+
4059+[[package]]
4060+name = "tokio-macros"
4061+version = "2.7.2"
4062+source = "registry+https://github.com/rust-lang/crates.io-index"
4063+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
4064+dependencies = [
4065+ "proc-macro2",
4066+ "quote",
4067+ "syn 3.0.6",
4068+]
4069+
4070+[[package]]
4071+name = "tokio-rustls"
4072+version = "0.26.5"
4073+source = "registry+https://github.com/rust-lang/crates.io-index"
4074+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
4075+dependencies = [
4076+ "rustls",
4077+ "tokio",
4078+]
4079+
4080+[[package]]
4081+name = "tokio-stream"
4082+version = "0.1.19"
4083+source = "registry+https://github.com/rust-lang/crates.io-index"
4084+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
4085+dependencies = [
4086+ "futures-core",
4087+ "pin-project-lite",
4088+ "tokio",
4089+ "tokio-util",
4090+]
4091+
4092+[[package]]
4093+name = "tokio-util"
4094+version = "0.7.19"
4095+source = "registry+https://github.com/rust-lang/crates.io-index"
4096+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
4097+dependencies = [
4098+ "bytes",
4099+ "futures-core",
4100+ "futures-sink",
4101+ "libc",
4102+ "pin-project-lite",
4103+ "tokio",
4104+]
4105+
4106+[[package]]
4107+name = "toml"
4108+version = "1.1.6+spec-1.1.0"
4109+source = "registry+https://github.com/rust-lang/crates.io-index"
4110+checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
4111+dependencies = [
4112+ "serde_core",
4113+ "serde_spanned",
4114+ "toml_datetime",
4115+ "toml_parser",
4116+ "winnow",
4117+]
4118+
4119+[[package]]
4120+name = "toml_datetime"
4121+version = "1.1.1+spec-1.1.0"
4122+source = "registry+https://github.com/rust-lang/crates.io-index"
4123+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
4124+dependencies = [
4125+ "serde_core",
4126+]
4127+
4128+[[package]]
4129+name = "toml_edit"
4130+version = "0.25.15+spec-1.1.0"
4131+source = "registry+https://github.com/rust-lang/crates.io-index"
4132+checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
4133+dependencies = [
4134+ "indexmap",
4135+ "toml_datetime",
4136+ "toml_parser",
4137+ "winnow",
4138+]
4139+
4140+[[package]]
4141+name = "toml_parser"
4142+version = "1.1.3+spec-1.1.0"
4143+source = "registry+https://github.com/rust-lang/crates.io-index"
4144+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
4145+dependencies = [
4146+ "winnow",
4147+]
4148+
4149+[[package]]
4150+name = "tower"
4151+version = "0.5.3"
4152+source = "registry+https://github.com/rust-lang/crates.io-index"
4153+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
4154+dependencies = [
4155+ "futures-core",
4156+ "futures-util",
4157+ "pin-project-lite",
4158+ "sync_wrapper",
4159+ "tokio",
4160+ "tower-layer",
4161+ "tower-service",
4162+ "tracing",
4163+]
4164+
4165+[[package]]
4166+name = "tower-http"
4167+version = "0.6.11"
4168+source = "registry+https://github.com/rust-lang/crates.io-index"
4169+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
4170+dependencies = [
4171+ "async-compression",
4172+ "bitflags",
4173+ "bytes",
4174+ "futures-core",
4175+ "futures-util",
4176+ "http",
4177+ "http-body",
4178+ "http-body-util",
4179+ "http-range-header",
4180+ "httpdate",
4181+ "mime",
4182+ "mime_guess",
4183+ "percent-encoding",
4184+ "pin-project-lite",
4185+ "tokio",
4186+ "tokio-util",
4187+ "tower",
4188+ "tower-layer",
4189+ "tower-service",
4190+ "tracing",
4191+ "url",
4192+]
4193+
4194+[[package]]
4195+name = "tower-layer"
4196+version = "0.3.3"
4197+source = "registry+https://github.com/rust-lang/crates.io-index"
4198+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
4199+
4200+[[package]]
4201+name = "tower-service"
4202+version = "0.3.3"
4203+source = "registry+https://github.com/rust-lang/crates.io-index"
4204+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
4205+
4206+[[package]]
4207+name = "tracing"
4208+version = "0.1.44"
4209+source = "registry+https://github.com/rust-lang/crates.io-index"
4210+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
4211+dependencies = [
4212+ "log",
4213+ "pin-project-lite",
4214+ "tracing-attributes",
4215+ "tracing-core",
4216+]
4217+
4218+[[package]]
4219+name = "tracing-attributes"
4220+version = "0.1.31"
4221+source = "registry+https://github.com/rust-lang/crates.io-index"
4222+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
4223+dependencies = [
4224+ "proc-macro2",
4225+ "quote",
4226+ "syn 2.0.119",
4227+]
4228+
4229+[[package]]
4230+name = "tracing-core"
4231+version = "0.1.36"
4232+source = "registry+https://github.com/rust-lang/crates.io-index"
4233+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
4234+dependencies = [
4235+ "once_cell",
4236+ "valuable",
4237+]
4238+
4239+[[package]]
4240+name = "tracing-log"
4241+version = "0.2.0"
4242+source = "registry+https://github.com/rust-lang/crates.io-index"
4243+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
4244+dependencies = [
4245+ "log",
4246+ "once_cell",
4247+ "tracing-core",
4248+]
4249+
4250+[[package]]
4251+name = "tracing-subscriber"
4252+version = "0.3.23"
4253+source = "registry+https://github.com/rust-lang/crates.io-index"
4254+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
4255+dependencies = [
4256+ "matchers",
4257+ "nu-ansi-term",
4258+ "once_cell",
4259+ "regex-automata",
4260+ "sharded-slab",
4261+ "smallvec",
4262+ "thread_local",
4263+ "tracing",
4264+ "tracing-core",
4265+ "tracing-log",
4266+]
4267+
4268+[[package]]
4269+name = "try-lock"
4270+version = "0.2.5"
4271+source = "registry+https://github.com/rust-lang/crates.io-index"
4272+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
4273+
4274+[[package]]
4275+name = "typenum"
4276+version = "1.20.1"
4277+source = "registry+https://github.com/rust-lang/crates.io-index"
4278+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
4279+
4280+[[package]]
4281+name = "typewit"
4282+version = "1.15.2"
4283+source = "registry+https://github.com/rust-lang/crates.io-index"
4284+checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6"
4285+
4286+[[package]]
4287+name = "ucd-trie"
4288+version = "0.1.7"
4289+source = "registry+https://github.com/rust-lang/crates.io-index"
4290+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
4291+
4292+[[package]]
4293+name = "ulid"
4294+version = "1.2.1"
4295+source = "registry+https://github.com/rust-lang/crates.io-index"
4296+checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
4297+dependencies = [
4298+ "rand 0.9.5",
4299+ "web-time",
4300+]
4301+
4302+[[package]]
4303+name = "unicase"
4304+version = "2.9.0"
4305+source = "registry+https://github.com/rust-lang/crates.io-index"
4306+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
4307+
4308+[[package]]
4309+name = "unicode-ident"
4310+version = "1.0.26"
4311+source = "registry+https://github.com/rust-lang/crates.io-index"
4312+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
4313+
4314+[[package]]
4315+name = "unicode-normalization"
4316+version = "0.1.25"
4317+source = "registry+https://github.com/rust-lang/crates.io-index"
4318+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
4319+dependencies = [
4320+ "tinyvec",
4321+]
4322+
4323+[[package]]
4324+name = "unicode-segmentation"
4325+version = "1.13.3"
4326+source = "registry+https://github.com/rust-lang/crates.io-index"
4327+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
4328+
4329+[[package]]
4330+name = "unicode-xid"
4331+version = "0.2.6"
4332+source = "registry+https://github.com/rust-lang/crates.io-index"
4333+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
4334+
4335+[[package]]
4336+name = "universal-hash"
4337+version = "0.5.1"
4338+source = "registry+https://github.com/rust-lang/crates.io-index"
4339+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
4340+dependencies = [
4341+ "crypto-common",
4342+ "subtle",
4343+]
4344+
4345+[[package]]
4346+name = "untrusted"
4347+version = "0.9.0"
4348+source = "registry+https://github.com/rust-lang/crates.io-index"
4349+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
4350+
4351+[[package]]
4352+name = "url"
4353+version = "2.5.8"
4354+source = "registry+https://github.com/rust-lang/crates.io-index"
4355+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
4356+dependencies = [
4357+ "form_urlencoded",
4358+ "idna",
4359+ "percent-encoding",
4360+ "serde",
4361+ "serde_derive",
4362+]
4363+
4364+[[package]]
4365+name = "urlencoding"
4366+version = "2.1.3"
4367+source = "registry+https://github.com/rust-lang/crates.io-index"
4368+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
4369+
4370+[[package]]
4371+name = "utf8_iter"
4372+version = "1.0.4"
4373+source = "registry+https://github.com/rust-lang/crates.io-index"
4374+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
4375+
4376+[[package]]
4377+name = "uuid"
4378+version = "1.26.1"
4379+source = "registry+https://github.com/rust-lang/crates.io-index"
4380+checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
4381+dependencies = [
4382+ "getrandom 0.4.3",
4383+ "js-sys",
4384+ "serde_core",
4385+ "wasm-bindgen",
4386+]
4387+
4388+[[package]]
4389+name = "valuable"
4390+version = "0.1.1"
4391+source = "registry+https://github.com/rust-lang/crates.io-index"
4392+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
4393+
4394+[[package]]
4395+name = "vcpkg"
4396+version = "0.2.15"
4397+source = "registry+https://github.com/rust-lang/crates.io-index"
4398+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
4399+
4400+[[package]]
4401+name = "version_check"
4402+version = "0.9.5"
4403+source = "registry+https://github.com/rust-lang/crates.io-index"
4404+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
4405+
4406+[[package]]
4407+name = "vodozemac"
4408+version = "0.10.0"
4409+source = "registry+https://github.com/rust-lang/crates.io-index"
4410+checksum = "b98bf83c0992966775b8012f194b07b44928996163e5a05b741b43891571ae5b"
4411+dependencies = [
4412+ "aes",
4413+ "arrayvec",
4414+ "base64 0.22.1",
4415+ "base64ct",
4416+ "cbc",
4417+ "chacha20poly1305",
4418+ "curve25519-dalek",
4419+ "ed25519-dalek",
4420+ "getrandom 0.2.17",
4421+ "hkdf",
4422+ "hmac",
4423+ "matrix-pickle",
4424+ "prost",
4425+ "rand 0.8.8",
4426+ "serde",
4427+ "serde_bytes",
4428+ "serde_json",
4429+ "sha2",
4430+ "subtle",
4431+ "thiserror 2.0.21",
4432+ "x25519-dalek",
4433+ "zeroize",
4434+]
4435+
4436+[[package]]
4437+name = "walkdir"
4438+version = "2.5.0"
4439+source = "registry+https://github.com/rust-lang/crates.io-index"
4440+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
4441+dependencies = [
4442+ "same-file",
4443+ "winapi-util",
4444+]
4445+
4446+[[package]]
4447+name = "want"
4448+version = "0.3.1"
4449+source = "registry+https://github.com/rust-lang/crates.io-index"
4450+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
4451+dependencies = [
4452+ "try-lock",
4453+]
4454+
4455+[[package]]
4456+name = "wasi"
4457+version = "0.11.1+wasi-snapshot-preview1"
4458+source = "registry+https://github.com/rust-lang/crates.io-index"
4459+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
4460+
4461+[[package]]
4462+name = "wasip2"
4463+version = "1.0.4+wasi-0.2.12"
4464+source = "registry+https://github.com/rust-lang/crates.io-index"
4465+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
4466+dependencies = [
4467+ "wit-bindgen",
4468+]
4469+
4470+[[package]]
4471+name = "wasm-bindgen"
4472+version = "0.2.128"
4473+source = "registry+https://github.com/rust-lang/crates.io-index"
4474+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
4475+dependencies = [
4476+ "cfg-if",
4477+ "once_cell",
4478+ "rustversion",
4479+ "wasm-bindgen-macro",
4480+ "wasm-bindgen-shared",
4481+]
4482+
4483+[[package]]
4484+name = "wasm-bindgen-futures"
4485+version = "0.4.78"
4486+source = "registry+https://github.com/rust-lang/crates.io-index"
4487+checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
4488+dependencies = [
4489+ "js-sys",
4490+ "wasm-bindgen",
4491+]
4492+
4493+[[package]]
4494+name = "wasm-bindgen-macro"
4495+version = "0.2.128"
4496+source = "registry+https://github.com/rust-lang/crates.io-index"
4497+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
4498+dependencies = [
4499+ "quote",
4500+ "wasm-bindgen-macro-support",
4501+]
4502+
4503+[[package]]
4504+name = "wasm-bindgen-macro-support"
4505+version = "0.2.128"
4506+source = "registry+https://github.com/rust-lang/crates.io-index"
4507+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
4508+dependencies = [
4509+ "bumpalo",
4510+ "proc-macro2",
4511+ "quote",
4512+ "syn 3.0.6",
4513+ "wasm-bindgen-shared",
4514+]
4515+
4516+[[package]]
4517+name = "wasm-bindgen-shared"
4518+version = "0.2.128"
4519+source = "registry+https://github.com/rust-lang/crates.io-index"
4520+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
4521+dependencies = [
4522+ "unicode-ident",
4523+]
4524+
4525+[[package]]
4526+name = "wasm-bindgen-test"
4527+version = "0.3.78"
4528+source = "registry+https://github.com/rust-lang/crates.io-index"
4529+checksum = "45863ef0bef521c12124eb39d9a38513c47db75f22e503c06beacd40afeb35db"
4530+dependencies = [
4531+ "async-trait",
4532+ "cast",
4533+ "js-sys",
4534+ "libm",
4535+ "minicov",
4536+ "nu-ansi-term",
4537+ "num-traits",
4538+ "oorandom",
4539+ "serde",
4540+ "serde_json",
4541+ "wasm-bindgen",
4542+ "wasm-bindgen-futures",
4543+ "wasm-bindgen-test-macro",
4544+ "wasm-bindgen-test-shared",
4545+]
4546+
4547+[[package]]
4548+name = "wasm-bindgen-test-macro"
4549+version = "0.3.78"
4550+source = "registry+https://github.com/rust-lang/crates.io-index"
4551+checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12"
4552+dependencies = [
4553+ "proc-macro2",
4554+ "quote",
4555+ "syn 3.0.6",
4556+]
4557+
4558+[[package]]
4559+name = "wasm-bindgen-test-shared"
4560+version = "0.2.128"
4561+source = "registry+https://github.com/rust-lang/crates.io-index"
4562+checksum = "f37b4f992cebe528ef34964ae69681ac0fe7080071e7298e46008f9d380302af"
4563+
4564+[[package]]
4565+name = "wasm-streams"
4566+version = "0.5.0"
4567+source = "registry+https://github.com/rust-lang/crates.io-index"
4568+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
4569+dependencies = [
4570+ "futures-util",
4571+ "js-sys",
4572+ "wasm-bindgen",
4573+ "wasm-bindgen-futures",
4574+ "web-sys",
4575+]
4576+
4577+[[package]]
4578+name = "wasm_evt_listener"
4579+version = "0.1.0"
4580+source = "registry+https://github.com/rust-lang/crates.io-index"
4581+checksum = "dc92d6378b411ed94839112a36d9dbc77143451d85b05dfb0cce93a78dab1963"
4582+dependencies = [
4583+ "accessory",
4584+ "derivative",
4585+ "derive_more 1.0.0",
4586+ "fancy_constructor",
4587+ "futures-core",
4588+ "js-sys",
4589+ "smallvec",
4590+ "tokio",
4591+ "wasm-bindgen",
4592+ "web-sys",
4593+]
4594+
4595+[[package]]
4596+name = "web-sys"
4597+version = "0.3.105"
4598+source = "registry+https://github.com/rust-lang/crates.io-index"
4599+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
4600+dependencies = [
4601+ "js-sys",
4602+ "wasm-bindgen",
4603+]
4604+
4605+[[package]]
4606+name = "web-time"
4607+version = "1.1.0"
4608+source = "registry+https://github.com/rust-lang/crates.io-index"
4609+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
4610+dependencies = [
4611+ "js-sys",
4612+ "serde",
4613+ "wasm-bindgen",
4614+]
4615+
4616+[[package]]
4617+name = "web_atoms"
4618+version = "0.2.6"
4619+source = "registry+https://github.com/rust-lang/crates.io-index"
4620+checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
4621+dependencies = [
4622+ "phf",
4623+ "phf_codegen",
4624+ "string_cache",
4625+ "string_cache_codegen",
4626+]
4627+
4628+[[package]]
4629+name = "webpki-root-certs"
4630+version = "1.0.9"
4631+source = "registry+https://github.com/rust-lang/crates.io-index"
4632+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
4633+dependencies = [
4634+ "rustls-pki-types",
4635+]
4636+
4637+[[package]]
4638+name = "webpki-roots"
4639+version = "1.0.9"
4640+source = "registry+https://github.com/rust-lang/crates.io-index"
4641+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
4642+dependencies = [
4643+ "rustls-pki-types",
4644+]
4645+
4646+[[package]]
4647+name = "wildmatch"
4648+version = "2.6.1"
4649+source = "registry+https://github.com/rust-lang/crates.io-index"
4650+checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68"
4651+
4652+[[package]]
4653+name = "winapi"
4654+version = "0.3.9"
4655+source = "registry+https://github.com/rust-lang/crates.io-index"
4656+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
4657+dependencies = [
4658+ "winapi-i686-pc-windows-gnu",
4659+ "winapi-x86_64-pc-windows-gnu",
4660+]
4661+
4662+[[package]]
4663+name = "winapi-i686-pc-windows-gnu"
4664+version = "0.4.0"
4665+source = "registry+https://github.com/rust-lang/crates.io-index"
4666+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
4667+
4668+[[package]]
4669+name = "winapi-util"
4670+version = "0.1.11"
4671+source = "registry+https://github.com/rust-lang/crates.io-index"
4672+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
4673+dependencies = [
4674+ "windows-sys 0.61.2",
4675+]
4676+
4677+[[package]]
4678+name = "winapi-x86_64-pc-windows-gnu"
4679+version = "0.4.0"
4680+source = "registry+https://github.com/rust-lang/crates.io-index"
4681+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
4682+
4683+[[package]]
4684+name = "windows-core"
4685+version = "0.62.2"
4686+source = "registry+https://github.com/rust-lang/crates.io-index"
4687+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
4688+dependencies = [
4689+ "windows-implement",
4690+ "windows-interface",
4691+ "windows-link",
4692+ "windows-result",
4693+ "windows-strings",
4694+]
4695+
4696+[[package]]
4697+name = "windows-implement"
4698+version = "0.60.2"
4699+source = "registry+https://github.com/rust-lang/crates.io-index"
4700+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
4701+dependencies = [
4702+ "proc-macro2",
4703+ "quote",
4704+ "syn 2.0.119",
4705+]
4706+
4707+[[package]]
4708+name = "windows-interface"
4709+version = "0.59.3"
4710+source = "registry+https://github.com/rust-lang/crates.io-index"
4711+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
4712+dependencies = [
4713+ "proc-macro2",
4714+ "quote",
4715+ "syn 2.0.119",
4716+]
4717+
4718+[[package]]
4719+name = "windows-link"
4720+version = "0.2.1"
4721+source = "registry+https://github.com/rust-lang/crates.io-index"
4722+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
4723+
4724+[[package]]
4725+name = "windows-result"
4726+version = "0.4.1"
4727+source = "registry+https://github.com/rust-lang/crates.io-index"
4728+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
4729+dependencies = [
4730+ "windows-link",
4731+]
4732+
4733+[[package]]
4734+name = "windows-strings"
4735+version = "0.5.1"
4736+source = "registry+https://github.com/rust-lang/crates.io-index"
4737+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
4738+dependencies = [
4739+ "windows-link",
4740+]
4741+
4742+[[package]]
4743+name = "windows-sys"
4744+version = "0.52.0"
4745+source = "registry+https://github.com/rust-lang/crates.io-index"
4746+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
4747+dependencies = [
4748+ "windows-targets",
4749+]
4750+
4751+[[package]]
4752+name = "windows-sys"
4753+version = "0.61.2"
4754+source = "registry+https://github.com/rust-lang/crates.io-index"
4755+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
4756+dependencies = [
4757+ "windows-link",
4758+]
4759+
4760+[[package]]
4761+name = "windows-targets"
4762+version = "0.52.6"
4763+source = "registry+https://github.com/rust-lang/crates.io-index"
4764+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
4765+dependencies = [
4766+ "windows_aarch64_gnullvm",
4767+ "windows_aarch64_msvc",
4768+ "windows_i686_gnu",
4769+ "windows_i686_gnullvm",
4770+ "windows_i686_msvc",
4771+ "windows_x86_64_gnu",
4772+ "windows_x86_64_gnullvm",
4773+ "windows_x86_64_msvc",
4774+]
4775+
4776+[[package]]
4777+name = "windows_aarch64_gnullvm"
4778+version = "0.52.6"
4779+source = "registry+https://github.com/rust-lang/crates.io-index"
4780+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
4781+
4782+[[package]]
4783+name = "windows_aarch64_msvc"
4784+version = "0.52.6"
4785+source = "registry+https://github.com/rust-lang/crates.io-index"
4786+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
4787+
4788+[[package]]
4789+name = "windows_i686_gnu"
4790+version = "0.52.6"
4791+source = "registry+https://github.com/rust-lang/crates.io-index"
4792+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
4793+
4794+[[package]]
4795+name = "windows_i686_gnullvm"
4796+version = "0.52.6"
4797+source = "registry+https://github.com/rust-lang/crates.io-index"
4798+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
4799+
4800+[[package]]
4801+name = "windows_i686_msvc"
4802+version = "0.52.6"
4803+source = "registry+https://github.com/rust-lang/crates.io-index"
4804+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
4805+
4806+[[package]]
4807+name = "windows_x86_64_gnu"
4808+version = "0.52.6"
4809+source = "registry+https://github.com/rust-lang/crates.io-index"
4810+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
4811+
4812+[[package]]
4813+name = "windows_x86_64_gnullvm"
4814+version = "0.52.6"
4815+source = "registry+https://github.com/rust-lang/crates.io-index"
4816+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
4817+
4818+[[package]]
4819+name = "windows_x86_64_msvc"
4820+version = "0.52.6"
4821+source = "registry+https://github.com/rust-lang/crates.io-index"
4822+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
4823+
4824+[[package]]
4825+name = "winnow"
4826+version = "1.0.4"
4827+source = "registry+https://github.com/rust-lang/crates.io-index"
4828+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
4829+dependencies = [
4830+ "memchr",
4831+]
4832+
4833+[[package]]
4834+name = "wiremock"
4835+version = "0.6.5"
4836+source = "registry+https://github.com/rust-lang/crates.io-index"
4837+checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
4838+dependencies = [
4839+ "assert-json-diff",
4840+ "base64 0.22.1",
4841+ "deadpool 0.12.3",
4842+ "futures",
4843+ "http",
4844+ "http-body-util",
4845+ "hyper",
4846+ "hyper-util",
4847+ "log",
4848+ "once_cell",
4849+ "regex",
4850+ "serde",
4851+ "serde_json",
4852+ "tokio",
4853+ "url",
4854+]
4855+
4856+[[package]]
4857+name = "wit-bindgen"
4858+version = "0.57.1"
4859+source = "registry+https://github.com/rust-lang/crates.io-index"
4860+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
4861+
4862+[[package]]
4863+name = "writeable"
4864+version = "0.6.4"
4865+source = "registry+https://github.com/rust-lang/crates.io-index"
4866+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
4867+
4868+[[package]]
4869+name = "x25519-dalek"
4870+version = "2.0.1"
4871+source = "registry+https://github.com/rust-lang/crates.io-index"
4872+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
4873+dependencies = [
4874+ "curve25519-dalek",
4875+ "rand_core 0.6.4",
4876+ "serde",
4877+ "zeroize",
4878+]
4879+
4880+[[package]]
4881+name = "xxhash-rust"
4882+version = "0.8.18"
4883+source = "registry+https://github.com/rust-lang/crates.io-index"
4884+checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
4885+
4886+[[package]]
4887+name = "yoke"
4888+version = "0.8.3"
4889+source = "registry+https://github.com/rust-lang/crates.io-index"
4890+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
4891+dependencies = [
4892+ "stable_deref_trait",
4893+ "yoke-derive",
4894+ "zerofrom",
4895+]
4896+
4897+[[package]]
4898+name = "yoke-derive"
4899+version = "0.8.3"
4900+source = "registry+https://github.com/rust-lang/crates.io-index"
4901+checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
4902+dependencies = [
4903+ "proc-macro2",
4904+ "quote",
4905+ "syn 3.0.6",
4906+ "synstructure",
4907+]
4908+
4909+[[package]]
4910+name = "zerocopy"
4911+version = "0.8.57"
4912+source = "registry+https://github.com/rust-lang/crates.io-index"
4913+checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
4914+dependencies = [
4915+ "zerocopy-derive",
4916+]
4917+
4918+[[package]]
4919+name = "zerocopy-derive"
4920+version = "0.8.57"
4921+source = "registry+https://github.com/rust-lang/crates.io-index"
4922+checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
4923+dependencies = [
4924+ "proc-macro2",
4925+ "quote",
4926+ "syn 2.0.119",
4927+]
4928+
4929+[[package]]
4930+name = "zerofrom"
4931+version = "0.1.8"
4932+source = "registry+https://github.com/rust-lang/crates.io-index"
4933+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
4934+dependencies = [
4935+ "zerofrom-derive",
4936+]
4937+
4938+[[package]]
4939+name = "zerofrom-derive"
4940+version = "0.1.8"
4941+source = "registry+https://github.com/rust-lang/crates.io-index"
4942+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
4943+dependencies = [
4944+ "proc-macro2",
4945+ "quote",
4946+ "syn 3.0.6",
4947+ "synstructure",
4948+]
4949+
4950+[[package]]
4951+name = "zeroize"
4952+version = "1.9.0"
4953+source = "registry+https://github.com/rust-lang/crates.io-index"
4954+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
4955+dependencies = [
4956+ "zeroize_derive",
4957+]
4958+
4959+[[package]]
4960+name = "zeroize_derive"
4961+version = "1.5.0"
4962+source = "registry+https://github.com/rust-lang/crates.io-index"
4963+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
4964+dependencies = [
4965+ "proc-macro2",
4966+ "quote",
4967+ "syn 2.0.119",
4968+]
4969+
4970+[[package]]
4971+name = "zerotrie"
4972+version = "0.2.5"
4973+source = "registry+https://github.com/rust-lang/crates.io-index"
4974+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
4975+dependencies = [
4976+ "displaydoc",
4977+ "yoke",
4978+ "zerofrom",
4979+]
4980+
4981+[[package]]
4982+name = "zerovec"
4983+version = "0.11.8"
4984+source = "registry+https://github.com/rust-lang/crates.io-index"
4985+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
4986+dependencies = [
4987+ "yoke",
4988+ "zerofrom",
4989+ "zerovec-derive",
4990+]
4991+
4992+[[package]]
4993+name = "zerovec-derive"
4994+version = "0.11.6"
4995+source = "registry+https://github.com/rust-lang/crates.io-index"
4996+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
4997+dependencies = [
4998+ "proc-macro2",
4999+ "quote",
5000+ "syn 3.0.6",
5001+]
5002+
5003+[[package]]
5004+name = "zlib-rs"
5005+version = "0.6.8"
5006+source = "registry+https://github.com/rust-lang/crates.io-index"
5007+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
5008+
5009+[[package]]
5010+name = "zmij"
5011+version = "1.0.23"
5012+source = "registry+https://github.com/rust-lang/crates.io-index"
5013+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Added file: Cargo.toml+15 additions
@@ -0,0 +1,15 @@
1+[workspace]
2+resolver = "3"
3+members = ["crates/malice-core", "crates/malice-server"]
4+
5+[workspace.dependencies]
6+macltok = { path = "vendor/macltok" }
7+ruma = { version = "0.16", default-features = false }
8+serde = { version = "1", features = ["derive"] }
9+serde_json = "1"
10+tokio = { version = "1" }
11+tracing = "0.1"
12+http = "1"
13+
14+[workspace.lints.rust]
15+unsafe_code = "forbid"
Added file: MACLTOK_MEDIA_STREAM.md+77 additions
@@ -0,0 +1,77 @@
1+# macltok request, what a host needs to stream large media itself
2+
3+## Problem
4+
5+`ClientHandle::media(&MediaId, MediaSize)` answers with `Media { bytes: Arc<[u8]>, .. }`, the whole
6+file, decrypted and hash-checked, capped by `EngineConfig::max_media_bytes`. That is right for
7+avatars, thumbnails and images. It is wrong for a 20 minute video.
8+
9+- In a browser the whole file sits in wasm linear memory, which never shrinks and tops out at 4 GB
10+ on wasm32. A few long videos and the worker is carrying hundreds of MB it can never give back.
11+- Nothing plays until the last byte has arrived and the hash has been checked, and seeking has to
12+ wait for the same.
13+
14+malice's on-device build serves media through a service worker so `<video>` and `<audio>` get real
15+URLs and Range requests. For large media it wants to answer each Range itself, fetching the matching
16+ciphertext range from the homeserver's authenticated media endpoint and decrypting it in the service
17+worker with WebCrypto AES-CTR (the counter block is the IV plus `offset / 16`, so any 16-byte aligned
18+range decrypts on its own). Nothing is buffered and the browser caches only ciphertext. The hash can
19+only be checked on a whole read, so a partial read plays unverified bytes. malice accepts that.
20+
21+What it can't get today is what the request needs. `MediaRegistration` and `MediaSourceInfo`
22+(`normalise/media.rs`) are `pub(crate)`, and there is no way to read the access token.
23+
24+## Request
25+
26+Two read-only accessors on `ClientHandle`. Suggested shapes, adjust freely.
27+
28+```rust
29+/// where a registered piece of media lives and how to decrypt it, for a host that fetches it
30+/// itself. `None` for an unknown id, same as `media` answering `MediaUnknown`
31+pub fn media_source(&self, media_id: &MediaId, size: MediaSize) -> Option<MediaSourceDetails>;
32+
33+#[non_exhaustive]
34+pub struct MediaSourceDetails {
35+ pub uri: OwnedMxcUri,
36+ pub mimetype: Option<String>,
37+ pub declared_size: Option<u64>,
38+ /// `Some` for an encrypted attachment
39+ pub encryption: Option<AttachmentEncryption>,
40+}
41+
42+#[non_exhaustive]
43+pub struct AttachmentEncryption {
44+ /// the JWK `k`, unpadded url-safe base64, AES-256-CTR
45+ pub key: String,
46+ /// unpadded base64, the initial counter block
47+ pub iv: String,
48+ /// unpadded base64 SHA-256 of the ciphertext, for a host that does read it whole
49+ pub sha256: String,
50+}
51+
52+/// the current access token and the homeserver it belongs to. it changes on every refresh, so
53+/// ask again after a 401 rather than keeping it
54+pub fn media_credentials(&self) -> Option<MediaCredentials>;
55+
56+#[non_exhaustive]
57+pub struct MediaCredentials {
58+ pub homeserver: String,
59+ pub access_token: String,
60+}
61+```
62+
63+Thumbnail sizes with no uploader thumbnail are server-side resizes, which are never encrypted, so
64+`media_source` for `MediaSize::Thumbnail` can answer the uploader's thumbnail when there is one and
65+the full source otherwise, whichever `media` would have used.
66+
67+The token accessor is the sensitive one. It hands a caller the bearer token, which `session_record`
68+already does in serialized form, so it doesn't widen what a host can reach. It is worth a doc line
69+saying the token should stay in memory and only go to the homeserver's media endpoints.
70+
71+## Also worth considering
72+
73+Streaming decryption inside macltok, `media_stream(&MediaId, range) -> impl Stream<Item = Bytes>`,
74+would let every host stream without reimplementing AES-CTR and Range handling, and would keep the
75+token inside macltok. matrix-sdk 0.18 decrypts attachments whole (`Media::get_media_content`), so
76+it would mean reimplementing the attachment decryptor over the raw download. malice will do it
77+host-side for now, so this is not blocking.
Added file: MACLTOK_PRESHARE.md+58 additions
@@ -0,0 +1,58 @@
1+# macltok request, pre-share room keys before send
2+
3+## Problem
4+
5+Sending into an encrypted room is slow in malice (and Element X), and under federation lag it can take over a minute. Clients on other SDKs feel instant because they do the key work before the user presses send.
6+
7+matrix-sdk 0.18 does all of it inline on the send path. `SendRawMessageLikeEvent::into_future` (`matrix-sdk-0.18.0/src/room/futures.rs:190-245`) calls the private `ensure_room_encryption_ready` (`futures.rs:523-537`), which runs, in the room's single send-queue worker and before the `/send` PUT
8+
9+1. `sync_members()` if `!are_members_synced()`
10+2. `query_keys_for_untracked_or_dirty_users()`, a `/keys/query` that the homeserver fans out over federation
11+3. `preshare_room_key()` (`room/mod.rs:2396-2431`), which claims one-time keys (`/keys/claim`, 10s server timeout, also federated) and then sends the megolm key to every device as to-device messages, awaiting each
12+
13+Every request uses the default 30s timeout, and the send queue retries with `short_retry` (3 attempts, exponential backoff). A slow remote server stalls the send and everything queued behind it in that room.
14+
15+For comparison, matrix-dart-sdk calls `prepareOutboundGroupSession` unawaited from `Client.setTyping`, so FluffyChat has usually finished steps 1 to 3 by the time the user hits send. matrix-js-sdk exposes `CryptoApi.prepareToEncrypt(room)` for the same purpose. matrix-sdk has the primitive but keeps it private, with this at `room/mod.rs:2392`
16+
17+```rust
18+// TODO: expose this publicly so people can pre-share a group session if
19+// e.g. a user starts to type a message for a room.
20+```
21+
22+## What is reachable today
23+
24+Checked against the 0.18 sources. Only the first step is public.
25+
26+| step | public from `matrix_sdk`? |
27+|---|---|
28+| `Room::sync_members`, `are_members_synced` | yes |
29+| `Room::query_keys_for_untracked_or_dirty_users` | no, private. `Encryption::request_user_identity(user_id)` does a per-user `/keys/query` |
30+| `Client::claim_one_time_keys` | no, `pub(crate)` |
31+| `Room::share_room_key`, `Room::preshare_room_key` | no, private. `OlmMachine::share_room_key` is pub but `Client::olm_machine()` is `pub(crate)` |
32+| `ensure_room_encryption_ready` | no, private free fn |
33+
34+There is no public encrypt-without-send path either. Every caller of `ensure_room_encryption_ready` goes on to send.
35+
36+## Request
37+
38+Expose a command that runs the send path's encryption prep without sending anything, fire and forget, deduplicated per room.
39+
40+```rust
41+impl ClientHandle {
42+ /// no-op for unencrypted rooms, coalesces with an in-flight prepare for the same room
43+ pub async fn prepare_to_send(&self, room_id: OwnedRoomId) -> Result<(), Error>;
44+}
45+```
46+
47+malice would call it when the user starts typing in a room (alongside a typing notice) and probably when a room is opened. A later send then finds members synced, device lists fresh and the outbound session already shared, so only the `/send` PUT remains. Correctness is unchanged, since the send path still runs `ensure_room_encryption_ready` and redoes whatever went stale in between (a member joining after the prepare, say).
48+
49+Suggested route, in order of preference
50+
51+1. Upstream a small matrix-rust-sdk PR making `Room::preshare_room_key` public, or adding `pub async fn Room::prepare_encryption(&self)` that wraps `ensure_room_encryption_ready`. The TODO invites exactly this. Until it is released macltok could carry it via a `[patch.crates-io]` git fork, if that fits its dependency policy.
52+2. A partial warm-up using only public API while the PR is pending. `sync_members()` when not synced, then `request_user_identity` for members whose device lists are unknown, capped (members can be many and each query is a federated round trip). This removes step 1 and the `/keys/query` part of step 2 from the send path, which is the part federation lag hurts most. Steps 3 (`/keys/claim` and the to-device share) stay on the send path. It does not know which users are dirty (only `tracked_users()` is public), so it can over-query. Worth it only if option 1 will take a while.
53+
54+malice will pair it with the existing `ClientHandle::set_typing(room_id, bool)` (`transport.rs:357`), so no new typing API is needed.
55+
56+## Not requested
57+
58+A "send without waiting for fresh device lists" mode. It would encrypt to stale device lists, so new members and new devices miss the key and see undecryptable messages. Pre-sharing gives the same latency without that.
Added file: README.md+127 additions
@@ -0,0 +1,127 @@
1+# Malice
2+
3+Malice is a hosted Matrix client with a Rust backend built on macltok and a
4+Svelte frontend. The Nix package serves both the API and the built frontend.
5+
6+## NixOS module
7+
8+Add this repository to your NixOS flake inputs, then include its module:
9+
10+```nix
11+# In your flake.nix
12+inputs.malice.url = "git+file:///absolute/path/to/malice";
13+
14+# In the modules list for your nixosSystem:
15+malice.nixosModules.default
16+```
17+
18+Use a published Git URL instead of the local path if you deploy from another
19+machine. For a local Git flake, add new source files to Git before rebuilding;
20+Nix does not include untracked files.
21+
22+Enable the service in your NixOS configuration. For access from another machine,
23+put it behind an HTTPS reverse proxy; the service binds to localhost and uses
24+Secure session cookies by default:
25+
26+```nix
27+{
28+ services.malice.enable = true;
29+ # Optional: services.malice.port = 3000;
30+
31+ services.nginx.enable = true;
32+ networking.firewall.allowedTCPPorts = [ 80 443 ];
33+ services.nginx.virtualHosts."chat.example.org" = {
34+ enableACME = true;
35+ forceSSL = true;
36+ locations."/" = {
37+ proxyPass = "http://127.0.0.1:3000";
38+ extraConfig = ''
39+ proxy_set_header Host $host;
40+ proxy_buffering off; # Keep event-stream updates timely.
41+ '';
42+ };
43+ };
44+ security.acme.acceptTerms = true;
45+ security.acme.defaults.email = "you@example.org";
46+}
47+```
48+
49+Replace the domain and ACME email, then rebuild your NixOS configuration as
50+usual. Check `systemctl status malice` and visit `https://chat.example.org/`.
51+The module stores the master key and sessions in `/var/lib/malice`; back up that
52+entire directory together. Its `listenAddress`, `port`, `package`, and
53+`openFirewall` options can be overridden. Do not expose the HTTP listener
54+without a trusted TLS proxy.
55+
56+## Client behavior
57+
58+The home list shows non-space rooms (channels); Matrix spaces have their own
59+navigation and show their advertised child rooms. A Matrix homeserver is the
60+network address used at sign-in, not a space. Sending a message returns a queued
61+command, then the timeline shows whether delivery is queued, sending, confirmed
62+by the server, or failed. Opening a room loads its timeline; scrolling up loads
63+older history. Images and avatars are served through authenticated, bounded
64+media endpoints; SVG content is not rendered. Link previews use URL text only
65+and do not fetch third-party pages.
66+
67+The Security panel accepts incoming device-verification requests and supports
68+emoji/number comparison, confirmation, rejection, and recovery-key entry.
69+Recovery keys and encryption state are per Matrix account; preserve the local
70+session data when migrating an installation.
71+
72+## Development
73+
74+From the repository root:
75+
76+```sh
77+nix develop
78+npm --prefix web ci
79+```
80+
81+For live frontend changes, start these in separate terminals inside `nix develop`:
82+
83+```sh
84+MALICE_SESSION_COOKIE_SECURE=false cargo run --bin malice
85+npm --prefix web run dev
86+```
87+
88+Open `http://127.0.0.1:5173/`. Vite proxies `/api` to the Rust server on port
89+3000. The cookie override is for local HTTP development only. By default,
90+Malice accepts HTTPS homeservers; set `MALICE_ALLOW_HTTP_HOMESERVER=true` only
91+when testing against a trusted local HTTP homeserver.
92+
93+To serve the built frontend directly from Rust instead of using Vite:
94+
95+```sh
96+npm --prefix web run build
97+MALICE_SESSION_COOKIE_SECURE=false cargo run --bin malice
98+```
99+
100+Open `http://127.0.0.1:3000/`. Rust uses `web/dist` unless `MALICE_WEB_DIR`
101+points at another built frontend directory. Local session data defaults to
102+`local-data/`; set `MALICE_DATA_DIR` to use another directory. Keep its
103+`master.key` and session files private.
104+
105+## Testing
106+
107+In `nix develop`, after `npm --prefix web ci`:
108+
109+```sh
110+cargo fmt -- --check
111+cargo clippy --all-targets -- -D warnings
112+cargo test --all-targets
113+npm --prefix web exec -- tsc --noEmit -p web/tsconfig.json
114+npm --prefix web run build
115+```
116+
117+The Rust integration test uses a mocked Matrix homeserver; it does not require
118+live account credentials. To verify the deployable package and NixOS module:
119+
120+```sh
121+nix build .#server
122+nix flake check
123+```
124+
125+`nix build` creates `result/bin/malice` with the frontend bundled. On a NixOS
126+host, `curl -fsS http://127.0.0.1:3000/api/health` checks the local backend;
127+opening the proxied URL checks the frontend and TLS setup.
Added file: crates/malice-core/Cargo.toml+18 additions
@@ -0,0 +1,18 @@
1+[package]
2+name = "malice-core"
3+version = "0.1.0"
4+edition = "2024"
5+
6+[lib]
7+name = "malice_core"
8+
9+[dependencies]
10+macltok = { workspace = true, features = ["serde", "mirror"] }
11+futures-util = { version = "0.3", default-features = false, features = ["std"] }
12+ruma = { workspace = true }
13+serde = { workspace = true }
14+serde_json = { workspace = true }
15+http = { workspace = true }
16+
17+[lints]
18+workspace = true
Added file: crates/malice-core/src/error.rs+65 additions
@@ -0,0 +1,65 @@
1+use serde::Serialize;
2+
3+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4+pub enum Failure {
5+ BadRequest,
6+ NotFound,
7+ Conflict,
8+ Busy,
9+ TooLarge,
10+ Unsupported,
11+ EngineStopped,
12+ Upstream,
13+}
14+
15+#[derive(Debug, Clone, Serialize)]
16+pub struct CoreError {
17+ pub kind: Failure,
18+ pub message: String,
19+}
20+
21+impl CoreError {
22+ pub fn new(kind: Failure, message: impl Into<String>) -> Self {
23+ Self {
24+ kind,
25+ message: message.into(),
26+ }
27+ }
28+
29+ pub fn bad_request(message: impl Into<String>) -> Self {
30+ Self::new(Failure::BadRequest, message)
31+ }
32+
33+ pub fn not_found(message: impl Into<String>) -> Self {
34+ Self::new(Failure::NotFound, message)
35+ }
36+
37+ pub fn unsupported(message: impl Into<String>) -> Self {
38+ Self::new(Failure::Unsupported, message)
39+ }
40+}
41+
42+impl From<macltok::Error> for CoreError {
43+ fn from(error: macltok::Error) -> Self {
44+ use macltok::ErrorKind;
45+ let kind = match error.kind {
46+ ErrorKind::Interrupted => {
47+ return Self::new(
48+ Failure::EngineStopped,
49+ "the sync engine stopped; sign in again",
50+ );
51+ }
52+ ErrorKind::QueueFull | ErrorKind::RoomBusy => Failure::Busy,
53+ ErrorKind::RoomUnknown | ErrorKind::MediaUnknown | ErrorKind::MediaUnavailable => {
54+ Failure::NotFound
55+ }
56+ ErrorKind::MediaTooLarge => Failure::TooLarge,
57+ ErrorKind::Invalid | ErrorKind::EmptyBody => Failure::BadRequest,
58+ ErrorKind::RoomNotOpen
59+ | ErrorKind::TimelineNotOpen
60+ | ErrorKind::VerificationInactive => Failure::Conflict,
61+ _ => Failure::Upstream,
62+ };
63+ Self::new(kind, error.message)
64+ }
65+}
Added file: crates/malice-core/src/feed.rs+179 additions
@@ -0,0 +1,179 @@
1+use std::collections::HashMap;
2+
3+use futures_util::{Stream, StreamExt, stream};
4+use macltok::{
5+ ConnectionState, EncryptionState, MirrorHandle, RoomSummary, SessionStatus, State, StateChange,
6+ TimelineChange, TimelineId, TimelineItem, UploadOutcome, VerificationState, imbl::Vector,
7+};
8+use serde::Serialize;
9+
10+#[derive(Debug, Clone, Serialize)]
11+#[serde(tag = "type", rename_all = "snake_case")]
12+pub enum WireEvent {
13+ Snapshot {
14+ rooms: Vec<RoomSummary>,
15+ timelines: HashMap<String, Vector<TimelineItem>>,
16+ connection: String,
17+ encryption: Option<EncryptionState>,
18+ verification: Option<VerificationState>,
19+ },
20+ Rooms {
21+ rooms: Vec<RoomSummary>,
22+ },
23+ Timeline {
24+ room_id: String,
25+ items: Vector<TimelineItem>,
26+ },
27+ TimelineClosed {
28+ room_id: String,
29+ },
30+ Connection {
31+ state: String,
32+ },
33+ Encryption {
34+ state: EncryptionState,
35+ },
36+ Verification {
37+ state: VerificationState,
38+ },
39+ Error {
40+ message: String,
41+ },
42+ /// Emitted directly by a Host when an account's engine ends; the hosted
43+ /// SSE stream never produces this, since it has no equivalent of an
44+ /// account disappearing out from under a live subscription.
45+ SessionEnded {
46+ retain: SessionRetain,
47+ },
48+}
49+
50+/// `macltok::Retain`'s wire form. macltok's own enum has no `rename_all`,
51+/// so this exists to give it one.
52+#[derive(Debug, Clone, Copy, Serialize)]
53+#[serde(rename_all = "snake_case")]
54+pub enum SessionRetain {
55+ Nothing,
56+ Store,
57+ RecordAndStore,
58+}
59+
60+impl From<macltok::Retain> for SessionRetain {
61+ fn from(retain: macltok::Retain) -> Self {
62+ match retain {
63+ macltok::Retain::Nothing => Self::Nothing,
64+ macltok::Retain::Store => Self::Store,
65+ macltok::Retain::RecordAndStore => Self::RecordAndStore,
66+ }
67+ }
68+}
69+
70+/// A fresh subscriber's first frame, built from the mirror's current state.
71+pub fn snapshot_event(state: &State) -> WireEvent {
72+ let timelines = state
73+ .timelines
74+ .iter()
75+ .filter_map(|(timeline_id, timeline)| match timeline_id {
76+ TimelineId::Room(room_id) => {
77+ Some((room_id.as_str().to_owned(), timeline.items.clone()))
78+ }
79+ _ => None,
80+ })
81+ .collect();
82+ WireEvent::Snapshot {
83+ rooms: state.rooms.iter().cloned().collect(),
84+ timelines,
85+ connection: connection_label(&state.connection),
86+ encryption: state.encryption.clone(),
87+ verification: state.verification.clone(),
88+ }
89+}
90+
91+fn error(message: &str) -> WireEvent {
92+ WireEvent::Error {
93+ message: message.to_owned(),
94+ }
95+}
96+
97+pub fn wire_events(change: &StateChange) -> Vec<WireEvent> {
98+ match change {
99+ StateChange::State(state) => {
100+ let mut events = vec![snapshot_event(state)];
101+ if let Some(message) = &state.session_save_failure {
102+ events.push(WireEvent::Error {
103+ message: message.clone(),
104+ });
105+ }
106+ events
107+ }
108+ StateChange::Rooms(rooms) => vec![WireEvent::Rooms {
109+ rooms: rooms.iter().cloned().collect(),
110+ }],
111+ StateChange::Timeline {
112+ timeline_id: TimelineId::Room(room_id),
113+ change,
114+ } => match change {
115+ TimelineChange::Reset { items } | TimelineChange::Changed { items, .. } => {
116+ vec![WireEvent::Timeline {
117+ room_id: room_id.as_str().to_owned(),
118+ items: items.clone(),
119+ }]
120+ }
121+ TimelineChange::Closed => vec![WireEvent::TimelineClosed {
122+ room_id: room_id.as_str().to_owned(),
123+ }],
124+ _ => vec![],
125+ },
126+ StateChange::Timeline { .. } => vec![],
127+ StateChange::Connection(state) => vec![WireEvent::Connection {
128+ state: connection_label(state),
129+ }],
130+ StateChange::Encryption(state) => vec![WireEvent::Encryption {
131+ state: state.clone(),
132+ }],
133+ StateChange::Verification(state) => vec![WireEvent::Verification {
134+ state: state.clone(),
135+ }],
136+ StateChange::Failed(failure) => vec![error(&failure.message)],
137+ StateChange::SessionSaveFailed(_) => {
138+ vec![error(
139+ "saving the session failed, you may need to sign in again",
140+ )]
141+ }
142+ StateChange::Session(
143+ SessionStatus::AccessRevoked | SessionStatus::SoftLoggedOut { .. },
144+ ) => {
145+ vec![error("session rejected by the server; sign in again")]
146+ }
147+ StateChange::UploadFinished { outcome, .. } => match outcome {
148+ UploadOutcome::Sent { .. } => vec![],
149+ UploadOutcome::Cancelled => vec![error("upload cancelled")],
150+ UploadOutcome::RoomForgotten => vec![error("upload cancelled, room forgotten")],
151+ UploadOutcome::Missed => vec![error("upload status unknown")],
152+ _ => vec![error("upload did not complete")],
153+ },
154+ StateChange::Upload { upload, .. } => match &upload.failure {
155+ Some(failure) if !failure.will_retry => vec![error("upload failed")],
156+ _ => vec![],
157+ },
158+ _ => vec![],
159+ }
160+}
161+
162+/// Snapshot first, then updates with no gap. Ends once the mirror stops.
163+pub fn subscribe(mirror: &MirrorHandle) -> impl Stream<Item = WireEvent> + use<> {
164+ mirror
165+ .subscribe()
166+ .flat_map(|update| stream::iter(wire_events(&update.change)))
167+}
168+
169+pub fn connection_label(state: &ConnectionState) -> String {
170+ match state {
171+ ConnectionState::Idle => "idle",
172+ ConnectionState::Connecting => "connecting",
173+ ConnectionState::Online => "online",
174+ ConnectionState::Offline => "offline",
175+ ConnectionState::Stopped => "stopped",
176+ _ => "unknown",
177+ }
178+ .to_owned()
179+}
Added file: crates/malice-core/src/lib.rs+6 additions
@@ -0,0 +1,6 @@
1+pub mod error;
2+pub mod feed;
3+pub mod login;
4+pub mod media;
5+pub mod parse;
6+pub mod request;
Added file: crates/malice-core/src/login.rs+95 additions
@@ -0,0 +1,95 @@
1+use serde::Deserialize;
2+
3+use crate::error::CoreError;
4+
5+#[derive(Debug, Deserialize)]
6+pub struct LoginInput {
7+ #[serde(default)]
8+ pub homeserver: String,
9+ #[serde(default)]
10+ pub username: String,
11+ #[serde(default)]
12+ pub password: String,
13+}
14+
15+impl LoginInput {
16+ pub fn validate(self, allow_http: bool) -> Result<LoginInput, CoreError> {
17+ let homeserver = normalise_homeserver(&self.homeserver, allow_http)?;
18+ let username = self.username.trim();
19+ if username.is_empty() {
20+ return Err(CoreError::bad_request("username is required"));
21+ }
22+ if self.password.is_empty() {
23+ return Err(CoreError::bad_request("password is required"));
24+ }
25+ Ok(LoginInput {
26+ homeserver,
27+ username: username.to_owned(),
28+ password: self.password,
29+ })
30+ }
31+}
32+
33+pub fn normalise_homeserver(raw: &str, allow_http: bool) -> Result<String, CoreError> {
34+ let trimmed = raw.trim();
35+ if trimmed.is_empty() {
36+ return Err(CoreError::bad_request("homeserver is required"));
37+ }
38+ if trimmed.contains(char::is_whitespace) {
39+ return Err(CoreError::bad_request("homeserver must not contain spaces"));
40+ }
41+ let with_scheme = if trimmed.contains("://") {
42+ trimmed.to_owned()
43+ } else {
44+ format!("https://{trimmed}")
45+ };
46+ let uri: http::Uri = with_scheme
47+ .parse()
48+ .map_err(|_| CoreError::bad_request("invalid homeserver"))?;
49+ let scheme = uri
50+ .scheme_str()
51+ .ok_or_else(|| CoreError::bad_request("invalid homeserver"))?;
52+ if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
53+ return Err(CoreError::bad_request("homeserver must use http(s)"));
54+ }
55+ let authority = uri
56+ .authority()
57+ .ok_or_else(|| CoreError::bad_request("homeserver must include a host"))?;
58+ if authority.as_str().contains('@') || authority.host().is_empty() {
59+ return Err(CoreError::bad_request("homeserver must include a host"));
60+ }
61+ if scheme.eq_ignore_ascii_case("http") {
62+ let host = authority.host().trim_end_matches('.');
63+ let loopback = host.eq_ignore_ascii_case("localhost")
64+ || host == "127.0.0.1"
65+ || host == "[::1]"
66+ || host == "::1";
67+ if !(allow_http && loopback) {
68+ return Err(CoreError::bad_request(
69+ "insecure http homeservers are not allowed",
70+ ));
71+ }
72+ }
73+ Ok(with_scheme)
74+}
75+
76+/// macltok's `AccountError` displays only its context, the homeserver's answer is in the sources
77+pub fn describe_error(error: &(dyn std::error::Error + 'static)) -> String {
78+ let mut message = error.to_string();
79+ let mut source = error.source();
80+ while let Some(cause) = source {
81+ message.push_str(", ");
82+ message.push_str(&cause.to_string());
83+ source = cause.source();
84+ }
85+ message
86+}
87+
88+pub fn sanitise_auth_error(message: &str, password: &str) -> String {
89+ let message = if password.is_empty() {
90+ message.to_owned()
91+ } else {
92+ message.replace(password, "[redacted]")
93+ };
94+ message.chars().take(400).collect()
95+}
Added file: crates/malice-core/src/media.rs+45 additions
@@ -0,0 +1,45 @@
1+pub const AVATAR_SIZE: u32 = 256;
2+pub const THUMBNAIL_SIZE: u32 = 800;
3+
4+pub fn image_content_type(bytes: &[u8]) -> Option<&'static str> {
5+ if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
6+ Some("image/png")
7+ } else if bytes.starts_with(b"\xff\xd8\xff") {
8+ Some("image/jpeg")
9+ } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
10+ Some("image/gif")
11+ } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
12+ Some("image/webp")
13+ } else if bytes.len() >= 12
14+ && &bytes[4..8] == b"ftyp"
15+ && (&bytes[8..12] == b"avif" || &bytes[8..12] == b"avis")
16+ {
17+ Some("image/avif")
18+ } else {
19+ None
20+ }
21+}
22+
23+pub fn attachment_content_type(bytes: &[u8]) -> Option<&'static str> {
24+ if let Some(image) = image_content_type(bytes) {
25+ return Some(image);
26+ }
27+ if bytes.starts_with(b"\x1a\x45\xdf\xa3") {
28+ Some("video/webm")
29+ } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE" {
30+ Some("audio/wav")
31+ } else if bytes.starts_with(b"OggS") {
32+ Some("audio/ogg")
33+ } else if bytes.starts_with(b"ID3") || bytes.starts_with(b"\xff\xfb") {
34+ Some("audio/mpeg")
35+ } else if bytes.len() >= 12
36+ && &bytes[4..8] == b"ftyp"
37+ && [b"isom", b"iso2", b"mp41", b"mp42", b"avc1"]
38+ .iter()
39+ .any(|brand| &bytes[8..12] == brand.as_slice())
40+ {
41+ Some("video/mp4")
42+ } else {
43+ None
44+ }
45+}
Added file: crates/malice-core/src/parse.rs+25 additions
@@ -0,0 +1,25 @@
1+use crate::error::CoreError;
2+
3+pub fn parse_room_id(raw: &str) -> Result<ruma::OwnedRoomId, CoreError> {
4+ let trimmed = raw.trim();
5+ if trimmed.is_empty() {
6+ return Err(CoreError::bad_request("room_id is required"));
7+ }
8+ ruma::OwnedRoomId::try_from(trimmed).map_err(|_| CoreError::bad_request("invalid room_id"))
9+}
10+
11+pub fn parse_event_id(raw: &str) -> Result<ruma::OwnedEventId, CoreError> {
12+ let trimmed = raw.trim();
13+ if trimmed.is_empty() {
14+ return Err(CoreError::bad_request("event_id is required"));
15+ }
16+ ruma::OwnedEventId::try_from(trimmed).map_err(|_| CoreError::bad_request("invalid event_id"))
17+}
18+
19+pub fn parse_room_or_alias(raw: &str) -> Result<ruma::OwnedRoomOrAliasId, CoreError> {
20+ let trimmed = raw.trim();
21+ if trimmed.is_empty() {
22+ return Err(CoreError::bad_request("room is required"));
23+ }
24+ ruma::OwnedRoomOrAliasId::try_from(trimmed).map_err(|_| CoreError::bad_request("invalid room"))
25+}
Added file: crates/malice-core/src/request.rs+336 additions
@@ -0,0 +1,336 @@
1+use macltok::{
2+ AttachmentUpload, ClientHandle, CreateRoomOptions, MediaId, MediaSize, MemberFilter,
3+ MessageContent, MirrorHandle, TimelineId, VerificationAction, VerificationTarget,
4+};
5+use serde::Deserialize;
6+use serde_json::json;
7+
8+use crate::{
9+ error::CoreError,
10+ media::{AVATAR_SIZE, THUMBNAIL_SIZE, attachment_content_type, image_content_type},
11+ parse::{parse_event_id, parse_room_id, parse_room_or_alias},
12+};
13+
14+#[derive(Debug, Deserialize)]
15+#[serde(tag = "op", rename_all = "snake_case")]
16+pub enum Request {
17+ Rooms,
18+ Members {
19+ #[serde(default)]
20+ room_id: String,
21+ },
22+ Open {
23+ #[serde(default)]
24+ room_id: String,
25+ },
26+ Close {
27+ #[serde(default)]
28+ room_id: String,
29+ },
30+ Send {
31+ #[serde(default)]
32+ room_id: String,
33+ #[serde(default)]
34+ body: String,
35+ },
36+ Upload {
37+ room_id: String,
38+ filename: String,
39+ mimetype: String,
40+ #[serde(skip)]
41+ bytes: Vec<u8>,
42+ },
43+ Paginate {
44+ #[serde(default)]
45+ room_id: String,
46+ },
47+ Join {
48+ #[serde(default)]
49+ room: String,
50+ },
51+ Create {
52+ #[serde(default)]
53+ name: String,
54+ #[serde(default)]
55+ topic: Option<String>,
56+ },
57+ Leave {
58+ #[serde(default)]
59+ room_id: String,
60+ },
61+ React {
62+ #[serde(default)]
63+ room_id: String,
64+ #[serde(default)]
65+ event_id: String,
66+ #[serde(default)]
67+ key: String,
68+ },
69+ Read {
70+ #[serde(default)]
71+ room_id: String,
72+ #[serde(default)]
73+ event_id: String,
74+ },
75+ Profile,
76+ SpaceChildren {
77+ #[serde(default)]
78+ room_id: String,
79+ },
80+ Encryption,
81+ Recover {
82+ recovery_key: String,
83+ },
84+ StartVerification,
85+ Verify {
86+ action: String,
87+ },
88+ Avatar {
89+ mxc: String,
90+ },
91+ Media {
92+ media_id: String,
93+ size: String,
94+ },
95+ Typing {
96+ #[serde(default)]
97+ room_id: String,
98+ #[serde(default)]
99+ typing: bool,
100+ },
101+}
102+
103+impl Request {
104+ pub fn upload(room_id: String, filename: String, mimetype: String, bytes: Vec<u8>) -> Request {
105+ Request::Upload {
106+ room_id,
107+ filename,
108+ mimetype,
109+ bytes,
110+ }
111+ }
112+}
113+
114+pub enum Reply {
115+ Done,
116+ Json(serde_json::Value),
117+ Avatar {
118+ bytes: Vec<u8>,
119+ content_type: &'static str,
120+ },
121+ Attachment {
122+ bytes: Vec<u8>,
123+ content_type: Option<&'static str>,
124+ },
125+}
126+
127+pub async fn dispatch(
128+ handle: &ClientHandle,
129+ mirror: &MirrorHandle,
130+ request: Request,
131+) -> Result<Reply, CoreError> {
132+ match request {
133+ Request::Rooms => {
134+ let rooms = handle.rooms().await;
135+ Ok(Reply::Json(json!({ "rooms": rooms })))
136+ }
137+ Request::Members { room_id } => {
138+ let room_id = parse_room_id(&room_id)?;
139+ let members = handle.members(&room_id, MemberFilter::Active).await?;
140+ Ok(Reply::Json(json!({ "members": members })))
141+ }
142+ Request::Open { room_id } => {
143+ let room_id = parse_room_id(&room_id)?;
144+ handle.open_room(room_id).await?;
145+ Ok(Reply::Done)
146+ }
147+ Request::Close { room_id } => {
148+ let room_id = parse_room_id(&room_id)?;
149+ handle.close_room(room_id).await?;
150+ Ok(Reply::Done)
151+ }
152+ Request::Send { room_id, body } => {
153+ let room_id = parse_room_id(&room_id)?;
154+ if body.trim().is_empty() {
155+ return Err(CoreError::bad_request("body must not be empty"));
156+ }
157+ if body.len() > 64 * 1024 {
158+ return Err(CoreError::bad_request("body is too large"));
159+ }
160+ handle
161+ .send_message(TimelineId::Room(room_id), MessageContent::text(body))
162+ .await?;
163+ Ok(Reply::Done)
164+ }
165+ Request::Upload {
166+ room_id,
167+ filename,
168+ mimetype,
169+ bytes,
170+ } => {
171+ let room_id = parse_room_id(&room_id)?;
172+ if bytes.is_empty() || bytes.len() > 20 * 1024 * 1024 {
173+ return Err(CoreError::bad_request(
174+ "file must be between 1 byte and 20 MiB",
175+ ));
176+ }
177+ let upload = AttachmentUpload::new(room_id, filename, mimetype, bytes);
178+ handle.upload(upload).await?;
179+ Ok(Reply::Done)
180+ }
181+ Request::Paginate { room_id } => {
182+ let room_id = parse_room_id(&room_id)?;
183+ handle.paginate(TimelineId::Room(room_id), 30).await?;
184+ Ok(Reply::Done)
185+ }
186+ Request::Join { room } => {
187+ let room = parse_room_or_alias(&room)?;
188+ let room_id = handle.join_room(room, Vec::new()).await?;
189+ Ok(Reply::Json(json!({ "room_id": room_id })))
190+ }
191+ Request::Create { name, topic } => {
192+ let name = name.trim();
193+ if name.is_empty() {
194+ return Err(CoreError::bad_request("name is required"));
195+ }
196+ if name.len() > 255 {
197+ return Err(CoreError::bad_request("name is too long"));
198+ }
199+ if topic.as_deref().is_some_and(|topic| topic.len() > 1024) {
200+ return Err(CoreError::bad_request("topic is too long"));
201+ }
202+ let mut options = CreateRoomOptions::default().with_name(name);
203+ if let Some(topic) = topic
204+ .as_deref()
205+ .map(str::trim)
206+ .filter(|topic| !topic.is_empty())
207+ {
208+ options = options.with_topic(topic);
209+ }
210+ let room_id = handle.create_room(options).await?;
211+ Ok(Reply::Json(json!({ "room_id": room_id })))
212+ }
213+ Request::Leave { room_id } => {
214+ let room_id = parse_room_id(&room_id)?;
215+ handle.leave_room(room_id).await?;
216+ Ok(Reply::Done)
217+ }
218+ Request::React {
219+ room_id,
220+ event_id,
221+ key,
222+ } => {
223+ let room_id = parse_room_id(&room_id)?;
224+ let event_id = parse_event_id(&event_id)?;
225+ let key = key.trim();
226+ if key.is_empty() || key.chars().count() > 32 {
227+ return Err(CoreError::bad_request("key must be 1..32 characters"));
228+ }
229+ handle
230+ .toggle_reaction(TimelineId::Room(room_id), event_id, key)
231+ .await?;
232+ Ok(Reply::Done)
233+ }
234+ Request::Read { room_id, event_id } => {
235+ let room_id = parse_room_id(&room_id)?;
236+ let event_id = parse_event_id(&event_id)?;
237+ handle
238+ .mark_read(TimelineId::Room(room_id), event_id)
239+ .await?;
240+ Ok(Reply::Done)
241+ }
242+ Request::Profile => {
243+ let profile = handle.own_profile().await?;
244+ Ok(Reply::Json(json!({
245+ "display_name": profile.display_name,
246+ "avatar_url": profile.avatar,
247+ })))
248+ }
249+ Request::SpaceChildren { room_id } => {
250+ let room_id = parse_room_id(&room_id)?;
251+ let rooms = handle.rooms().await;
252+ match rooms.into_iter().find(|room| room.room_id == room_id) {
253+ Some(space) => Ok(Reply::Json(json!({ "room_ids": space.children }))),
254+ None => Err(CoreError::not_found(
255+ "room is not available in the local store",
256+ )),
257+ }
258+ }
259+ Request::Encryption => {
260+ let state = mirror.snapshot();
261+ Ok(Reply::Json(json!({
262+ "state": state.encryption,
263+ "verification": state.verification,
264+ })))
265+ }
266+ Request::Recover { recovery_key } => {
267+ if recovery_key.trim().is_empty() {
268+ return Err(CoreError::bad_request("recovery_key is required"));
269+ }
270+ handle.recover(recovery_key).await?;
271+ Ok(Reply::Done)
272+ }
273+ Request::StartVerification => {
274+ handle
275+ .start_verification(VerificationTarget::OwnIdentity)
276+ .await?;
277+ Ok(Reply::Done)
278+ }
279+ Request::Verify { action } => {
280+ let action = match action.as_str() {
281+ "Accept" => VerificationAction::Accept,
282+ "Confirm" => VerificationAction::Confirm,
283+ "Reject" => VerificationAction::Reject,
284+ "Cancel" => VerificationAction::Cancel,
285+ _ => return Err(CoreError::bad_request("invalid verification action")),
286+ };
287+ handle.verify(action).await?;
288+ Ok(Reply::Done)
289+ }
290+ Request::Avatar { mxc } => {
291+ let mxc_ref = <&ruma::MxcUri>::from(mxc.as_str());
292+ if !mxc_ref.is_valid() {
293+ return Err(CoreError::bad_request("invalid mxc URI"));
294+ }
295+ let size = MediaSize::Thumbnail {
296+ width: AVATAR_SIZE,
297+ height: AVATAR_SIZE,
298+ };
299+ let bytes = handle.mxc_media(mxc_ref, size).await?.bytes.to_vec();
300+ let Some(content_type) = image_content_type(&bytes) else {
301+ return Err(CoreError::unsupported(
302+ "MXC content is not a supported image",
303+ ));
304+ };
305+ Ok(Reply::Avatar {
306+ bytes,
307+ content_type,
308+ })
309+ }
310+ Request::Media { media_id, size } => {
311+ if media_id.is_empty() || media_id.len() > 512 {
312+ return Err(CoreError::bad_request("invalid media_id"));
313+ }
314+ let media_id = MediaId::from(media_id);
315+ let size = match size.as_str() {
316+ "Thumbnail" => MediaSize::Thumbnail {
317+ width: THUMBNAIL_SIZE,
318+ height: THUMBNAIL_SIZE,
319+ },
320+ "Full" => MediaSize::Full,
321+ _ => return Err(CoreError::bad_request("invalid size")),
322+ };
323+ let bytes = handle.media(&media_id, size).await?.bytes.to_vec();
324+ let content_type = attachment_content_type(&bytes);
325+ Ok(Reply::Attachment {
326+ bytes,
327+ content_type,
328+ })
329+ }
330+ Request::Typing { room_id, typing } => {
331+ let room_id = parse_room_id(&room_id)?;
332+ handle.set_typing(room_id, typing).await?;
333+ Ok(Reply::Done)
334+ }
335+ }
336+}
Added file: crates/malice-server/Cargo.toml+40 additions
@@ -0,0 +1,40 @@
1+[package]
2+name = "malice"
3+version = "0.1.0"
4+edition = "2024"
5+
6+[[bin]]
7+name = "malice"
8+path = "src/main.rs"
9+
10+[lib]
11+name = "malice"
12+path = "src/lib.rs"
13+
14+[dependencies]
15+malice-core = { path = "../malice-core" }
16+macltok = { workspace = true, features = ["sqlite", "serde", "mirror"] }
17+axum = { version = "0.8", features = ["macros", "multipart"] }
18+futures-util = { version = "0.3", default-features = false, features = ["std"] }
19+hex = "0.4"
20+listenfd = "1"
21+rand_core = { version = "0.6", features = ["getrandom"] }
22+ruma = { workspace = true }
23+serde = { workspace = true }
24+serde_json = { workspace = true }
25+sha2 = "0.10"
26+tokio = { workspace = true, features = ["full"] }
27+tower-http = { version = "0.6", features = ["fs", "trace"] }
28+tracing = { workspace = true }
29+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
30+
31+[dev-dependencies]
32+http-body-util = "0.1"
33+tower = { version = "0.5", features = ["util"] }
34+matrix-sdk = { version = "0.18", default-features = false, features = ["testing"] }
35+reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls"] }
36+tempfile = "3"
37+wiremock = "0.6"
38+
39+[lints]
40+workspace = true
Added file: crates/malice-server/src/csrf.rs+111 additions
@@ -0,0 +1,111 @@
1+use axum::response::Json;
2+use axum::{
3+ extract::Request,
4+ http::{HeaderMap, Method, StatusCode, header},
5+ middleware::Next,
6+ response::{IntoResponse, Response},
7+};
8+use serde_json::json;
9+
10+pub const COOKIE_NAME: &str = "malice_session";
11+const COOKIE_MAX_AGE: u32 = 2_592_000;
12+
13+/// Parse the opaque session id from the `Cookie` header.
14+///
15+/// Returns `None` when the cookie is absent or malformed. The value is
16+/// restricted to a safe alphabet so it can be used as a file name.
17+pub fn session_sid(headers: &HeaderMap) -> Option<String> {
18+ for value in headers.get_all(header::COOKIE) {
19+ let Ok(raw) = value.to_str() else {
20+ continue;
21+ };
22+ for part in raw.split(';') {
23+ let part = part.trim();
24+ let Some((name, val)) = part.split_once('=') else {
25+ continue;
26+ };
27+ if name.trim() != COOKIE_NAME {
28+ continue;
29+ }
30+ let val = val.trim().trim_matches('"');
31+ if valid_sid(val) {
32+ return Some(val.to_owned());
33+ }
34+ }
35+ }
36+ None
37+}
38+
39+pub fn valid_sid(value: &str) -> bool {
40+ !value.is_empty()
41+ && value.len() <= 128
42+ && value
43+ .chars()
44+ .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
45+}
46+
47+pub fn set_cookie_value(sid: &str, secure: bool) -> String {
48+ let mut cookie =
49+ format!("{COOKIE_NAME}={sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age={COOKIE_MAX_AGE}");
50+ if secure {
51+ cookie.push_str("; Secure");
52+ }
53+ cookie
54+}
55+
56+pub fn clear_cookie_value(secure: bool) -> String {
57+ let mut cookie = format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
58+ if secure {
59+ cookie.push_str("; Secure");
60+ }
61+ cookie
62+}
63+
64+/// Reject cross-origin mutating API requests. Port is part of an origin:
65+/// another app on the same host must not be able to drive this user's cookie.
66+/// The dev proxy preserves Host, so its forwarded requests remain same-origin.
67+pub async fn csrf_guard(request: Request, next: Next) -> Response {
68+ let method = request.method().clone();
69+ let path = request.uri().path().to_owned();
70+ let is_api = path == "/api" || path.starts_with("/api/");
71+ let is_mutating = method != Method::GET && method != Method::HEAD && method != Method::OPTIONS;
72+ if !(is_api && is_mutating) {
73+ return next.run(request).await;
74+ }
75+ let headers = request.headers();
76+ // Host only: X-Forwarded-Host is client-controlled and must not decide
77+ // same-origin. The Host the backend received is what the browser used,
78+ // which is exactly what Origin must match.
79+ let host = headers
80+ .get(header::HOST)
81+ .and_then(|value| value.to_str().ok())
82+ .unwrap_or("");
83+ let origin = headers
84+ .get(header::ORIGIN)
85+ .and_then(|value| value.to_str().ok());
86+ let referer = headers
87+ .get(header::REFERER)
88+ .and_then(|value| value.to_str().ok());
89+ let allowed = match (origin, referer) {
90+ (Some(value), _) => same_authority(value, host),
91+ (None, Some(value)) => same_authority(value, host),
92+ (None, None) => true,
93+ };
94+ if !allowed {
95+ return (
96+ StatusCode::FORBIDDEN,
97+ Json(json!({"message": "origin check failed"})),
98+ )
99+ .into_response();
100+ }
101+ next.run(request).await
102+}
103+
104+fn same_authority(source: &str, host: &str) -> bool {
105+ source.parse::<axum::http::Uri>().ok().is_some_and(|uri| {
106+ matches!(uri.scheme_str(), Some("http" | "https"))
107+ && uri
108+ .authority()
109+ .is_some_and(|authority| authority.as_str().eq_ignore_ascii_case(host))
110+ })
111+}
Added file: crates/malice-server/src/error.rs+110 additions
@@ -0,0 +1,110 @@
1+use axum::{
2+ http::{HeaderMap, StatusCode, header},
3+ response::{IntoResponse, Response},
4+};
5+use malice_core::error::{CoreError, Failure};
6+use serde::Serialize;
7+use serde_json::json;
8+
9+use crate::csrf::clear_cookie_value;
10+
11+/// A JSON API failure: `{ "message": ... }` with a meaningful status code.
12+pub struct ApiError {
13+ status: StatusCode,
14+ message: String,
15+ /// When set, the response clears the session cookie (stale credential).
16+ clear_session: Option<bool>,
17+}
18+
19+impl ApiError {
20+ pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
21+ Self {
22+ status,
23+ message: message.into(),
24+ clear_session: None,
25+ }
26+ }
27+
28+ pub fn bad_request(message: impl Into<String>) -> Self {
29+ Self::new(StatusCode::BAD_REQUEST, message)
30+ }
31+
32+ pub fn unauthorized(message: impl Into<String>) -> Self {
33+ Self::new(StatusCode::UNAUTHORIZED, message)
34+ }
35+
36+ /// 401 that also expires the session cookie.
37+ pub fn unauthorized_stale(message: impl Into<String>, cookie_secure: bool) -> Self {
38+ Self {
39+ status: StatusCode::UNAUTHORIZED,
40+ message: message.into(),
41+ clear_session: Some(cookie_secure),
42+ }
43+ }
44+
45+ pub fn not_found(message: impl Into<String>) -> Self {
46+ Self::new(StatusCode::NOT_FOUND, message)
47+ }
48+
49+ pub fn engine_stopped() -> Self {
50+ Self::new(
51+ StatusCode::BAD_GATEWAY,
52+ "the sync engine stopped; sign in again",
53+ )
54+ }
55+}
56+
57+impl From<CoreError> for ApiError {
58+ fn from(error: CoreError) -> Self {
59+ let status = match error.kind {
60+ Failure::BadRequest => StatusCode::BAD_REQUEST,
61+ Failure::NotFound => StatusCode::NOT_FOUND,
62+ Failure::Conflict => StatusCode::CONFLICT,
63+ Failure::Busy => StatusCode::TOO_MANY_REQUESTS,
64+ Failure::TooLarge => StatusCode::PAYLOAD_TOO_LARGE,
65+ Failure::Unsupported => StatusCode::UNSUPPORTED_MEDIA_TYPE,
66+ Failure::EngineStopped => return Self::engine_stopped(),
67+ Failure::Upstream => StatusCode::BAD_GATEWAY,
68+ };
69+ Self::new(status, error.message)
70+ }
71+}
72+
73+impl IntoResponse for ApiError {
74+ fn into_response(self) -> Response {
75+ let mut headers = HeaderMap::new();
76+ if let Some(secure) = self.clear_session
77+ && let Ok(value) = clear_cookie_value(secure).parse()
78+ {
79+ headers.insert(header::SET_COOKIE, value);
80+ }
81+ (
82+ self.status,
83+ headers,
84+ axum::Json(json!({ "message": self.message })),
85+ )
86+ .into_response()
87+ }
88+}
89+
90+/// 200 JSON response, optionally refreshing the session cookie.
91+pub fn json_ok<T: Serialize>(value: &T, set_cookie: Option<String>) -> Response {
92+ let mut headers = HeaderMap::new();
93+ if let Some(cookie) = set_cookie
94+ && let Ok(value) = cookie.parse()
95+ {
96+ headers.insert(header::SET_COOKIE, value);
97+ }
98+ (StatusCode::OK, headers, axum::Json(json!(value))).into_response()
99+}
100+
101+/// 204 response, optionally clearing the session cookie.
102+pub fn no_content(clear_cookie: Option<String>) -> Response {
103+ let mut headers = HeaderMap::new();
104+ if let Some(cookie) = clear_cookie
105+ && let Ok(value) = cookie.parse()
106+ {
107+ headers.insert(header::SET_COOKIE, value);
108+ }
109+ (StatusCode::NO_CONTENT, headers).into_response()
110+}
Added file: crates/malice-server/src/idle.rs+73 additions
@@ -0,0 +1,73 @@
1+//! Idle-exit tracking for the socket-activated static-only server: counts
2+//! in-flight requests and the time of the last one, so the process can shut
3+//! itself down once nothing has happened for `MALICE_IDLE_EXIT_SECS`.
4+
5+use std::{
6+ sync::{
7+ Arc,
8+ atomic::{AtomicUsize, Ordering},
9+ },
10+ time::{Duration, Instant},
11+};
12+
13+use axum::{extract::State, middleware::Next};
14+
15+#[derive(Clone)]
16+pub struct IdleTracker {
17+ in_flight: Arc<AtomicUsize>,
18+ last_activity: Arc<std::sync::Mutex<Instant>>,
19+}
20+
21+impl IdleTracker {
22+ pub fn new() -> Self {
23+ Self {
24+ in_flight: Arc::new(AtomicUsize::new(0)),
25+ last_activity: Arc::new(std::sync::Mutex::new(Instant::now())),
26+ }
27+ }
28+
29+ fn touch(&self) {
30+ *self
31+ .last_activity
32+ .lock()
33+ .expect("idle tracker mutex poisoned") = Instant::now();
34+ }
35+
36+ /// Resolves once no request has started or finished, and none is still
37+ /// in flight, for at least `idle`.
38+ pub async fn wait_idle(&self, idle: Duration) {
39+ loop {
40+ tokio::time::sleep(idle.min(Duration::from_millis(200))).await;
41+ if self.in_flight.load(Ordering::SeqCst) != 0 {
42+ continue;
43+ }
44+ let elapsed = self
45+ .last_activity
46+ .lock()
47+ .expect("idle tracker mutex poisoned")
48+ .elapsed();
49+ if elapsed >= idle {
50+ return;
51+ }
52+ }
53+ }
54+}
55+
56+impl Default for IdleTracker {
57+ fn default() -> Self {
58+ Self::new()
59+ }
60+}
61+
62+pub async fn track(
63+ State(idle): State<IdleTracker>,
64+ request: axum::extract::Request,
65+ next: Next,
66+) -> axum::response::Response {
67+ idle.in_flight.fetch_add(1, Ordering::SeqCst);
68+ idle.touch();
69+ let response = next.run(request).await;
70+ idle.touch();
71+ idle.in_flight.fetch_sub(1, Ordering::SeqCst);
72+ response
73+}
Added file: crates/malice-server/src/lib.rs+5 additions
@@ -0,0 +1,5 @@
1+pub mod csrf;
2+pub mod error;
3+pub mod idle;
4+pub mod routes;
5+pub mod state;
Added file: crates/malice-server/src/main.rs+159 additions
@@ -0,0 +1,159 @@
1+use std::{future::Future, net::SocketAddr, path::PathBuf, time::Duration};
2+
3+use axum::{Router, middleware};
4+use listenfd::ListenFd;
5+use malice::{
6+ idle::IdleTracker,
7+ routes::{router, static_router},
8+ state::{AppState, ensure_dirs, load_or_create_master_key},
9+};
10+
11+fn env_flag(name: &str, default: bool) -> bool {
12+ match std::env::var(name) {
13+ Ok(value) => {
14+ let value = value.trim().to_ascii_lowercase();
15+ match value.as_str() {
16+ "1" | "true" | "yes" | "on" => true,
17+ "0" | "false" | "no" | "off" => false,
18+ _ => default,
19+ }
20+ }
21+ Err(_) => default,
22+ }
23+}
24+
25+fn data_dir() -> PathBuf {
26+ std::env::var("MALICE_DATA_DIR")
27+ .map(PathBuf::from)
28+ .unwrap_or_else(|_| PathBuf::from("local-data"))
29+}
30+
31+fn bind_addr() -> SocketAddr {
32+ std::env::var("MALICE_BIND")
33+ .unwrap_or_else(|_| "127.0.0.1:3000".to_owned())
34+ .parse()
35+ .expect("MALICE_BIND must be host:port")
36+}
37+
38+fn idle_exit_secs() -> Option<u64> {
39+ std::env::var("MALICE_IDLE_EXIT_SECS")
40+ .ok()
41+ .and_then(|value| value.trim().parse().ok())
42+}
43+
44+fn web_dir() -> Option<String> {
45+ if let Ok(dir) = std::env::var("MALICE_WEB_DIR") {
46+ assert!(
47+ std::path::Path::new(&dir).join("index.html").is_file(),
48+ "MALICE_WEB_DIR must point to built frontend assets containing index.html"
49+ );
50+ return Some(dir);
51+ }
52+
53+ let local = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("web/dist");
54+ local
55+ .join("index.html")
56+ .is_file()
57+ .then(|| local.to_string_lossy().into_owned())
58+}
59+
60+/// Takes over a systemd-activated listener from fd 3 when present, so the
61+/// socket unit can hold the port before the process ever starts. Falls back
62+/// to a plain bind otherwise.
63+async fn bind_listener() -> tokio::net::TcpListener {
64+ let mut listenfd = ListenFd::from_env();
65+ if let Some(listener) = listenfd
66+ .take_tcp_listener(0)
67+ .expect("inspecting inherited sockets")
68+ {
69+ listener
70+ .set_nonblocking(true)
71+ .expect("setting inherited socket nonblocking");
72+ return tokio::net::TcpListener::from_std(listener)
73+ .expect("adopting inherited socket into tokio");
74+ }
75+ let addr = bind_addr();
76+ tracing::info!("listening on {addr}");
77+ tokio::net::TcpListener::bind(addr)
78+ .await
79+ .expect("binding the listener must succeed")
80+}
81+
82+/// Serves `app` on `listener`, exiting on ctrl-c or, when
83+/// `MALICE_IDLE_EXIT_SECS` is set, after that long with no request in flight
84+/// or started. `on_shutdown` runs after the exit signal either way.
85+async fn serve(
86+ listener: tokio::net::TcpListener,
87+ app: Router,
88+ on_shutdown: impl Future<Output = ()> + Send + 'static,
89+) {
90+ let idle = idle_exit_secs().map(|secs| (IdleTracker::new(), secs));
91+ let app = match &idle {
92+ Some((tracker, _)) => app.layer(middleware::from_fn_with_state(
93+ tracker.clone(),
94+ malice::idle::track,
95+ )),
96+ None => app,
97+ };
98+ axum::serve(listener, app)
99+ .with_graceful_shutdown(async move {
100+ match idle {
101+ Some((tracker, secs)) => {
102+ tokio::select! {
103+ _ = tokio::signal::ctrl_c() => {}
104+ _ = tracker.wait_idle(Duration::from_secs(secs)) => {
105+ tracing::info!("idle for {secs}s with no requests, exiting");
106+ }
107+ }
108+ }
109+ None => {
110+ let _ = tokio::signal::ctrl_c().await;
111+ }
112+ }
113+ on_shutdown.await;
114+ })
115+ .await
116+ .expect("serving must succeed");
117+}
118+
119+async fn run_hosted() {
120+ let data_dir = data_dir();
121+ ensure_dirs(&data_dir);
122+ let master_key =
123+ load_or_create_master_key(&data_dir).expect("loading the master key must succeed");
124+ let cookie_secure = env_flag("MALICE_SESSION_COOKIE_SECURE", true);
125+ let allow_http_homeserver = env_flag("MALICE_ALLOW_HTTP_HOMESERVER", false);
126+ let state = AppState::new(data_dir, master_key, cookie_secure, allow_http_homeserver);
127+ let app = router(state.clone(), web_dir());
128+ let listener = bind_listener().await;
129+ serve(
130+ listener,
131+ app,
132+ async move { state.shutdown_gracefully().await },
133+ )
134+ .await;
135+}
136+
137+async fn run_static_only() {
138+ let app = static_router(web_dir());
139+ let listener = bind_listener().await;
140+ serve(listener, app, async {}).await;
141+}
142+
143+#[tokio::main]
144+async fn main() {
145+ tracing_subscriber::fmt()
146+ .with_env_filter(
147+ tracing_subscriber::EnvFilter::try_from_default_env()
148+ .unwrap_or_else(|_| "malice=info,tower_http=info".into()),
149+ )
150+ .with_target(false)
151+ .compact()
152+ .init();
153+
154+ if env_flag("MALICE_STATIC_ONLY", false) {
155+ run_static_only().await;
156+ } else {
157+ run_hosted().await;
158+ }
159+}
Added file: crates/malice-server/src/routes.rs+835 additions
@@ -0,0 +1,835 @@
1+use std::sync::Arc;
2+
3+use axum::{
4+ Router,
5+ extract::{DefaultBodyLimit, Multipart, Query, State},
6+ http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
7+ middleware,
8+ middleware::Next,
9+ response::{IntoResponse, Response, Sse, sse::Event as SseEvent},
10+ routing::{any, get, post},
11+};
12+use futures_util::StreamExt;
13+use macltok::Retain;
14+use malice_core::{
15+ feed::subscribe,
16+ login::{LoginInput, sanitise_auth_error},
17+ request::{Reply, Request, dispatch},
18+};
19+use serde::{Deserialize, Serialize};
20+use tower_http::{
21+ services::{ServeDir, ServeFile},
22+ trace::TraceLayer,
23+};
24+
25+use crate::{
26+ csrf::{csrf_guard, session_sid, set_cookie_value},
27+ error::{ApiError, json_ok, no_content},
28+ state::{AppState, RestoreError, UserSession},
29+};
30+
31+#[derive(Debug, Deserialize)]
32+struct RoomIdBody {
33+ #[serde(default)]
34+ room_id: String,
35+}
36+
37+#[derive(Debug, Deserialize)]
38+struct SendBody {
39+ #[serde(default)]
40+ room_id: String,
41+ #[serde(default)]
42+ body: String,
43+}
44+
45+#[derive(Debug, Deserialize)]
46+struct PaginateBody {
47+ #[serde(default)]
48+ room_id: String,
49+}
50+
51+#[derive(Debug, Deserialize)]
52+struct JoinBody {
53+ #[serde(default)]
54+ room: String,
55+}
56+
57+#[derive(Debug, Deserialize)]
58+struct CreateBody {
59+ #[serde(default)]
60+ name: String,
61+ #[serde(default)]
62+ topic: Option<String>,
63+}
64+
65+#[derive(Debug, Deserialize)]
66+struct ReactBody {
67+ #[serde(default)]
68+ room_id: String,
69+ #[serde(default)]
70+ event_id: String,
71+ #[serde(default)]
72+ key: String,
73+}
74+
75+#[derive(Debug, Deserialize)]
76+struct ReadBody {
77+ #[serde(default)]
78+ room_id: String,
79+ #[serde(default)]
80+ event_id: String,
81+}
82+
83+#[derive(Debug, Deserialize)]
84+struct MembersQuery {
85+ #[serde(default)]
86+ room_id: String,
87+}
88+#[derive(Debug, Deserialize)]
89+struct RecoverBody {
90+ recovery_key: String,
91+}
92+
93+#[derive(Debug, Deserialize)]
94+struct VerifyBody {
95+ action: String,
96+}
97+
98+#[derive(Debug, Deserialize)]
99+struct MediaQuery {
100+ mxc: String,
101+}
102+#[derive(Debug, Deserialize)]
103+struct MessageMediaQuery {
104+ media_id: String,
105+ size: String,
106+}
107+
108+#[derive(Debug, Deserialize)]
109+struct TypingBody {
110+ #[serde(default)]
111+ room_id: String,
112+ #[serde(default)]
113+ typing: bool,
114+}
115+
116+#[derive(Debug, Serialize)]
117+struct SoftLogoutInfo {
118+ user_id: String,
119+ homeserver: String,
120+}
121+
122+#[derive(Debug, Serialize)]
123+struct SessionResponse {
124+ user_id: Option<String>,
125+ #[serde(skip_serializing_if = "Option::is_none")]
126+ soft_logout: Option<SoftLogoutInfo>,
127+}
128+
129+async fn auth(
130+ state: &AppState,
131+ headers: &HeaderMap,
132+) -> Result<(Arc<UserSession>, String), ApiError> {
133+ let Some(sid) = session_sid(headers) else {
134+ return Err(ApiError::unauthorized("not signed in"));
135+ };
136+ match state.get_or_restore(&sid).await {
137+ Ok(user) => Ok((user, sid)),
138+ Err(RestoreError::NotFound) => Err(ApiError::unauthorized_stale(
139+ "session expired; sign in again",
140+ state.cookie_secure(),
141+ )),
142+ Err(RestoreError::SoftLoggedOut { .. }) => Err(ApiError::unauthorized(
143+ "signed out by the server, sign in again",
144+ )),
145+ Err(RestoreError::Stale(message)) => {
146+ state.remove_session(&sid, Retain::Nothing).await;
147+ Err(ApiError::unauthorized_stale(message, state.cookie_secure()))
148+ }
149+ Err(RestoreError::Unavailable(message)) => {
150+ Err(ApiError::new(StatusCode::BAD_GATEWAY, message))
151+ }
152+ }
153+}
154+
155+/// commands answer when they're done, so the browser gets the outcome in the response
156+fn respond(result: Result<Reply, malice_core::error::CoreError>) -> Response {
157+ match result {
158+ Ok(Reply::Done) => no_content(None),
159+ Ok(Reply::Json(value)) => json_ok(&value, None),
160+ Ok(Reply::Avatar {
161+ bytes,
162+ content_type,
163+ }) => (
164+ [
165+ (axum::http::header::CONTENT_TYPE, content_type),
166+ (axum::http::header::CACHE_CONTROL, "private, max-age=3600"),
167+ (axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
168+ ],
169+ bytes,
170+ )
171+ .into_response(),
172+ Ok(Reply::Attachment {
173+ bytes,
174+ content_type,
175+ }) => (
176+ [
177+ (
178+ axum::http::header::CONTENT_TYPE,
179+ content_type.unwrap_or("application/octet-stream"),
180+ ),
181+ (
182+ axum::http::header::CONTENT_DISPOSITION,
183+ if content_type.is_some() {
184+ "inline"
185+ } else {
186+ "attachment"
187+ },
188+ ),
189+ (axum::http::header::CACHE_CONTROL, "private, max-age=3600"),
190+ (axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
191+ (axum::http::header::CONTENT_SECURITY_POLICY, "sandbox"),
192+ ],
193+ bytes,
194+ )
195+ .into_response(),
196+ Err(error) => ApiError::from(error).into_response(),
197+ }
198+}
199+
200+async fn get_session(State(state): State<AppState>, headers: HeaderMap) -> Response {
201+ let Some(sid) = session_sid(&headers) else {
202+ return json_ok(
203+ &SessionResponse {
204+ user_id: None,
205+ soft_logout: None,
206+ },
207+ None,
208+ );
209+ };
210+ match state.get_or_restore(&sid).await {
211+ Ok(user) => json_ok(
212+ &SessionResponse {
213+ user_id: Some(user.user_id.clone()),
214+ soft_logout: None,
215+ },
216+ None,
217+ ),
218+ Err(RestoreError::NotFound) => json_ok(
219+ &SessionResponse {
220+ user_id: None,
221+ soft_logout: None,
222+ },
223+ None,
224+ ),
225+ Err(RestoreError::SoftLoggedOut {
226+ user_id,
227+ homeserver,
228+ }) => json_ok(
229+ &SessionResponse {
230+ user_id: None,
231+ soft_logout: Some(SoftLogoutInfo {
232+ user_id,
233+ homeserver,
234+ }),
235+ },
236+ None,
237+ ),
238+ Err(RestoreError::Stale(message)) => {
239+ state.remove_session(&sid, Retain::Nothing).await;
240+ ApiError::unauthorized_stale(message, state.cookie_secure()).into_response()
241+ }
242+ Err(RestoreError::Unavailable(message)) => {
243+ ApiError::new(StatusCode::BAD_GATEWAY, message).into_response()
244+ }
245+ }
246+}
247+
248+async fn post_login(
249+ State(state): State<AppState>,
250+ headers: HeaderMap,
251+ body: axum::Json<LoginInput>,
252+) -> Response {
253+ let input = match body.0.validate(state.allow_http_homeserver()) {
254+ Ok(input) => input,
255+ Err(error) => return ApiError::from(error).into_response(),
256+ };
257+ let cookie_sid = session_sid(&headers);
258+ let password = input.password.clone();
259+ match state
260+ .login(
261+ cookie_sid.as_deref(),
262+ input.homeserver,
263+ input.username,
264+ input.password,
265+ )
266+ .await
267+ {
268+ Ok(user) => json_ok(
269+ &serde_json::json!({ "user_id": user.user_id }),
270+ Some(set_cookie_value(&user.sid, state.cookie_secure())),
271+ ),
272+ Err(message) => {
273+ let status = if message.contains("starting sync engine")
274+ || message.contains("could not save the session")
275+ || message.contains("another engine is using this session")
276+ {
277+ StatusCode::BAD_GATEWAY
278+ } else {
279+ StatusCode::UNAUTHORIZED
280+ };
281+ ApiError::new(status, sanitise_auth_error(&message, &password)).into_response()
282+ }
283+ }
284+}
285+
286+async fn post_logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
287+ if let Some(sid) = session_sid(&headers) {
288+ state.logout(&sid).await;
289+ }
290+ no_content(Some(crate::csrf::clear_cookie_value(state.cookie_secure())))
291+}
292+
293+async fn get_rooms(State(state): State<AppState>, headers: HeaderMap) -> Response {
294+ let (user, _) = match auth(&state, &headers).await {
295+ Ok(pair) => pair,
296+ Err(error) => return error.into_response(),
297+ };
298+ respond(dispatch(&user.handle, &user.mirror, Request::Rooms).await)
299+}
300+
301+async fn get_members(
302+ State(state): State<AppState>,
303+ headers: HeaderMap,
304+ Query(query): Query<MembersQuery>,
305+) -> Response {
306+ let (user, _) = match auth(&state, &headers).await {
307+ Ok(pair) => pair,
308+ Err(error) => return error.into_response(),
309+ };
310+ respond(
311+ dispatch(
312+ &user.handle,
313+ &user.mirror,
314+ Request::Members {
315+ room_id: query.room_id,
316+ },
317+ )
318+ .await,
319+ )
320+}
321+
322+async fn rooms_open(
323+ State(state): State<AppState>,
324+ headers: HeaderMap,
325+ body: axum::Json<RoomIdBody>,
326+) -> Response {
327+ let (user, _) = match auth(&state, &headers).await {
328+ Ok(pair) => pair,
329+ Err(error) => return error.into_response(),
330+ };
331+ respond(
332+ dispatch(
333+ &user.handle,
334+ &user.mirror,
335+ Request::Open {
336+ room_id: body.0.room_id,
337+ },
338+ )
339+ .await,
340+ )
341+}
342+
343+async fn rooms_close(
344+ State(state): State<AppState>,
345+ headers: HeaderMap,
346+ body: axum::Json<RoomIdBody>,
347+) -> Response {
348+ let (user, _) = match auth(&state, &headers).await {
349+ Ok(pair) => pair,
350+ Err(error) => return error.into_response(),
351+ };
352+ respond(
353+ dispatch(
354+ &user.handle,
355+ &user.mirror,
356+ Request::Close {
357+ room_id: body.0.room_id,
358+ },
359+ )
360+ .await,
361+ )
362+}
363+
364+async fn rooms_send(
365+ State(state): State<AppState>,
366+ headers: HeaderMap,
367+ body: axum::Json<SendBody>,
368+) -> Response {
369+ let (user, _) = match auth(&state, &headers).await {
370+ Ok(pair) => pair,
371+ Err(error) => return error.into_response(),
372+ };
373+ respond(
374+ dispatch(
375+ &user.handle,
376+ &user.mirror,
377+ Request::Send {
378+ room_id: body.0.room_id,
379+ body: body.0.body,
380+ },
381+ )
382+ .await,
383+ )
384+}
385+
386+async fn rooms_upload(
387+ State(state): State<AppState>,
388+ headers: HeaderMap,
389+ mut upload: Multipart,
390+) -> Response {
391+ let (user, _) = match auth(&state, &headers).await {
392+ Ok(pair) => pair,
393+ Err(error) => return error.into_response(),
394+ };
395+ let mut room_id = None;
396+ let mut file = None;
397+ loop {
398+ let field = match upload.next_field().await {
399+ Ok(Some(field)) => field,
400+ Ok(None) => break,
401+ Err(_) => return ApiError::bad_request("invalid upload form").into_response(),
402+ };
403+ match field.name() {
404+ Some("room_id") if room_id.is_none() => {
405+ room_id = match field.text().await {
406+ Ok(value) => Some(value),
407+ Err(_) => return ApiError::bad_request("invalid room_id").into_response(),
408+ };
409+ }
410+ Some("file") if file.is_none() => {
411+ let filename = field.file_name().unwrap_or("attachment").to_owned();
412+ let mimetype = field
413+ .content_type()
414+ .unwrap_or("application/octet-stream")
415+ .to_owned();
416+ let bytes = match field.bytes().await {
417+ Ok(bytes) => bytes,
418+ Err(_) => return ApiError::bad_request("invalid file").into_response(),
419+ };
420+ if bytes.is_empty() || bytes.len() > 20 * 1024 * 1024 {
421+ return ApiError::bad_request("file must be between 1 byte and 20 MiB")
422+ .into_response();
423+ }
424+ file = Some((filename, mimetype, bytes));
425+ }
426+ _ => return ApiError::bad_request("unexpected upload field").into_response(),
427+ }
428+ }
429+ let Some(room_id) = room_id else {
430+ return ApiError::bad_request("room_id is required").into_response();
431+ };
432+ let Some((filename, mimetype, bytes)) = file else {
433+ return ApiError::bad_request("file is required").into_response();
434+ };
435+ respond(
436+ dispatch(
437+ &user.handle,
438+ &user.mirror,
439+ Request::upload(room_id, filename, mimetype, bytes.to_vec()),
440+ )
441+ .await,
442+ )
443+}
444+
445+async fn rooms_paginate(
446+ State(state): State<AppState>,
447+ headers: HeaderMap,
448+ body: axum::Json<PaginateBody>,
449+) -> Response {
450+ let (user, _) = match auth(&state, &headers).await {
451+ Ok(pair) => pair,
452+ Err(error) => return error.into_response(),
453+ };
454+ respond(
455+ dispatch(
456+ &user.handle,
457+ &user.mirror,
458+ Request::Paginate {
459+ room_id: body.0.room_id,
460+ },
461+ )
462+ .await,
463+ )
464+}
465+
466+async fn rooms_join(
467+ State(state): State<AppState>,
468+ headers: HeaderMap,
469+ body: axum::Json<JoinBody>,
470+) -> Response {
471+ let (user, _) = match auth(&state, &headers).await {
472+ Ok(pair) => pair,
473+ Err(error) => return error.into_response(),
474+ };
475+ respond(
476+ dispatch(
477+ &user.handle,
478+ &user.mirror,
479+ Request::Join { room: body.0.room },
480+ )
481+ .await,
482+ )
483+}
484+
485+async fn rooms_create(
486+ State(state): State<AppState>,
487+ headers: HeaderMap,
488+ body: axum::Json<CreateBody>,
489+) -> Response {
490+ let (user, _) = match auth(&state, &headers).await {
491+ Ok(pair) => pair,
492+ Err(error) => return error.into_response(),
493+ };
494+ respond(
495+ dispatch(
496+ &user.handle,
497+ &user.mirror,
498+ Request::Create {
499+ name: body.0.name,
500+ topic: body.0.topic,
501+ },
502+ )
503+ .await,
504+ )
505+}
506+
507+async fn rooms_leave(
508+ State(state): State<AppState>,
509+ headers: HeaderMap,
510+ body: axum::Json<RoomIdBody>,
511+) -> Response {
512+ let (user, _) = match auth(&state, &headers).await {
513+ Ok(pair) => pair,
514+ Err(error) => return error.into_response(),
515+ };
516+ respond(
517+ dispatch(
518+ &user.handle,
519+ &user.mirror,
520+ Request::Leave {
521+ room_id: body.0.room_id,
522+ },
523+ )
524+ .await,
525+ )
526+}
527+
528+async fn rooms_react(
529+ State(state): State<AppState>,
530+ headers: HeaderMap,
531+ body: axum::Json<ReactBody>,
532+) -> Response {
533+ let (user, _) = match auth(&state, &headers).await {
534+ Ok(pair) => pair,
535+ Err(error) => return error.into_response(),
536+ };
537+ respond(
538+ dispatch(
539+ &user.handle,
540+ &user.mirror,
541+ Request::React {
542+ room_id: body.0.room_id,
543+ event_id: body.0.event_id,
544+ key: body.0.key,
545+ },
546+ )
547+ .await,
548+ )
549+}
550+
551+async fn rooms_read(
552+ State(state): State<AppState>,
553+ headers: HeaderMap,
554+ body: axum::Json<ReadBody>,
555+) -> Response {
556+ let (user, _) = match auth(&state, &headers).await {
557+ Ok(pair) => pair,
558+ Err(error) => return error.into_response(),
559+ };
560+ respond(
561+ dispatch(
562+ &user.handle,
563+ &user.mirror,
564+ Request::Read {
565+ room_id: body.0.room_id,
566+ event_id: body.0.event_id,
567+ },
568+ )
569+ .await,
570+ )
571+}
572+
573+async fn get_profile(State(state): State<AppState>, headers: HeaderMap) -> Response {
574+ let (user, _) = match auth(&state, &headers).await {
575+ Ok(pair) => pair,
576+ Err(error) => return error.into_response(),
577+ };
578+ respond(dispatch(&user.handle, &user.mirror, Request::Profile).await)
579+}
580+
581+async fn get_space_children(
582+ State(state): State<AppState>,
583+ headers: HeaderMap,
584+ Query(query): Query<MembersQuery>,
585+) -> Response {
586+ let (user, _) = match auth(&state, &headers).await {
587+ Ok(pair) => pair,
588+ Err(error) => return error.into_response(),
589+ };
590+ respond(
591+ dispatch(
592+ &user.handle,
593+ &user.mirror,
594+ Request::SpaceChildren {
595+ room_id: query.room_id,
596+ },
597+ )
598+ .await,
599+ )
600+}
601+
602+async fn get_media(
603+ State(state): State<AppState>,
604+ headers: HeaderMap,
605+ Query(query): Query<MediaQuery>,
606+) -> Response {
607+ let (user, _) = match auth(&state, &headers).await {
608+ Ok(pair) => pair,
609+ Err(error) => return error.into_response(),
610+ };
611+ respond(
612+ dispatch(
613+ &user.handle,
614+ &user.mirror,
615+ Request::Avatar { mxc: query.mxc },
616+ )
617+ .await,
618+ )
619+}
620+
621+async fn get_message_media(
622+ State(state): State<AppState>,
623+ headers: HeaderMap,
624+ Query(query): Query<MessageMediaQuery>,
625+) -> Response {
626+ let (user, _) = match auth(&state, &headers).await {
627+ Ok(pair) => pair,
628+ Err(error) => return error.into_response(),
629+ };
630+ // the declared mimetype is the sender's claim, so the bytes get sniffed instead
631+ respond(
632+ dispatch(
633+ &user.handle,
634+ &user.mirror,
635+ Request::Media {
636+ media_id: query.media_id,
637+ size: query.size,
638+ },
639+ )
640+ .await,
641+ )
642+}
643+
644+async fn get_encryption(State(state): State<AppState>, headers: HeaderMap) -> Response {
645+ let (user, _) = match auth(&state, &headers).await {
646+ Ok(pair) => pair,
647+ Err(error) => return error.into_response(),
648+ };
649+ respond(dispatch(&user.handle, &user.mirror, Request::Encryption).await)
650+}
651+
652+async fn recover_encryption(
653+ State(state): State<AppState>,
654+ headers: HeaderMap,
655+ body: axum::Json<RecoverBody>,
656+) -> Response {
657+ let (user, _) = match auth(&state, &headers).await {
658+ Ok(pair) => pair,
659+ Err(error) => return error.into_response(),
660+ };
661+ respond(
662+ dispatch(
663+ &user.handle,
664+ &user.mirror,
665+ Request::Recover {
666+ recovery_key: body.0.recovery_key,
667+ },
668+ )
669+ .await,
670+ )
671+}
672+
673+async fn start_verification(State(state): State<AppState>, headers: HeaderMap) -> Response {
674+ let (user, _) = match auth(&state, &headers).await {
675+ Ok(pair) => pair,
676+ Err(error) => return error.into_response(),
677+ };
678+ respond(dispatch(&user.handle, &user.mirror, Request::StartVerification).await)
679+}
680+
681+async fn verify_encryption(
682+ State(state): State<AppState>,
683+ headers: HeaderMap,
684+ body: axum::Json<VerifyBody>,
685+) -> Response {
686+ let (user, _) = match auth(&state, &headers).await {
687+ Ok(pair) => pair,
688+ Err(error) => return error.into_response(),
689+ };
690+ respond(
691+ dispatch(
692+ &user.handle,
693+ &user.mirror,
694+ Request::Verify {
695+ action: body.0.action,
696+ },
697+ )
698+ .await,
699+ )
700+}
701+
702+async fn rooms_typing(
703+ State(state): State<AppState>,
704+ headers: HeaderMap,
705+ body: axum::Json<TypingBody>,
706+) -> Response {
707+ let (user, _) = match auth(&state, &headers).await {
708+ Ok(pair) => pair,
709+ Err(error) => return error.into_response(),
710+ };
711+ respond(
712+ dispatch(
713+ &user.handle,
714+ &user.mirror,
715+ Request::Typing {
716+ room_id: body.0.room_id,
717+ typing: body.0.typing,
718+ },
719+ )
720+ .await,
721+ )
722+}
723+
724+async fn get_events(State(state): State<AppState>, headers: HeaderMap) -> Response {
725+ let (user, _) = match auth(&state, &headers).await {
726+ Ok(pair) => pair,
727+ Err(error) => return error.into_response(),
728+ };
729+ let body = subscribe(&user.mirror).map(|event| {
730+ let json = serde_json::to_string(&event).unwrap_or_else(|_| "{\"type\":\"error\"}".into());
731+ Ok::<_, std::convert::Infallible>(SseEvent::default().data(json))
732+ });
733+ Sse::new(body).into_response()
734+}
735+
736+async fn get_health() -> Response {
737+ json_ok(&serde_json::json!({ "status": "ok" }), None)
738+}
739+
740+async fn static_shell() -> Response {
741+ (
742+ StatusCode::SERVICE_UNAVAILABLE,
743+ axum::Json(serde_json::json!({ "message": "frontend assets missing; run npm --prefix web run build or use the Nix package" })),
744+ )
745+ .into_response()
746+}
747+
748+pub fn router(state: AppState, web_dir: Option<String>) -> Router {
749+ let api = Router::new()
750+ .route("/session", get(get_session))
751+ .route("/login", post(post_login))
752+ .route("/logout", post(post_logout))
753+ .route("/rooms", get(get_rooms))
754+ .route("/events", get(get_events))
755+ .route("/health", get(get_health))
756+ .route("/rooms/open", post(rooms_open))
757+ .route("/rooms/close", post(rooms_close))
758+ .route("/rooms/send", post(rooms_send))
759+ .route("/rooms/paginate", post(rooms_paginate))
760+ .route(
761+ "/rooms/upload",
762+ post(rooms_upload).layer(DefaultBodyLimit::max(21 * 1024 * 1024)),
763+ )
764+ .route("/rooms/join", post(rooms_join))
765+ .route("/rooms/create", post(rooms_create))
766+ .route("/rooms/leave", post(rooms_leave))
767+ .route("/rooms/react", post(rooms_react))
768+ .route("/rooms/read", post(rooms_read))
769+ .route("/rooms/typing", post(rooms_typing))
770+ .route("/profile", get(get_profile))
771+ .route("/media", get(get_media))
772+ .route("/message-media", get(get_message_media))
773+ .route("/spaces/children", get(get_space_children))
774+ .route("/rooms/members", get(get_members))
775+ .route("/encryption", get(get_encryption))
776+ .route("/encryption/recover", post(recover_encryption))
777+ .route("/encryption/start", post(start_verification))
778+ .route("/encryption/verify", post(verify_encryption));
779+ let api = Router::new()
780+ .nest("/api", api)
781+ .layer(middleware::from_fn(csrf_guard));
782+ let router = api.with_state(state).layer(TraceLayer::new_for_http());
783+ match web_dir {
784+ Some(dir) => {
785+ let files =
786+ ServeDir::new(&dir).not_found_service(ServeFile::new(format!("{dir}/index.html")));
787+ router.fallback_service(files)
788+ }
789+ None => router.fallback(static_shell),
790+ }
791+}
792+
793+async fn api_not_found() -> StatusCode {
794+ StatusCode::NOT_FOUND
795+}
796+
797+fn is_no_cache_path(path: &str) -> bool {
798+ matches!(path, "/" | "/index.html" | "/sw.js")
799+}
800+
801+/// index.html and sw.js must never be cached, or a stale service worker or
802+/// app shell survives a rebuild; the rest of the bundle is hashed and can be
803+/// cached freely.
804+async fn static_headers(request: axum::extract::Request, next: Next) -> Response {
805+ let no_cache = is_no_cache_path(request.uri().path());
806+ let mut response = next.run(request).await;
807+ if no_cache {
808+ let headers = response.headers_mut();
809+ headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
810+ headers.insert(
811+ HeaderName::from_static("service-worker-allowed"),
812+ HeaderValue::from_static("/"),
813+ );
814+ }
815+ response
816+}
817+
818+/// Serves only the built frontend: no `/api` routes, no session state, no
819+/// data dir. Used for the socket-activated on-device build.
820+pub fn static_router(web_dir: Option<String>) -> Router {
821+ let router = Router::new()
822+ .route("/api", any(api_not_found))
823+ .route("/api/{*rest}", any(api_not_found))
824+ .layer(TraceLayer::new_for_http());
825+ match web_dir {
826+ Some(dir) => {
827+ let files =
828+ ServeDir::new(&dir).not_found_service(ServeFile::new(format!("{dir}/index.html")));
829+ router
830+ .fallback_service(files)
831+ .layer(middleware::from_fn(static_headers))
832+ }
833+ None => router.fallback(static_shell),
834+ }
835+}
Added file: crates/malice-server/src/state.rs+497 additions
@@ -0,0 +1,497 @@
1+use std::{
2+ collections::HashMap,
3+ path::{Path, PathBuf},
4+ sync::Arc,
5+ time::Duration,
6+};
7+
8+use futures_util::StreamExt;
9+use macltok::{
10+ AccountError, ClientHandle, EncryptedSessionStore, EngineConfig, EngineError, Mirror,
11+ MirrorHandle, PasswordLogin, Restore, Retain, Session, SessionStatus, SessionStore, SoftLogout,
12+ StateChange, StoreConfig, Termination,
13+};
14+use rand_core::{OsRng, RngCore};
15+use sha2::{Digest, Sha256};
16+use tokio::{sync::Mutex, task::JoinHandle, time::timeout};
17+
18+use crate::csrf::valid_sid;
19+
20+const AUTH_TIMEOUT: Duration = Duration::from_secs(20);
21+
22+pub struct UserSession {
23+ pub sid: String,
24+ pub user_id: String,
25+ pub handle: ClientHandle,
26+ pub mirror: MirrorHandle,
27+ sessions: Arc<dyn SessionStore>,
28+ engine: Mutex<Option<JoinHandle<Result<Termination, EngineError>>>>,
29+ mirror_task: Mutex<Option<JoinHandle<()>>>,
30+}
31+
32+struct Inner {
33+ sessions: tokio::sync::RwLock<HashMap<String, Arc<UserSession>>>,
34+ /// Serialises concurrent restores of the same sid so two engines never
35+ /// open the same SQLite store at once.
36+ init_lock: Mutex<()>,
37+ data_dir: PathBuf,
38+ master_key: [u8; 32],
39+ cookie_secure: bool,
40+ allow_http_homeserver: bool,
41+}
42+
43+#[derive(Clone)]
44+pub struct AppState {
45+ inner: Arc<Inner>,
46+}
47+
48+#[derive(Debug)]
49+pub enum RestoreError {
50+ /// No cookie session and nothing on disk: plain guest.
51+ NotFound,
52+ /// Credential on disk is unusable: caller should clear the cookie.
53+ Stale(String),
54+ /// Credential looks fine but the engine would not start.
55+ Unavailable(String),
56+ /// The server logged this device out; it can log back in as the same user.
57+ SoftLoggedOut { user_id: String, homeserver: String },
58+}
59+
60+impl AppState {
61+ pub fn new(
62+ data_dir: PathBuf,
63+ master_key: [u8; 32],
64+ cookie_secure: bool,
65+ allow_http_homeserver: bool,
66+ ) -> Self {
67+ Self {
68+ inner: Arc::new(Inner {
69+ sessions: tokio::sync::RwLock::new(HashMap::new()),
70+ init_lock: Mutex::new(()),
71+ data_dir,
72+ master_key,
73+ cookie_secure,
74+ allow_http_homeserver,
75+ }),
76+ }
77+ }
78+
79+ pub fn allow_http_homeserver(&self) -> bool {
80+ self.inner.allow_http_homeserver
81+ }
82+
83+ pub fn cookie_secure(&self) -> bool {
84+ self.inner.cookie_secure
85+ }
86+
87+ fn sessions_dir(&self) -> PathBuf {
88+ self.inner.data_dir.join("sessions")
89+ }
90+
91+ fn session_file(&self, sid: &str) -> PathBuf {
92+ self.sessions_dir().join(format!("{sid}.bin"))
93+ }
94+
95+ fn store_dir(&self, sid: &str) -> PathBuf {
96+ self.inner.data_dir.join("stores").join(sid)
97+ }
98+
99+ fn store_passphrase(&self, sid: &str) -> String {
100+ let mut hasher = Sha256::new();
101+ hasher.update(self.inner.master_key);
102+ hasher.update(b"malice-store-v1:");
103+ hasher.update(sid.as_bytes());
104+ hex::encode(hasher.finalize())
105+ }
106+
107+ fn store_config(&self, sid: &str) -> StoreConfig {
108+ StoreConfig::sqlite(self.store_dir(sid), self.store_passphrase(sid))
109+ }
110+
111+ /// A fresh handle onto a sid's record and store. Cheap: nothing touches
112+ /// disk until the handle is actually used.
113+ fn session_store(&self, sid: &str) -> Arc<dyn SessionStore> {
114+ Arc::new(EncryptedSessionStore::file(
115+ self.session_file(sid),
116+ self.store_config(sid),
117+ ))
118+ }
119+
120+ async fn apply_retention(&self, sessions: &Arc<dyn SessionStore>, retain: Retain) {
121+ let result = match retain {
122+ Retain::RecordAndStore => Ok(()),
123+ Retain::Store => sessions.soft_log_out().await,
124+ Retain::Nothing => sessions.discard().await,
125+ };
126+ if let Err(error) = result {
127+ tracing::warn!(%error, "applying session retention failed");
128+ }
129+ }
130+
131+ /// Resolve a cookie sid to a live session, restoring from disk on demand.
132+ /// The shared map is never held across network or engine work: the fast
133+ /// path clones an `Arc` under a read lock, and restores serialise on a
134+ /// short init lock with a double-checked insert.
135+ pub async fn get_or_restore(&self, sid: &str) -> Result<Arc<UserSession>, RestoreError> {
136+ if !valid_sid(sid) {
137+ return Err(RestoreError::NotFound);
138+ }
139+ if let Some(user) = self.inner.sessions.read().await.get(sid).cloned() {
140+ return Ok(user);
141+ }
142+ let _init = self.inner.init_lock.lock().await;
143+ if let Some(user) = self.inner.sessions.read().await.get(sid).cloned() {
144+ return Ok(user);
145+ }
146+ let sessions = self.session_store(sid);
147+ let restore = Session::restore(sessions.clone()).await.map_err(|error| {
148+ tracing::warn!("restoring persisted session failed: {error}");
149+ restore_error(error)
150+ })?;
151+ match restore {
152+ None => Err(RestoreError::NotFound),
153+ Some(Restore::SoftLoggedOut(soft)) => Err(RestoreError::SoftLoggedOut {
154+ user_id: soft.user_id.to_string(),
155+ homeserver: soft.homeserver,
156+ }),
157+ Some(Restore::Resumed(session)) => self
158+ .start_user_session(sid.to_owned(), session, sessions)
159+ .await
160+ .map_err(|error| {
161+ tracing::warn!("starting restored session failed: {error}");
162+ RestoreError::Unavailable(error)
163+ }),
164+ }
165+ }
166+
167+ /// Restore just far enough to tell whether `sid`'s record is a soft
168+ /// logout, without disturbing anything else.
169+ async fn soft_logged_out(&self, sid: &str) -> Option<SoftLogout> {
170+ match Session::restore(self.session_store(sid)).await {
171+ Ok(Some(Restore::SoftLoggedOut(soft))) => Some(soft),
172+ _ => None,
173+ }
174+ }
175+
176+ /// Password login: creates a fresh opaque sid and starts the engine,
177+ /// which saves the record itself. The password never leaves this call.
178+ /// When the cookie sid names the same user's soft-logged-out device, logs
179+ /// back in as that device against the same sid instead, keeping its keys.
180+ pub async fn login(
181+ &self,
182+ cookie_sid: Option<&str>,
183+ homeserver: String,
184+ username: String,
185+ password: String,
186+ ) -> Result<Arc<UserSession>, String> {
187+ if let Some(sid) = cookie_sid.filter(|sid| valid_sid(sid))
188+ && let Some(soft) = self.soft_logged_out(sid).await
189+ {
190+ if username_matches(&username, &soft.user_id) {
191+ let sessions = self.session_store(sid);
192+ let session = login_with(soft.password_login(password), sessions.clone()).await?;
193+ let _init = self.inner.init_lock.lock().await;
194+ return self
195+ .start_user_session(sid.to_owned(), session, sessions)
196+ .await;
197+ }
198+ self.apply_retention(&self.session_store(sid), Retain::Nothing)
199+ .await;
200+ }
201+ let sid = new_sid();
202+ let sessions = self.session_store(&sid);
203+ let session = login_with(
204+ PasswordLogin::new(homeserver, username, password)
205+ .with_device_display_name("malice web"),
206+ sessions.clone(),
207+ )
208+ .await?;
209+ let _init = self.inner.init_lock.lock().await;
210+ self.start_user_session(sid, session, sessions).await
211+ }
212+
213+ async fn start_user_session(
214+ &self,
215+ sid: String,
216+ session: Session,
217+ sessions: Arc<dyn SessionStore>,
218+ ) -> Result<Arc<UserSession>, String> {
219+ let (engine, handle, events) = session
220+ .build(EngineConfig::default())
221+ .await
222+ .map_err(|error| format!("starting sync engine: {error}"))?;
223+ let engine_task = tokio::spawn(engine.run());
224+ let (mirror, mirror_handle) = Mirror::new(handle.clone(), events);
225+ let mirror_task = tokio::spawn(mirror.run());
226+ let Some(user_id) = wait_authenticated(&mirror_handle).await else {
227+ engine_task.abort();
228+ let _ = engine_task.await;
229+ mirror_task.abort();
230+ let _ = mirror_task.await;
231+ return Err("sync engine stopped before authenticating".to_owned());
232+ };
233+ let user = Arc::new(UserSession {
234+ sid: sid.clone(),
235+ user_id,
236+ handle,
237+ mirror: mirror_handle,
238+ sessions,
239+ engine: Mutex::new(Some(engine_task)),
240+ mirror_task: Mutex::new(Some(mirror_task)),
241+ });
242+ {
243+ let mut sessions_map = self.inner.sessions.write().await;
244+ sessions_map.insert(sid, user.clone());
245+ }
246+ let supervisor_state = self.clone();
247+ let supervisor_user = user.clone();
248+ tokio::spawn(async move {
249+ supervise(supervisor_state, supervisor_user).await;
250+ });
251+ Ok(user)
252+ }
253+
254+ /// Logout: best-effort server-side logout, then drop all local state and
255+ /// delete persisted secrets. Never holds the map lock across the network
256+ /// call. Forces `Retain::Nothing` regardless of what the engine's own
257+ /// termination said, so an explicit logout always ends in a full discard
258+ /// even if it raced the supervisor over a server-initiated soft logout.
259+ pub async fn logout(&self, sid: &str) {
260+ let user = { self.inner.sessions.read().await.get(sid).cloned() };
261+ if let Some(user) = user {
262+ // answers once the server has let go of the device, and the engine stops itself
263+ let _ = timeout(Duration::from_secs(5), user.handle.logout()).await;
264+ }
265+ self.remove_session(sid, Retain::Nothing).await;
266+ }
267+
268+ /// Forget an in-memory session, abort its engine and mirror tasks, and
269+ /// apply the given retention to its persisted secrets. Called both for
270+ /// explicit logout (always `Retain::Nothing`) and from the supervisor
271+ /// once an engine's `Termination` says what its record and store are
272+ /// still worth. Retention always runs, even if the session was already
273+ /// gone from the map, so a racing logout's `Nothing` still lands.
274+ pub async fn remove_session(&self, sid: &str, retain: Retain) {
275+ // A restore must not reopen the store between removing the map entry
276+ // and deleting its persisted credentials.
277+ let _init = self.inner.init_lock.lock().await;
278+ let user = self.inner.sessions.write().await.remove(sid);
279+ let sessions = match &user {
280+ Some(user) => user.sessions.clone(),
281+ None => self.session_store(sid),
282+ };
283+ if let Some(user) = &user {
284+ if let Some(task) = user.engine.lock().await.take() {
285+ task.abort();
286+ }
287+ if let Some(task) = user.mirror_task.lock().await.take() {
288+ task.abort();
289+ }
290+ }
291+ self.apply_retention(&sessions, retain).await;
292+ }
293+
294+ /// Graceful shutdown: ask every engine to stop, keep persisted records so
295+ /// the next boot restores them.
296+ pub async fn shutdown_gracefully(&self) {
297+ let users: Vec<Arc<UserSession>> =
298+ self.inner.sessions.read().await.values().cloned().collect();
299+ for user in &users {
300+ // the supervisor keeps waiting, so a shutdown can answer once `Stopped` is out
301+ let handle = user.handle.clone();
302+ tokio::spawn(async move {
303+ let _ = timeout(Duration::from_secs(5), handle.shutdown()).await;
304+ });
305+ }
306+ // Give engines a moment to emit `Stopped`; the supervisor then drops
307+ // each in-memory session while leaving persisted records behind.
308+ timeout(Duration::from_secs(5), async {
309+ loop {
310+ if self.inner.sessions.read().await.is_empty() {
311+ break;
312+ }
313+ tokio::time::sleep(Duration::from_millis(50)).await;
314+ }
315+ })
316+ .await
317+ .ok();
318+ for user in &users {
319+ if let Some(task) = user.engine.lock().await.take() {
320+ task.abort();
321+ }
322+ if let Some(task) = user.mirror_task.lock().await.take() {
323+ task.abort();
324+ }
325+ }
326+ self.inner.sessions.write().await.clear();
327+ }
328+}
329+
330+fn restore_error(error: AccountError) -> RestoreError {
331+ match error {
332+ AccountError::StoreInUse => {
333+ RestoreError::Unavailable("another connection is using this session".to_owned())
334+ }
335+ error => RestoreError::Stale(format!("restoring persisted session failed: {error}")),
336+ }
337+}
338+
339+async fn login_with(
340+ login: PasswordLogin,
341+ sessions: Arc<dyn SessionStore>,
342+) -> Result<Session, String> {
343+ match Session::login_password(login, sessions).await {
344+ Ok(session) => Ok(session),
345+ Err(AccountError::StoreInUse) => Err("another engine is using this session".to_owned()),
346+ Err(AccountError::Unsaved { session, .. }) => session
347+ .save()
348+ .await
349+ .map_err(|_| "could not save the session".to_owned()),
350+ Err(error) => Err(format!(
351+ "login failed: {}",
352+ malice_core::login::describe_error(&error)
353+ )),
354+ }
355+}
356+
357+fn username_matches(submitted: &str, user_id: &ruma::OwnedUserId) -> bool {
358+ let submitted = submitted.trim();
359+ submitted == user_id.as_str() || submitted == user_id.localpart()
360+}
361+
362+/// Wait for the mirror to learn the user id. Fails once the subscription
363+/// ends or the session leaves `Active` before that happens.
364+async fn wait_authenticated(mirror: &MirrorHandle) -> Option<String> {
365+ timeout(AUTH_TIMEOUT, async {
366+ let mut updates = mirror.subscribe();
367+ loop {
368+ let update = updates.next().await?;
369+ match update.change {
370+ StateChange::State(state) => {
371+ if let Some(user_id) = &state.user_id {
372+ return Some(user_id.to_string());
373+ }
374+ if state.session != SessionStatus::Active {
375+ return None;
376+ }
377+ }
378+ StateChange::Authenticated(user_id) => return Some(user_id.to_string()),
379+ StateChange::Session(status) if status != SessionStatus::Active => return None,
380+ _ => {}
381+ }
382+ }
383+ })
384+ .await
385+ .ok()
386+ .flatten()
387+}
388+
389+/// Awaits a session's engine, then its mirror, removes it from the map, and
390+/// applies the retention its `Termination` earns. Taking the engine
391+/// `JoinHandle` is the single point two racing teardowns (this task and an
392+/// explicit logout) arbitrate over: whichever finds it empty does nothing
393+/// further, so the engine and mirror are never aborted twice.
394+async fn supervise(state: AppState, user: Arc<UserSession>) {
395+ let Some(engine_task) = user.engine.lock().await.take() else {
396+ return;
397+ };
398+ let retain = match engine_task.await {
399+ Ok(Ok(termination)) => termination.retain(),
400+ Ok(Err(error)) => {
401+ tracing::warn!(%error, "sync engine ended with an error; keeping its session");
402+ Retain::RecordAndStore
403+ }
404+ Err(error) => {
405+ tracing::warn!(%error, "sync engine task did not finish cleanly; keeping its session");
406+ Retain::RecordAndStore
407+ }
408+ };
409+ if let Some(mirror_task) = user.mirror_task.lock().await.take() {
410+ let _ = mirror_task.await;
411+ }
412+ state.remove_session(&user.sid, retain).await;
413+}
414+
415+fn new_sid() -> String {
416+ let mut bytes = [0u8; 32];
417+ OsRng.fill_bytes(&mut bytes);
418+ hex::encode(bytes)
419+}
420+
421+/// Load the 32-byte master key, creating it with owner-only permissions when
422+/// absent. Secrets must never be world-readable.
423+pub fn load_or_create_master_key(data_dir: &Path) -> Result<[u8; 32], String> {
424+ create_private_dir(data_dir).map_err(|error| format!("creating data dir: {error}"))?;
425+ restrict_dir(data_dir);
426+ let path = data_dir.join("master.key");
427+ if path.exists() {
428+ let bytes = std::fs::read(&path).map_err(|error| format!("reading master key: {error}"))?;
429+ restrict_file(&path);
430+ if bytes.len() != 32 {
431+ return Err("master key has an unexpected length".to_owned());
432+ }
433+ let mut key = [0u8; 32];
434+ key.copy_from_slice(&bytes);
435+ return Ok(key);
436+ }
437+ let mut key = [0u8; 32];
438+ OsRng.fill_bytes(&mut key);
439+ let mut file =
440+ create_private_file(&path).map_err(|error| format!("creating master key: {error}"))?;
441+ if let Err(error) = std::io::Write::write_all(&mut file, &key).and_then(|_| file.sync_all()) {
442+ let _ = std::fs::remove_file(&path);
443+ return Err(format!("writing master key: {error}"));
444+ }
445+ Ok(key)
446+}
447+
448+fn create_private_dir(path: &Path) -> std::io::Result<()> {
449+ let mut builder = std::fs::DirBuilder::new();
450+ builder.recursive(true);
451+ #[cfg(unix)]
452+ {
453+ use std::os::unix::fs::DirBuilderExt;
454+ builder.mode(0o700);
455+ }
456+ builder.create(path)
457+}
458+
459+fn create_private_file(path: &Path) -> std::io::Result<std::fs::File> {
460+ let mut options = std::fs::OpenOptions::new();
461+ options.write(true).create_new(true);
462+ #[cfg(unix)]
463+ {
464+ use std::os::unix::fs::OpenOptionsExt;
465+ options.mode(0o600);
466+ }
467+ options.open(path)
468+}
469+
470+pub fn ensure_dirs(data_dir: &Path) {
471+ for dir in [
472+ data_dir.to_path_buf(),
473+ data_dir.join("sessions"),
474+ data_dir.join("stores"),
475+ ] {
476+ let _ = create_private_dir(&dir);
477+ restrict_dir(&dir);
478+ }
479+}
480+
481+#[cfg(unix)]
482+fn restrict_dir(path: &Path) {
483+ use std::os::unix::fs::PermissionsExt;
484+ let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
485+}
486+
487+#[cfg(not(unix))]
488+fn restrict_dir(_path: &Path) {}
489+
490+#[cfg(unix)]
491+fn restrict_file(path: &Path) {
492+ use std::os::unix::fs::PermissionsExt;
493+ let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
494+}
495+
496+#[cfg(not(unix))]
497+fn restrict_file(_path: &Path) {}
Added file: crates/malice-server/tests/integration.rs+540 additions
@@ -0,0 +1,540 @@
1+use std::{
2+ net::TcpListener,
3+ path::PathBuf,
4+ process::{Child, Command, Stdio},
5+ sync::atomic::{AtomicBool, Ordering},
6+ time::Duration,
7+};
8+
9+use matrix_sdk::test_utils::mocks::MatrixMockServer;
10+use reqwest::{Client, StatusCode, header};
11+use serde_json::{Value, json};
12+use tempfile::TempDir;
13+use wiremock::{
14+ Mock, ResponseTemplate,
15+ matchers::{method, path_regex},
16+};
17+
18+struct Server(Child);
19+
20+impl Drop for Server {
21+ fn drop(&mut self) {
22+ let _ = self.0.kill();
23+ let _ = self.0.wait();
24+ }
25+}
26+
27+fn server_binary() -> PathBuf {
28+ option_env!("CARGO_BIN_EXE_malice")
29+ .map(PathBuf::from)
30+ .unwrap_or_else(|| {
31+ std::env::current_exe()
32+ .expect("integration test executable")
33+ .parent()
34+ .expect("cargo deps directory")
35+ .parent()
36+ .expect("cargo target directory")
37+ .join("malice")
38+ })
39+}
40+
41+async fn start(port: u16, data_dir: &TempDir, client: &Client) -> Server {
42+ let child = Command::new(server_binary())
43+ .env("MALICE_BIND", format!("127.0.0.1:{port}"))
44+ .env("MALICE_DATA_DIR", data_dir.path())
45+ .env("MALICE_SESSION_COOKIE_SECURE", "false")
46+ .env("MALICE_ALLOW_HTTP_HOMESERVER", "true")
47+ .stdout(Stdio::null())
48+ .stderr(Stdio::inherit())
49+ .spawn()
50+ .expect("start Malice backend");
51+ let mut server = Server(child);
52+ tokio::time::timeout(Duration::from_secs(30), async {
53+ loop {
54+ if let Some(status) = server.0.try_wait().expect("poll backend") {
55+ panic!("Malice exited before health check: {status}");
56+ }
57+ if client
58+ .get(format!("http://127.0.0.1:{port}/api/health"))
59+ .send()
60+ .await
61+ .is_ok_and(|response| response.status().is_success())
62+ {
63+ break;
64+ }
65+ tokio::time::sleep(Duration::from_millis(100)).await;
66+ }
67+ })
68+ .await
69+ .expect("Malice did not start");
70+ server
71+}
72+
73+fn unused_port() -> u16 {
74+ TcpListener::bind("127.0.0.1:0")
75+ .expect("bind ephemeral port")
76+ .local_addr()
77+ .expect("read ephemeral port")
78+ .port()
79+}
80+
81+#[tokio::test]
82+async fn login_isolated_cookie_survives_restart_and_logout_revokes_it() {
83+ let matrix = MatrixMockServer::new().await;
84+ matrix.mock_versions().ok().mount().await;
85+ matrix.mock_login().ok().expect(1).mount().await;
86+ let first_room = AtomicBool::new(false);
87+ Mock::given(method("POST"))
88+ .and(path_regex("/sync$"))
89+ .respond_with(move |request: &wiremock::Request| {
90+ let body: Value = serde_json::from_slice(&request.body).unwrap();
91+ let rooms =
92+ if body["conn_id"] == "room-list" && !first_room.swap(true, Ordering::Relaxed) {
93+ json!({"!malice:localhost": {
94+ "initial": true,
95+ "name": "Malice test room",
96+ "joined_count": 1,
97+ "required_state": [{
98+ "type": "m.room.member",
99+ "state_key": "@cheeky_monkey:matrix.org",
100+ "sender": "@cheeky_monkey:matrix.org",
101+ "event_id": "$join:localhost",
102+ "origin_server_ts": 1,
103+ "content": {"membership": "join"}
104+ }],
105+ "timeline": [{
106+ "type": "m.room.message",
107+ "sender": "@cheeky_monkey:matrix.org",
108+ "event_id": "$recent:localhost",
109+ "origin_server_ts": 2,
110+ "content": {"msgtype":"m.text", "body":"recent channel message"}
111+ },{
112+ "type": "m.room.message",
113+ "sender": "@cheeky_monkey:matrix.org",
114+ "event_id": "$image:localhost",
115+ "origin_server_ts": 3,
116+ "content": {
117+ "msgtype": "m.image",
118+ "body": "image.png",
119+ "url": "mxc://localhost/avatar",
120+ "info": {"mimetype": "image/png", "size": 13}
121+ }
122+ }],
123+ "prev_batch": "older-page"
124+ }})
125+ } else {
126+ json!({})
127+ };
128+ ResponseTemplate::new(200)
129+ .set_body_json(json!({"pos":"1","lists":{},"rooms":rooms,"extensions":{}}))
130+ .set_delay(Duration::from_millis(100))
131+ })
132+ .mount(matrix.server())
133+ .await;
134+ Mock::given(method("GET"))
135+ .and(path_regex("/rooms/.*/messages$"))
136+ .respond_with(ResponseTemplate::new(200).set_body_json(json!({
137+ "start": "older-page",
138+ "chunk": [{
139+ "type": "m.room.message",
140+ "room_id": "!malice:localhost",
141+ "sender": "@cheeky_monkey:matrix.org",
142+ "event_id": "$older:localhost",
143+ "origin_server_ts": 1,
144+ "content": {"msgtype":"m.text", "body":"older channel message"}
145+ }]
146+ })))
147+ .mount(matrix.server())
148+ .await;
149+ matrix
150+ .mock_room_state_encryption()
151+ .expect_access_token("abc123")
152+ .plain()
153+ .mount()
154+ .await;
155+ matrix
156+ .mock_room_send()
157+ .expect_access_token("abc123")
158+ .body_matches_partial_json(json!({"body":"backend room smoke"}))
159+ .ok(matrix_sdk::ruma::event_id!("$sent:localhost"))
160+ .expect(1)
161+ .mount()
162+ .await;
163+
164+ Mock::given(method("GET"))
165+ .and(path_regex("/download/[^/]+/avatar$"))
166+ .respond_with(ResponseTemplate::new(200).set_body_bytes(b"\x89PNG\r\n\x1a\nimage".to_vec()))
167+ .mount(matrix.server())
168+ .await;
169+ Mock::given(method("GET"))
170+ .and(path_regex("/download/[^/]+/svg$"))
171+ .respond_with(
172+ ResponseTemplate::new(200).set_body_bytes(b"<svg onload='alert(1)'/>".to_vec()),
173+ )
174+ .mount(matrix.server())
175+ .await;
176+
177+ let data_dir = tempfile::tempdir().expect("temporary user stores");
178+ let client = Client::new();
179+ let port = unused_port();
180+ let base = format!("http://127.0.0.1:{port}");
181+ let server = start(port, &data_dir, &client).await;
182+
183+ let guest: Value = client
184+ .get(format!("{base}/api/session"))
185+ .send()
186+ .await
187+ .unwrap()
188+ .json()
189+ .await
190+ .unwrap();
191+ assert_eq!(guest["user_id"], Value::Null);
192+ assert_eq!(
193+ client
194+ .get(format!("{base}/api/rooms"))
195+ .send()
196+ .await
197+ .unwrap()
198+ .status(),
199+ StatusCode::UNAUTHORIZED
200+ );
201+
202+ let cross_origin = client
203+ .post(format!("{base}/api/login"))
204+ .header(header::ORIGIN, "https://attacker.example")
205+ .json(&json!({
206+ "homeserver": matrix.uri(),
207+ "username": "alice",
208+ "password": "test-password"
209+ }))
210+ .send()
211+ .await
212+ .unwrap();
213+ assert_eq!(cross_origin.status(), StatusCode::FORBIDDEN);
214+
215+ let cross_port = client
216+ .post(format!("{base}/api/login"))
217+ .header(header::ORIGIN, format!("http://127.0.0.1:{}", port + 1))
218+ .json(&json!({
219+ "homeserver": matrix.uri(),
220+ "username": "alice",
221+ "password": "test-password"
222+ }))
223+ .send()
224+ .await
225+ .unwrap();
226+ assert_eq!(cross_port.status(), StatusCode::FORBIDDEN);
227+
228+ let login = client
229+ .post(format!("{base}/api/login"))
230+ .header(header::ORIGIN, &base)
231+ .json(&json!({
232+ "homeserver": matrix.uri(),
233+ "username": "alice",
234+ "password": "test-password"
235+ }))
236+ .send()
237+ .await
238+ .unwrap();
239+ assert!(
240+ login.status().is_success(),
241+ "login returned {}",
242+ login.status()
243+ );
244+ let cookie = login
245+ .headers()
246+ .get(header::SET_COOKIE)
247+ .expect("opaque session cookie")
248+ .to_str()
249+ .unwrap()
250+ .split(';')
251+ .next()
252+ .unwrap()
253+ .to_owned();
254+ let logged_in: Value = login.json().await.unwrap();
255+ assert!(logged_in["user_id"].as_str().is_some());
256+ #[cfg(unix)]
257+ {
258+ use std::os::unix::fs::PermissionsExt;
259+ let sid = cookie.split_once('=').unwrap().1;
260+ let mode = |path: std::path::PathBuf| {
261+ std::fs::metadata(path).unwrap().permissions().mode() & 0o777
262+ };
263+ assert_eq!(mode(data_dir.path().join("master.key")), 0o600);
264+ assert_eq!(mode(data_dir.path().join("sessions")), 0o700);
265+ assert_eq!(
266+ mode(data_dir.path().join("sessions").join(format!("{sid}.bin"))),
267+ 0o600
268+ );
269+ }
270+ assert_eq!(
271+ client
272+ .get(format!("{base}/api/rooms"))
273+ .send()
274+ .await
275+ .unwrap()
276+ .status(),
277+ StatusCode::UNAUTHORIZED,
278+ "one browser must not inherit another browser's Matrix session"
279+ );
280+ assert_eq!(
281+ client
282+ .get(format!("{base}/api/media"))
283+ .query(&[("mxc", "mxc://localhost/avatar")])
284+ .send()
285+ .await
286+ .unwrap()
287+ .status(),
288+ StatusCode::UNAUTHORIZED,
289+ );
290+ assert_eq!(
291+ client
292+ .get(format!("{base}/api/media"))
293+ .header(header::COOKIE, &cookie)
294+ .query(&[("mxc", "not-a-mxc")])
295+ .send()
296+ .await
297+ .unwrap()
298+ .status(),
299+ StatusCode::BAD_REQUEST,
300+ );
301+ let avatar = client
302+ .get(format!("{base}/api/media"))
303+ .header(header::COOKIE, &cookie)
304+ .query(&[("mxc", "mxc://localhost/avatar")])
305+ .send()
306+ .await
307+ .unwrap();
308+ assert_eq!(avatar.status(), StatusCode::OK);
309+ assert_eq!(
310+ avatar.headers().get(header::CONTENT_TYPE).unwrap(),
311+ "image/png"
312+ );
313+ assert_eq!(
314+ avatar.bytes().await.unwrap().as_ref(),
315+ b"\x89PNG\r\n\x1a\nimage"
316+ );
317+ assert_eq!(
318+ client
319+ .get(format!("{base}/api/media"))
320+ .header(header::COOKIE, &cookie)
321+ .query(&[("mxc", "mxc://localhost/svg")])
322+ .send()
323+ .await
324+ .unwrap()
325+ .status(),
326+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
327+ );
328+ let rooms = client
329+ .get(format!("{base}/api/rooms"))
330+ .header(header::COOKIE, &cookie)
331+ .send()
332+ .await
333+ .unwrap();
334+ assert!(rooms.status().is_success());
335+ let rooms: Value = rooms.json().await.unwrap();
336+ assert!(rooms["rooms"].is_array());
337+ tokio::time::timeout(Duration::from_secs(5), async {
338+ loop {
339+ let response: Value = client
340+ .get(format!("{base}/api/rooms"))
341+ .header(header::COOKIE, &cookie)
342+ .send()
343+ .await
344+ .unwrap()
345+ .json()
346+ .await
347+ .unwrap();
348+ if response["rooms"]
349+ .as_array()
350+ .unwrap()
351+ .iter()
352+ .any(|room| room["room_id"] == "!malice:localhost")
353+ {
354+ break;
355+ }
356+ tokio::time::sleep(Duration::from_millis(50)).await;
357+ }
358+ })
359+ .await
360+ .expect("joined room appeared in live room list");
361+ let open = client
362+ .post(format!("{base}/api/rooms/open"))
363+ .header(header::ORIGIN, &base)
364+ .header(header::COOKIE, &cookie)
365+ .json(&json!({"room_id":"!malice:localhost"}))
366+ .send()
367+ .await
368+ .unwrap();
369+ assert_eq!(open.status(), StatusCode::NO_CONTENT);
370+ let media_id = tokio::time::timeout(Duration::from_secs(10), async {
371+ loop {
372+ let mut stream = client
373+ .get(format!("{base}/api/events"))
374+ .header(header::COOKIE, &cookie)
375+ .send()
376+ .await
377+ .unwrap();
378+ let frame = stream.chunk().await.unwrap().expect("SSE snapshot");
379+ let text = std::str::from_utf8(&frame).unwrap();
380+ let payload = text
381+ .lines()
382+ .find_map(|line| line.strip_prefix("data: "))
383+ .unwrap();
384+ let snapshot: Value = serde_json::from_str(payload).unwrap();
385+ if let Some(id) = snapshot["timelines"]["!malice:localhost"]
386+ .as_array()
387+ .and_then(|items| {
388+ items.iter().find_map(|item| {
389+ item["Event"]["content"]["Message"]["media"]["id"].as_str()
390+ })
391+ })
392+ {
393+ break id.to_owned();
394+ }
395+ tokio::time::sleep(Duration::from_millis(50)).await;
396+ }
397+ })
398+ .await
399+ .expect("opened room registered image attachment");
400+ assert_eq!(
401+ client
402+ .get(format!("{base}/api/message-media"))
403+ .query(&[("media_id", media_id.as_str()), ("size", "Full")])
404+ .send()
405+ .await
406+ .unwrap()
407+ .status(),
408+ StatusCode::UNAUTHORIZED,
409+ );
410+ let attachment = client
411+ .get(format!("{base}/api/message-media"))
412+ .header(header::COOKIE, &cookie)
413+ .query(&[("media_id", media_id.as_str()), ("size", "Full")])
414+ .send()
415+ .await
416+ .unwrap();
417+ assert_eq!(attachment.status(), StatusCode::OK);
418+ assert_eq!(
419+ attachment.headers().get(header::CONTENT_TYPE).unwrap(),
420+ "image/png"
421+ );
422+ assert_eq!(
423+ attachment.bytes().await.unwrap().as_ref(),
424+ b"\x89PNG\r\n\x1a\nimage"
425+ );
426+ let sent = client
427+ .post(format!("{base}/api/rooms/send"))
428+ .header(header::ORIGIN, &base)
429+ .header(header::COOKIE, &cookie)
430+ .json(&json!({"room_id":"!malice:localhost", "body":"backend room smoke"}))
431+ .send()
432+ .await
433+ .unwrap();
434+ assert_eq!(sent.status(), StatusCode::NO_CONTENT);
435+ tokio::time::timeout(Duration::from_secs(5), async {
436+ loop {
437+ if matrix
438+ .received_requests()
439+ .await
440+ .unwrap()
441+ .iter()
442+ .any(|request| request.url.path().contains("/send/m.room.message/"))
443+ {
444+ break;
445+ }
446+ tokio::time::sleep(Duration::from_millis(50)).await;
447+ }
448+ })
449+ .await
450+ .expect("message reached Matrix send endpoint");
451+ tokio::time::timeout(Duration::from_secs(15), async {
452+ loop {
453+ let mut stream = client
454+ .get(format!("{base}/api/events"))
455+ .header(header::COOKIE, &cookie)
456+ .send()
457+ .await
458+ .unwrap();
459+ let frame = stream.chunk().await.unwrap().expect("SSE snapshot");
460+ let text = std::str::from_utf8(&frame).unwrap();
461+ let payload = text
462+ .lines()
463+ .find_map(|line| line.strip_prefix("data: "))
464+ .unwrap();
465+ let snapshot: Value = serde_json::from_str(payload).unwrap();
466+ if snapshot["connection"] == "online"
467+ && snapshot["timelines"]["!malice:localhost"]
468+ .as_array()
469+ .is_some_and(|items| {
470+ items.iter().any(|item| {
471+ item["Event"]["content"]["Message"]["body"] == "older channel message"
472+ && item["Event"]["event_id"] == "$older:localhost"
473+ }) && items.iter().any(|item| {
474+ item["Event"]["content"]["Message"]["body"] == "backend room smoke"
475+ && item["Event"]["event_id"] == "$sent:localhost"
476+ && item["Event"]["send_state"] == "Sent"
477+ })
478+ })
479+ {
480+ break;
481+ }
482+ tokio::time::sleep(Duration::from_millis(50)).await;
483+ }
484+ })
485+ .await
486+ .expect("server-confirmed message appeared in online timeline SSE snapshot");
487+
488+ drop(server);
489+ let _restored = start(port, &data_dir, &client).await;
490+ let session: Value = client
491+ .get(format!("{base}/api/session"))
492+ .header(header::COOKIE, &cookie)
493+ .send()
494+ .await
495+ .unwrap()
496+ .json()
497+ .await
498+ .unwrap();
499+ assert_eq!(session["user_id"], logged_in["user_id"]);
500+ let mut events = client
501+ .get(format!("{base}/api/events"))
502+ .header(header::COOKIE, &cookie)
503+ .send()
504+ .await
505+ .unwrap();
506+ assert_eq!(events.status(), StatusCode::OK);
507+ let initial = events.chunk().await.unwrap().expect("initial SSE frame");
508+ assert!(String::from_utf8_lossy(&initial).contains("\"type\":\"snapshot\""));
509+
510+ matrix
511+ .mock_logout()
512+ .expect_access_token("abc123")
513+ .ok()
514+ .expect(1)
515+ .mount()
516+ .await;
517+ let logout = client
518+ .post(format!("{base}/api/logout"))
519+ .header(header::ORIGIN, &base)
520+ .header(header::COOKIE, &cookie)
521+ .send()
522+ .await
523+ .unwrap();
524+ assert_eq!(logout.status(), StatusCode::NO_CONTENT);
525+ tokio::time::timeout(Duration::from_secs(2), async {
526+ while events.chunk().await.unwrap().is_some() {}
527+ })
528+ .await
529+ .expect("logout must close the authenticated event stream");
530+ let after_logout: Value = client
531+ .get(format!("{base}/api/session"))
532+ .header(header::COOKIE, &cookie)
533+ .send()
534+ .await
535+ .unwrap()
536+ .json()
537+ .await
538+ .unwrap();
539+ assert_eq!(after_logout["user_id"], Value::Null);
540+}
Added file: crates/malice-server/tests/static_only.rs+139 additions
@@ -0,0 +1,139 @@
1+use std::{
2+ net::TcpListener,
3+ path::PathBuf,
4+ process::{Child, Command, Stdio},
5+ time::Duration,
6+};
7+
8+use reqwest::{Client, StatusCode, header};
9+use tempfile::TempDir;
10+
11+struct Server(Child);
12+
13+impl Drop for Server {
14+ fn drop(&mut self) {
15+ let _ = self.0.kill();
16+ let _ = self.0.wait();
17+ }
18+}
19+
20+fn server_binary() -> PathBuf {
21+ option_env!("CARGO_BIN_EXE_malice")
22+ .map(PathBuf::from)
23+ .unwrap_or_else(|| {
24+ std::env::current_exe()
25+ .expect("integration test executable")
26+ .parent()
27+ .expect("cargo deps directory")
28+ .parent()
29+ .expect("cargo target directory")
30+ .join("malice")
31+ })
32+}
33+
34+fn unused_port() -> u16 {
35+ TcpListener::bind("127.0.0.1:0")
36+ .expect("bind ephemeral port")
37+ .local_addr()
38+ .expect("read ephemeral port")
39+ .port()
40+}
41+
42+async fn start(port: u16, web_dir: &TempDir, idle_secs: u64, client: &Client) -> Server {
43+ let child = Command::new(server_binary())
44+ .env("MALICE_BIND", format!("127.0.0.1:{port}"))
45+ .env("MALICE_STATIC_ONLY", "1")
46+ .env("MALICE_WEB_DIR", web_dir.path())
47+ .env("MALICE_IDLE_EXIT_SECS", idle_secs.to_string())
48+ .stdout(Stdio::null())
49+ .stderr(Stdio::inherit())
50+ .spawn()
51+ .expect("start Malice backend");
52+ let mut server = Server(child);
53+ tokio::time::timeout(Duration::from_secs(30), async {
54+ loop {
55+ if let Some(status) = server.0.try_wait().expect("poll backend") {
56+ panic!("Malice exited before it started serving: {status}");
57+ }
58+ if client
59+ .get(format!("http://127.0.0.1:{port}/"))
60+ .send()
61+ .await
62+ .is_ok_and(|response| response.status().is_success())
63+ {
64+ break;
65+ }
66+ tokio::time::sleep(Duration::from_millis(100)).await;
67+ }
68+ })
69+ .await
70+ .expect("Malice did not start");
71+ server
72+}
73+
74+#[tokio::test]
75+async fn static_only_serves_the_bundle_and_exits_when_idle() {
76+ let web_dir = tempfile::tempdir().expect("temporary web dir");
77+ std::fs::write(web_dir.path().join("index.html"), "<html>malice</html>")
78+ .expect("write index.html");
79+ std::fs::write(web_dir.path().join("sw.js"), "// service worker").expect("write sw.js");
80+ std::fs::write(web_dir.path().join("style.css"), "body {}").expect("write style.css");
81+
82+ let client = Client::new();
83+ let port = unused_port();
84+ let base = format!("http://127.0.0.1:{port}");
85+ let mut server = start(port, &web_dir, 1, &client).await;
86+
87+ let index = client.get(&base).send().await.unwrap();
88+ assert_eq!(index.status(), StatusCode::OK);
89+ assert_eq!(
90+ index.headers().get(header::CACHE_CONTROL).unwrap(),
91+ "no-cache"
92+ );
93+ assert_eq!(index.headers().get("service-worker-allowed").unwrap(), "/");
94+ assert_eq!(index.text().await.unwrap(), "<html>malice</html>");
95+
96+ let sw = client.get(format!("{base}/sw.js")).send().await.unwrap();
97+ assert_eq!(sw.status(), StatusCode::OK);
98+ assert_eq!(sw.headers().get(header::CACHE_CONTROL).unwrap(), "no-cache");
99+ assert_eq!(sw.headers().get("service-worker-allowed").unwrap(), "/");
100+
101+ let asset = client
102+ .get(format!("{base}/style.css"))
103+ .send()
104+ .await
105+ .unwrap();
106+ assert_eq!(asset.status(), StatusCode::OK);
107+ assert!(asset.headers().get(header::CACHE_CONTROL).is_none());
108+
109+ assert_eq!(
110+ client
111+ .get(format!("{base}/api/rooms"))
112+ .send()
113+ .await
114+ .unwrap()
115+ .status(),
116+ StatusCode::NOT_FOUND,
117+ );
118+ assert_eq!(
119+ client
120+ .get(format!("{base}/api/"))
121+ .send()
122+ .await
123+ .unwrap()
124+ .status(),
125+ StatusCode::NOT_FOUND,
126+ );
127+
128+ let status = tokio::time::timeout(Duration::from_secs(10), async {
129+ loop {
130+ if let Some(status) = server.0.try_wait().expect("poll backend") {
131+ return status;
132+ }
133+ tokio::time::sleep(Duration::from_millis(50)).await;
134+ }
135+ })
136+ .await
137+ .expect("Malice did not exit once idle");
138+ assert!(status.success(), "idle exit should be clean: {status}");
139+}
Added file: crates/malice-worker/.cargo/config.toml+3 additions
@@ -0,0 +1,3 @@
1+[target.wasm32-unknown-unknown]
2+runner = "wasm-bindgen-test-runner"
3+rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]
Added file: crates/malice-worker/Cargo.lock+4532 additions
@@ -0,0 +1,4532 @@
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.1.0",
12+ "proc-macro2",
13+ "quote",
14+ "syn 3.0.6",
15+]
16+
17+[[package]]
18+name = "adler2"
19+version = "2.0.1"
20+source = "registry+https://github.com/rust-lang/crates.io-index"
21+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
22+
23+[[package]]
24+name = "aead"
25+version = "0.5.2"
26+source = "registry+https://github.com/rust-lang/crates.io-index"
27+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
28+dependencies = [
29+ "crypto-common",
30+ "generic-array",
31+]
32+
33+[[package]]
34+name = "aes"
35+version = "0.8.4"
36+source = "registry+https://github.com/rust-lang/crates.io-index"
37+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
38+dependencies = [
39+ "cfg-if",
40+ "cipher",
41+ "cpufeatures 0.2.17",
42+]
43+
44+[[package]]
45+name = "aho-corasick"
46+version = "1.1.5"
47+source = "registry+https://github.com/rust-lang/crates.io-index"
48+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
49+dependencies = [
50+ "memchr",
51+]
52+
53+[[package]]
54+name = "android_system_properties"
55+version = "0.1.6"
56+source = "registry+https://github.com/rust-lang/crates.io-index"
57+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
58+dependencies = [
59+ "libc",
60+]
61+
62+[[package]]
63+name = "anyhow"
64+version = "1.0.104"
65+source = "registry+https://github.com/rust-lang/crates.io-index"
66+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
67+
68+[[package]]
69+name = "anymap2"
70+version = "0.13.0"
71+source = "registry+https://github.com/rust-lang/crates.io-index"
72+checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
73+
74+[[package]]
75+name = "aquamarine"
76+version = "0.6.0"
77+source = "registry+https://github.com/rust-lang/crates.io-index"
78+checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2"
79+dependencies = [
80+ "include_dir",
81+ "itertools 0.10.5",
82+ "proc-macro-error2",
83+ "proc-macro2",
84+ "quote",
85+ "syn 2.0.119",
86+]
87+
88+[[package]]
89+name = "archery"
90+version = "1.2.3"
91+source = "registry+https://github.com/rust-lang/crates.io-index"
92+checksum = "33ca55ee147b1926dbea904f50fe4902494e97bc742205abbbf10c709e43815f"
93+
94+[[package]]
95+name = "arrayvec"
96+version = "0.7.8"
97+source = "registry+https://github.com/rust-lang/crates.io-index"
98+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
99+dependencies = [
100+ "serde",
101+]
102+
103+[[package]]
104+name = "as_variant"
105+version = "1.3.0"
106+source = "registry+https://github.com/rust-lang/crates.io-index"
107+checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a"
108+
109+[[package]]
110+name = "assign"
111+version = "1.1.1"
112+source = "registry+https://github.com/rust-lang/crates.io-index"
113+checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002"
114+
115+[[package]]
116+name = "async-channel"
117+version = "2.5.0"
118+source = "registry+https://github.com/rust-lang/crates.io-index"
119+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
120+dependencies = [
121+ "concurrent-queue",
122+ "event-listener-strategy",
123+ "futures-core",
124+ "pin-project-lite",
125+]
126+
127+[[package]]
128+name = "async-compression"
129+version = "0.4.48"
130+source = "registry+https://github.com/rust-lang/crates.io-index"
131+checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
132+dependencies = [
133+ "compression-codecs",
134+ "compression-core",
135+ "pin-project-lite",
136+ "tokio",
137+]
138+
139+[[package]]
140+name = "async-once-cell"
141+version = "0.5.4"
142+source = "registry+https://github.com/rust-lang/crates.io-index"
143+checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"
144+
145+[[package]]
146+name = "async-rx"
147+version = "0.2.1"
148+source = "registry+https://github.com/rust-lang/crates.io-index"
149+checksum = "a82c1a761cd7b772912fd2500f2c0a850c0c72f5f66543038393ac6cfaa3659a"
150+dependencies = [
151+ "futures-core",
152+ "pin-project-lite",
153+]
154+
155+[[package]]
156+name = "async-stream"
157+version = "0.3.6"
158+source = "registry+https://github.com/rust-lang/crates.io-index"
159+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
160+dependencies = [
161+ "async-stream-impl",
162+ "futures-core",
163+ "pin-project-lite",
164+]
165+
166+[[package]]
167+name = "async-stream-impl"
168+version = "0.3.6"
169+source = "registry+https://github.com/rust-lang/crates.io-index"
170+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
171+dependencies = [
172+ "proc-macro2",
173+ "quote",
174+ "syn 2.0.119",
175+]
176+
177+[[package]]
178+name = "async-trait"
179+version = "0.1.92"
180+source = "registry+https://github.com/rust-lang/crates.io-index"
181+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
182+dependencies = [
183+ "proc-macro2",
184+ "quote",
185+ "syn 3.0.6",
186+]
187+
188+[[package]]
189+name = "async_cell"
190+version = "0.2.3"
191+source = "registry+https://github.com/rust-lang/crates.io-index"
192+checksum = "447ab28afbb345f5408b120702a44e5529ebf90b1796ec76e9528df8e288e6c2"
193+dependencies = [
194+ "loom",
195+]
196+
197+[[package]]
198+name = "atomic-waker"
199+version = "1.1.2"
200+source = "registry+https://github.com/rust-lang/crates.io-index"
201+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
202+
203+[[package]]
204+name = "autocfg"
205+version = "1.5.1"
206+source = "registry+https://github.com/rust-lang/crates.io-index"
207+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
208+
209+[[package]]
210+name = "aws-lc-rs"
211+version = "1.18.1"
212+source = "registry+https://github.com/rust-lang/crates.io-index"
213+checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
214+dependencies = [
215+ "aws-lc-sys",
216+ "zeroize",
217+]
218+
219+[[package]]
220+name = "aws-lc-sys"
221+version = "0.45.0"
222+source = "registry+https://github.com/rust-lang/crates.io-index"
223+checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
224+dependencies = [
225+ "cc",
226+ "cmake",
227+ "dunce",
228+ "fs_extra",
229+ "pkg-config",
230+]
231+
232+[[package]]
233+name = "backon"
234+version = "1.6.0"
235+source = "registry+https://github.com/rust-lang/crates.io-index"
236+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
237+dependencies = [
238+ "fastrand",
239+ "gloo-timers",
240+ "tokio",
241+]
242+
243+[[package]]
244+name = "base64"
245+version = "0.22.1"
246+source = "registry+https://github.com/rust-lang/crates.io-index"
247+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
248+
249+[[package]]
250+name = "base64"
251+version = "0.23.1"
252+source = "registry+https://github.com/rust-lang/crates.io-index"
253+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
254+
255+[[package]]
256+name = "base64ct"
257+version = "1.8.3"
258+source = "registry+https://github.com/rust-lang/crates.io-index"
259+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
260+
261+[[package]]
262+name = "bitflags"
263+version = "2.13.2"
264+source = "registry+https://github.com/rust-lang/crates.io-index"
265+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
266+dependencies = [
267+ "serde_core",
268+]
269+
270+[[package]]
271+name = "bitmaps"
272+version = "3.2.1"
273+source = "registry+https://github.com/rust-lang/crates.io-index"
274+checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"
275+
276+[[package]]
277+name = "blake3"
278+version = "1.8.7"
279+source = "registry+https://github.com/rust-lang/crates.io-index"
280+checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
281+dependencies = [
282+ "arrayvec",
283+ "cc",
284+ "cfg-if",
285+ "constant_time_eq",
286+ "cpufeatures 0.3.1",
287+]
288+
289+[[package]]
290+name = "block-buffer"
291+version = "0.10.4"
292+source = "registry+https://github.com/rust-lang/crates.io-index"
293+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
294+dependencies = [
295+ "generic-array",
296+]
297+
298+[[package]]
299+name = "block-padding"
300+version = "0.3.3"
301+source = "registry+https://github.com/rust-lang/crates.io-index"
302+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
303+dependencies = [
304+ "generic-array",
305+]
306+
307+[[package]]
308+name = "bs58"
309+version = "0.5.1"
310+source = "registry+https://github.com/rust-lang/crates.io-index"
311+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
312+dependencies = [
313+ "tinyvec",
314+]
315+
316+[[package]]
317+name = "bumpalo"
318+version = "3.20.3"
319+source = "registry+https://github.com/rust-lang/crates.io-index"
320+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
321+
322+[[package]]
323+name = "byteorder"
324+version = "1.5.0"
325+source = "registry+https://github.com/rust-lang/crates.io-index"
326+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
327+
328+[[package]]
329+name = "bytes"
330+version = "1.12.1"
331+source = "registry+https://github.com/rust-lang/crates.io-index"
332+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
333+
334+[[package]]
335+name = "bytesize"
336+version = "2.7.0"
337+source = "registry+https://github.com/rust-lang/crates.io-index"
338+checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b"
339+
340+[[package]]
341+name = "cast"
342+version = "0.3.0"
343+source = "registry+https://github.com/rust-lang/crates.io-index"
344+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
345+
346+[[package]]
347+name = "cbc"
348+version = "0.1.2"
349+source = "registry+https://github.com/rust-lang/crates.io-index"
350+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
351+dependencies = [
352+ "cipher",
353+]
354+
355+[[package]]
356+name = "cc"
357+version = "1.5.1"
358+source = "registry+https://github.com/rust-lang/crates.io-index"
359+checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
360+dependencies = [
361+ "find-msvc-tools",
362+ "jobserver",
363+ "libc",
364+ "shlex",
365+]
366+
367+[[package]]
368+name = "cfg-if"
369+version = "1.0.5"
370+source = "registry+https://github.com/rust-lang/crates.io-index"
371+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
372+
373+[[package]]
374+name = "cfg_aliases"
375+version = "0.2.2"
376+source = "registry+https://github.com/rust-lang/crates.io-index"
377+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
378+
379+[[package]]
380+name = "chacha20"
381+version = "0.9.1"
382+source = "registry+https://github.com/rust-lang/crates.io-index"
383+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
384+dependencies = [
385+ "cfg-if",
386+ "cipher",
387+ "cpufeatures 0.2.17",
388+]
389+
390+[[package]]
391+name = "chacha20"
392+version = "0.10.2"
393+source = "registry+https://github.com/rust-lang/crates.io-index"
394+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
395+dependencies = [
396+ "cfg-if",
397+ "cpufeatures 0.3.1",
398+ "rand_core 0.10.1",
399+]
400+
401+[[package]]
402+name = "chacha20poly1305"
403+version = "0.10.1"
404+source = "registry+https://github.com/rust-lang/crates.io-index"
405+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
406+dependencies = [
407+ "aead",
408+ "chacha20 0.9.1",
409+ "cipher",
410+ "poly1305",
411+ "zeroize",
412+]
413+
414+[[package]]
415+name = "chrono"
416+version = "0.4.45"
417+source = "registry+https://github.com/rust-lang/crates.io-index"
418+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
419+dependencies = [
420+ "iana-time-zone",
421+ "js-sys",
422+ "num-traits",
423+ "serde",
424+ "wasm-bindgen",
425+ "windows-link",
426+]
427+
428+[[package]]
429+name = "cipher"
430+version = "0.4.4"
431+source = "registry+https://github.com/rust-lang/crates.io-index"
432+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
433+dependencies = [
434+ "crypto-common",
435+ "inout",
436+ "zeroize",
437+]
438+
439+[[package]]
440+name = "cmake"
441+version = "0.1.58"
442+source = "registry+https://github.com/rust-lang/crates.io-index"
443+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
444+dependencies = [
445+ "cc",
446+]
447+
448+[[package]]
449+name = "combine"
450+version = "4.6.8"
451+source = "registry+https://github.com/rust-lang/crates.io-index"
452+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
453+dependencies = [
454+ "bytes",
455+ "memchr",
456+]
457+
458+[[package]]
459+name = "compression-codecs"
460+version = "0.4.43"
461+source = "registry+https://github.com/rust-lang/crates.io-index"
462+checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
463+dependencies = [
464+ "compression-core",
465+ "flate2",
466+ "memchr",
467+]
468+
469+[[package]]
470+name = "compression-core"
471+version = "0.4.33"
472+source = "registry+https://github.com/rust-lang/crates.io-index"
473+checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
474+
475+[[package]]
476+name = "concurrent-queue"
477+version = "2.5.0"
478+source = "registry+https://github.com/rust-lang/crates.io-index"
479+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
480+dependencies = [
481+ "crossbeam-utils",
482+]
483+
484+[[package]]
485+name = "const-oid"
486+version = "0.9.6"
487+source = "registry+https://github.com/rust-lang/crates.io-index"
488+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
489+
490+[[package]]
491+name = "const_panic"
492+version = "0.2.17"
493+source = "registry+https://github.com/rust-lang/crates.io-index"
494+checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3"
495+dependencies = [
496+ "typewit",
497+]
498+
499+[[package]]
500+name = "constant_time_eq"
501+version = "0.4.2"
502+source = "registry+https://github.com/rust-lang/crates.io-index"
503+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
504+
505+[[package]]
506+name = "core-foundation"
507+version = "0.10.1"
508+source = "registry+https://github.com/rust-lang/crates.io-index"
509+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
510+dependencies = [
511+ "core-foundation-sys",
512+ "libc",
513+]
514+
515+[[package]]
516+name = "core-foundation-sys"
517+version = "0.8.7"
518+source = "registry+https://github.com/rust-lang/crates.io-index"
519+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
520+
521+[[package]]
522+name = "cpufeatures"
523+version = "0.2.17"
524+source = "registry+https://github.com/rust-lang/crates.io-index"
525+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
526+dependencies = [
527+ "libc",
528+]
529+
530+[[package]]
531+name = "cpufeatures"
532+version = "0.3.1"
533+source = "registry+https://github.com/rust-lang/crates.io-index"
534+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
535+dependencies = [
536+ "libc",
537+]
538+
539+[[package]]
540+name = "crc32fast"
541+version = "1.5.2"
542+source = "registry+https://github.com/rust-lang/crates.io-index"
543+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
544+dependencies = [
545+ "cfg-if",
546+]
547+
548+[[package]]
549+name = "crossbeam-utils"
550+version = "0.8.23"
551+source = "registry+https://github.com/rust-lang/crates.io-index"
552+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
553+
554+[[package]]
555+name = "crypto-common"
556+version = "0.1.7"
557+source = "registry+https://github.com/rust-lang/crates.io-index"
558+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
559+dependencies = [
560+ "generic-array",
561+ "rand_core 0.6.4",
562+ "typenum",
563+]
564+
565+[[package]]
566+name = "ctr"
567+version = "0.9.2"
568+source = "registry+https://github.com/rust-lang/crates.io-index"
569+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
570+dependencies = [
571+ "cipher",
572+]
573+
574+[[package]]
575+name = "curve25519-dalek"
576+version = "4.1.3"
577+source = "registry+https://github.com/rust-lang/crates.io-index"
578+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
579+dependencies = [
580+ "cfg-if",
581+ "cpufeatures 0.2.17",
582+ "curve25519-dalek-derive",
583+ "digest",
584+ "fiat-crypto",
585+ "rustc_version",
586+ "serde",
587+ "subtle",
588+ "zeroize",
589+]
590+
591+[[package]]
592+name = "curve25519-dalek-derive"
593+version = "0.1.1"
594+source = "registry+https://github.com/rust-lang/crates.io-index"
595+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
596+dependencies = [
597+ "proc-macro2",
598+ "quote",
599+ "syn 2.0.119",
600+]
601+
602+[[package]]
603+name = "date_header"
604+version = "1.0.5"
605+source = "registry+https://github.com/rust-lang/crates.io-index"
606+checksum = "0c03c416ed1a30fbb027ef484ba6ab6f80e1eada675e1a2b92fd673c045a1f1d"
607+
608+[[package]]
609+name = "deadpool"
610+version = "0.13.1"
611+source = "registry+https://github.com/rust-lang/crates.io-index"
612+checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e"
613+dependencies = [
614+ "deadpool-runtime",
615+ "tokio",
616+]
617+
618+[[package]]
619+name = "deadpool-runtime"
620+version = "0.3.1"
621+source = "registry+https://github.com/rust-lang/crates.io-index"
622+checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae"
623+dependencies = [
624+ "tokio",
625+]
626+
627+[[package]]
628+name = "deadpool-sync"
629+version = "0.2.0"
630+source = "registry+https://github.com/rust-lang/crates.io-index"
631+checksum = "e385cc95d3d582c328b36d1ff90feac061102b001894b555e6b465a2e0eaabbf"
632+dependencies = [
633+ "deadpool-runtime",
634+]
635+
636+[[package]]
637+name = "decancer"
638+version = "3.3.3"
639+source = "registry+https://github.com/rust-lang/crates.io-index"
640+checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"
641+
642+[[package]]
643+name = "delegate-display"
644+version = "3.0.1"
645+source = "registry+https://github.com/rust-lang/crates.io-index"
646+checksum = "54e3a499943fd5180aeffcb708164ffb22125ceeccb05491b5297981d37d6249"
647+dependencies = [
648+ "impartial-ord",
649+ "itoa",
650+ "macroific 3.1.0",
651+ "proc-macro2",
652+ "quote",
653+ "syn 3.0.6",
654+]
655+
656+[[package]]
657+name = "der"
658+version = "0.7.10"
659+source = "registry+https://github.com/rust-lang/crates.io-index"
660+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
661+dependencies = [
662+ "const-oid",
663+ "zeroize",
664+]
665+
666+[[package]]
667+name = "deranged"
668+version = "0.5.8"
669+source = "registry+https://github.com/rust-lang/crates.io-index"
670+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
671+
672+[[package]]
673+name = "derivative"
674+version = "2.2.0"
675+source = "registry+https://github.com/rust-lang/crates.io-index"
676+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
677+dependencies = [
678+ "proc-macro2",
679+ "quote",
680+ "syn 1.0.109",
681+]
682+
683+[[package]]
684+name = "derive_more"
685+version = "1.0.0"
686+source = "registry+https://github.com/rust-lang/crates.io-index"
687+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
688+dependencies = [
689+ "derive_more-impl 1.0.0",
690+]
691+
692+[[package]]
693+name = "derive_more"
694+version = "2.1.1"
695+source = "registry+https://github.com/rust-lang/crates.io-index"
696+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
697+dependencies = [
698+ "derive_more-impl 2.1.1",
699+]
700+
701+[[package]]
702+name = "derive_more-impl"
703+version = "1.0.0"
704+source = "registry+https://github.com/rust-lang/crates.io-index"
705+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
706+dependencies = [
707+ "proc-macro2",
708+ "quote",
709+ "syn 2.0.119",
710+]
711+
712+[[package]]
713+name = "derive_more-impl"
714+version = "2.1.1"
715+source = "registry+https://github.com/rust-lang/crates.io-index"
716+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
717+dependencies = [
718+ "proc-macro2",
719+ "quote",
720+ "rustc_version",
721+ "syn 2.0.119",
722+ "unicode-xid",
723+]
724+
725+[[package]]
726+name = "digest"
727+version = "0.10.7"
728+source = "registry+https://github.com/rust-lang/crates.io-index"
729+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
730+dependencies = [
731+ "block-buffer",
732+ "crypto-common",
733+ "subtle",
734+]
735+
736+[[package]]
737+name = "displaydoc"
738+version = "0.2.7"
739+source = "registry+https://github.com/rust-lang/crates.io-index"
740+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
741+dependencies = [
742+ "proc-macro2",
743+ "quote",
744+ "syn 3.0.6",
745+]
746+
747+[[package]]
748+name = "dunce"
749+version = "1.0.5"
750+source = "registry+https://github.com/rust-lang/crates.io-index"
751+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
752+
753+[[package]]
754+name = "ed25519"
755+version = "2.2.3"
756+source = "registry+https://github.com/rust-lang/crates.io-index"
757+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
758+dependencies = [
759+ "pkcs8",
760+ "serde",
761+ "signature",
762+]
763+
764+[[package]]
765+name = "ed25519-dalek"
766+version = "2.2.0"
767+source = "registry+https://github.com/rust-lang/crates.io-index"
768+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
769+dependencies = [
770+ "curve25519-dalek",
771+ "ed25519",
772+ "rand_core 0.6.4",
773+ "serde",
774+ "sha2",
775+ "subtle",
776+ "zeroize",
777+]
778+
779+[[package]]
780+name = "either"
781+version = "1.18.0"
782+source = "registry+https://github.com/rust-lang/crates.io-index"
783+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
784+
785+[[package]]
786+name = "emojis"
787+version = "0.8.2"
788+source = "registry+https://github.com/rust-lang/crates.io-index"
789+checksum = "0a4d5d50b0b58df5173d8ff1192b4d1422ceae5d981b30d4b6f8ed1d673a2bc4"
790+dependencies = [
791+ "phf",
792+]
793+
794+[[package]]
795+name = "equivalent"
796+version = "1.0.2"
797+source = "registry+https://github.com/rust-lang/crates.io-index"
798+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
799+
800+[[package]]
801+name = "errno"
802+version = "0.3.14"
803+source = "registry+https://github.com/rust-lang/crates.io-index"
804+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
805+dependencies = [
806+ "libc",
807+ "windows-sys 0.61.2",
808+]
809+
810+[[package]]
811+name = "event-listener"
812+version = "5.4.2"
813+source = "registry+https://github.com/rust-lang/crates.io-index"
814+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
815+dependencies = [
816+ "parking",
817+ "pin-project-lite",
818+]
819+
820+[[package]]
821+name = "event-listener-strategy"
822+version = "0.5.4"
823+source = "registry+https://github.com/rust-lang/crates.io-index"
824+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
825+dependencies = [
826+ "event-listener",
827+ "pin-project-lite",
828+]
829+
830+[[package]]
831+name = "eyeball"
832+version = "0.8.8"
833+source = "registry+https://github.com/rust-lang/crates.io-index"
834+checksum = "d93bd0ebf93d61d6332d3c09a96e97975968a44e19a64c947bde06e6baff383f"
835+dependencies = [
836+ "futures-core",
837+ "readlock",
838+ "readlock-tokio",
839+ "tokio",
840+ "tokio-util",
841+ "tracing",
842+]
843+
844+[[package]]
845+name = "eyeball-im"
846+version = "0.8.0"
847+source = "registry+https://github.com/rust-lang/crates.io-index"
848+checksum = "4790c03df183c2b46665c1a58118c04fd3e3976ec2fe16a0aa00e00c9eea7754"
849+dependencies = [
850+ "futures-core",
851+ "imbl",
852+ "tokio",
853+ "tracing",
854+]
855+
856+[[package]]
857+name = "eyeball-im-util"
858+version = "0.10.0"
859+source = "registry+https://github.com/rust-lang/crates.io-index"
860+checksum = "809fa2e8e59c25a41f5b2440aa937f1e84df6ae48b707e65f69ce259b1c44bda"
861+dependencies = [
862+ "arrayvec",
863+ "eyeball-im",
864+ "futures-core",
865+ "imbl",
866+ "pin-project-lite",
867+ "smallvec",
868+]
869+
870+[[package]]
871+name = "fallible-iterator"
872+version = "0.3.0"
873+source = "registry+https://github.com/rust-lang/crates.io-index"
874+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
875+
876+[[package]]
877+name = "fallible-streaming-iterator"
878+version = "0.1.9"
879+source = "registry+https://github.com/rust-lang/crates.io-index"
880+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
881+
882+[[package]]
883+name = "fancy_constructor"
884+version = "2.1.1"
885+source = "registry+https://github.com/rust-lang/crates.io-index"
886+checksum = "4a8211ab12c36b63c269e17873331dc1da196a3edb7a5c79015677ef972da34a"
887+dependencies = [
888+ "macroific 3.1.0",
889+ "proc-macro2",
890+ "quote",
891+ "syn 3.0.6",
892+]
893+
894+[[package]]
895+name = "fastrand"
896+version = "2.5.0"
897+source = "registry+https://github.com/rust-lang/crates.io-index"
898+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
899+
900+[[package]]
901+name = "fiat-crypto"
902+version = "0.2.9"
903+source = "registry+https://github.com/rust-lang/crates.io-index"
904+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
905+
906+[[package]]
907+name = "find-msvc-tools"
908+version = "0.1.14"
909+source = "registry+https://github.com/rust-lang/crates.io-index"
910+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
911+
912+[[package]]
913+name = "flate2"
914+version = "1.1.10"
915+source = "registry+https://github.com/rust-lang/crates.io-index"
916+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
917+dependencies = [
918+ "crc32fast",
919+ "miniz_oxide",
920+ "zlib-rs",
921+]
922+
923+[[package]]
924+name = "fnv"
925+version = "1.0.7"
926+source = "registry+https://github.com/rust-lang/crates.io-index"
927+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
928+
929+[[package]]
930+name = "foldhash"
931+version = "0.1.5"
932+source = "registry+https://github.com/rust-lang/crates.io-index"
933+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
934+
935+[[package]]
936+name = "form_urlencoded"
937+version = "1.2.2"
938+source = "registry+https://github.com/rust-lang/crates.io-index"
939+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
940+dependencies = [
941+ "percent-encoding",
942+]
943+
944+[[package]]
945+name = "fs_extra"
946+version = "1.3.0"
947+source = "registry+https://github.com/rust-lang/crates.io-index"
948+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
949+
950+[[package]]
951+name = "futures-channel"
952+version = "0.3.34"
953+source = "registry+https://github.com/rust-lang/crates.io-index"
954+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
955+dependencies = [
956+ "futures-core",
957+]
958+
959+[[package]]
960+name = "futures-core"
961+version = "0.3.34"
962+source = "registry+https://github.com/rust-lang/crates.io-index"
963+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
964+
965+[[package]]
966+name = "futures-executor"
967+version = "0.3.34"
968+source = "registry+https://github.com/rust-lang/crates.io-index"
969+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
970+dependencies = [
971+ "futures-core",
972+ "futures-task",
973+ "futures-util",
974+]
975+
976+[[package]]
977+name = "futures-io"
978+version = "0.3.34"
979+source = "registry+https://github.com/rust-lang/crates.io-index"
980+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
981+
982+[[package]]
983+name = "futures-macro"
984+version = "0.3.34"
985+source = "registry+https://github.com/rust-lang/crates.io-index"
986+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
987+dependencies = [
988+ "proc-macro2",
989+ "quote",
990+ "syn 3.0.6",
991+]
992+
993+[[package]]
994+name = "futures-sink"
995+version = "0.3.34"
996+source = "registry+https://github.com/rust-lang/crates.io-index"
997+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
998+
999+[[package]]
1000+name = "futures-task"
1001+version = "0.3.34"
1002+source = "registry+https://github.com/rust-lang/crates.io-index"
1003+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
1004+
1005+[[package]]
1006+name = "futures-util"
1007+version = "0.3.34"
1008+source = "registry+https://github.com/rust-lang/crates.io-index"
1009+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
1010+dependencies = [
1011+ "futures-channel",
1012+ "futures-core",
1013+ "futures-io",
1014+ "futures-macro",
1015+ "futures-sink",
1016+ "futures-task",
1017+ "memchr",
1018+ "pin-project-lite",
1019+ "slab",
1020+]
1021+
1022+[[package]]
1023+name = "fuzzy-matcher"
1024+version = "0.3.7"
1025+source = "registry+https://github.com/rust-lang/crates.io-index"
1026+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
1027+dependencies = [
1028+ "thread_local",
1029+]
1030+
1031+[[package]]
1032+name = "generator"
1033+version = "0.8.10"
1034+source = "registry+https://github.com/rust-lang/crates.io-index"
1035+checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551"
1036+dependencies = [
1037+ "cc",
1038+ "cfg-if",
1039+ "libc",
1040+ "log",
1041+ "rustversion",
1042+ "windows-link",
1043+ "windows-result",
1044+]
1045+
1046+[[package]]
1047+name = "generic-array"
1048+version = "0.14.7"
1049+source = "registry+https://github.com/rust-lang/crates.io-index"
1050+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1051+dependencies = [
1052+ "typenum",
1053+ "version_check",
1054+]
1055+
1056+[[package]]
1057+name = "getrandom"
1058+version = "0.2.17"
1059+source = "registry+https://github.com/rust-lang/crates.io-index"
1060+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
1061+dependencies = [
1062+ "cfg-if",
1063+ "js-sys",
1064+ "libc",
1065+ "wasi",
1066+ "wasm-bindgen",
1067+]
1068+
1069+[[package]]
1070+name = "getrandom"
1071+version = "0.3.4"
1072+source = "registry+https://github.com/rust-lang/crates.io-index"
1073+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1074+dependencies = [
1075+ "cfg-if",
1076+ "js-sys",
1077+ "libc",
1078+ "r-efi 5.3.0",
1079+ "wasip2",
1080+ "wasm-bindgen",
1081+]
1082+
1083+[[package]]
1084+name = "getrandom"
1085+version = "0.4.3"
1086+source = "registry+https://github.com/rust-lang/crates.io-index"
1087+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
1088+dependencies = [
1089+ "cfg-if",
1090+ "js-sys",
1091+ "libc",
1092+ "r-efi 6.0.0",
1093+ "rand_core 0.10.1",
1094+ "wasm-bindgen",
1095+]
1096+
1097+[[package]]
1098+name = "gloo-timers"
1099+version = "0.3.0"
1100+source = "registry+https://github.com/rust-lang/crates.io-index"
1101+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
1102+dependencies = [
1103+ "futures-channel",
1104+ "futures-core",
1105+ "js-sys",
1106+ "wasm-bindgen",
1107+]
1108+
1109+[[package]]
1110+name = "gloo-utils"
1111+version = "0.2.0"
1112+source = "registry+https://github.com/rust-lang/crates.io-index"
1113+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
1114+dependencies = [
1115+ "js-sys",
1116+ "serde",
1117+ "serde_json",
1118+ "wasm-bindgen",
1119+ "web-sys",
1120+]
1121+
1122+[[package]]
1123+name = "growable-bloom-filter"
1124+version = "2.1.1"
1125+source = "registry+https://github.com/rust-lang/crates.io-index"
1126+checksum = "d174ccb4ba660d431329e7f0797870d0a4281e36353ec4b4a3c5eab6c2cfb6f1"
1127+dependencies = [
1128+ "serde",
1129+ "serde_bytes",
1130+ "serde_derive",
1131+ "xxhash-rust",
1132+]
1133+
1134+[[package]]
1135+name = "h2"
1136+version = "0.4.19"
1137+source = "registry+https://github.com/rust-lang/crates.io-index"
1138+checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
1139+dependencies = [
1140+ "atomic-waker",
1141+ "bytes",
1142+ "fnv",
1143+ "futures-core",
1144+ "futures-sink",
1145+ "http",
1146+ "indexmap",
1147+ "slab",
1148+ "tokio",
1149+ "tokio-util",
1150+ "tracing",
1151+]
1152+
1153+[[package]]
1154+name = "hashbrown"
1155+version = "0.15.5"
1156+source = "registry+https://github.com/rust-lang/crates.io-index"
1157+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
1158+dependencies = [
1159+ "foldhash",
1160+]
1161+
1162+[[package]]
1163+name = "hashbrown"
1164+version = "0.17.1"
1165+source = "registry+https://github.com/rust-lang/crates.io-index"
1166+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
1167+
1168+[[package]]
1169+name = "hashlink"
1170+version = "0.10.0"
1171+source = "registry+https://github.com/rust-lang/crates.io-index"
1172+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
1173+dependencies = [
1174+ "hashbrown 0.15.5",
1175+]
1176+
1177+[[package]]
1178+name = "hermit-abi"
1179+version = "0.5.3"
1180+source = "registry+https://github.com/rust-lang/crates.io-index"
1181+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
1182+
1183+[[package]]
1184+name = "hkdf"
1185+version = "0.12.4"
1186+source = "registry+https://github.com/rust-lang/crates.io-index"
1187+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
1188+dependencies = [
1189+ "hmac",
1190+]
1191+
1192+[[package]]
1193+name = "hmac"
1194+version = "0.12.1"
1195+source = "registry+https://github.com/rust-lang/crates.io-index"
1196+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
1197+dependencies = [
1198+ "digest",
1199+]
1200+
1201+[[package]]
1202+name = "html5ever"
1203+version = "0.39.0"
1204+source = "registry+https://github.com/rust-lang/crates.io-index"
1205+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
1206+dependencies = [
1207+ "log",
1208+ "markup5ever",
1209+]
1210+
1211+[[package]]
1212+name = "http"
1213+version = "1.5.0"
1214+source = "registry+https://github.com/rust-lang/crates.io-index"
1215+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
1216+dependencies = [
1217+ "bytes",
1218+ "itoa",
1219+]
1220+
1221+[[package]]
1222+name = "http-body"
1223+version = "1.1.0"
1224+source = "registry+https://github.com/rust-lang/crates.io-index"
1225+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
1226+dependencies = [
1227+ "bytes",
1228+ "http",
1229+]
1230+
1231+[[package]]
1232+name = "http-body-util"
1233+version = "0.1.5"
1234+source = "registry+https://github.com/rust-lang/crates.io-index"
1235+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
1236+dependencies = [
1237+ "bytes",
1238+ "futures-core",
1239+ "http",
1240+ "http-body",
1241+ "pin-project-lite",
1242+]
1243+
1244+[[package]]
1245+name = "httparse"
1246+version = "1.10.1"
1247+source = "registry+https://github.com/rust-lang/crates.io-index"
1248+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
1249+
1250+[[package]]
1251+name = "hyper"
1252+version = "1.11.1"
1253+source = "registry+https://github.com/rust-lang/crates.io-index"
1254+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
1255+dependencies = [
1256+ "atomic-waker",
1257+ "bytes",
1258+ "futures-channel",
1259+ "futures-core",
1260+ "h2",
1261+ "http",
1262+ "http-body",
1263+ "httparse",
1264+ "itoa",
1265+ "pin-project-lite",
1266+ "smallvec",
1267+ "tokio",
1268+ "want",
1269+]
1270+
1271+[[package]]
1272+name = "hyper-rustls"
1273+version = "0.27.10"
1274+source = "registry+https://github.com/rust-lang/crates.io-index"
1275+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
1276+dependencies = [
1277+ "http",
1278+ "hyper",
1279+ "hyper-util",
1280+ "rustls",
1281+ "tokio",
1282+ "tokio-rustls",
1283+ "tower-service",
1284+]
1285+
1286+[[package]]
1287+name = "hyper-util"
1288+version = "0.1.21"
1289+source = "registry+https://github.com/rust-lang/crates.io-index"
1290+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
1291+dependencies = [
1292+ "base64 0.23.1",
1293+ "bytes",
1294+ "futures-channel",
1295+ "futures-util",
1296+ "http",
1297+ "http-body",
1298+ "httparse",
1299+ "hyper",
1300+ "ipnet",
1301+ "libc",
1302+ "percent-encoding",
1303+ "pin-project-lite",
1304+ "socket2",
1305+ "tokio",
1306+ "tower-service",
1307+ "tracing",
1308+]
1309+
1310+[[package]]
1311+name = "iana-time-zone"
1312+version = "0.1.65"
1313+source = "registry+https://github.com/rust-lang/crates.io-index"
1314+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
1315+dependencies = [
1316+ "android_system_properties",
1317+ "core-foundation-sys",
1318+ "iana-time-zone-haiku",
1319+ "js-sys",
1320+ "log",
1321+ "wasm-bindgen",
1322+ "windows-core",
1323+]
1324+
1325+[[package]]
1326+name = "iana-time-zone-haiku"
1327+version = "0.1.2"
1328+source = "registry+https://github.com/rust-lang/crates.io-index"
1329+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1330+dependencies = [
1331+ "cc",
1332+]
1333+
1334+[[package]]
1335+name = "icu_collections"
1336+version = "2.3.0"
1337+source = "registry+https://github.com/rust-lang/crates.io-index"
1338+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
1339+dependencies = [
1340+ "displaydoc",
1341+ "potential_utf",
1342+ "utf8_iter",
1343+ "yoke",
1344+ "zerofrom",
1345+ "zerovec",
1346+]
1347+
1348+[[package]]
1349+name = "icu_locale_core"
1350+version = "2.3.0"
1351+source = "registry+https://github.com/rust-lang/crates.io-index"
1352+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
1353+dependencies = [
1354+ "displaydoc",
1355+ "litemap",
1356+ "tinystr",
1357+ "writeable",
1358+ "zerovec",
1359+]
1360+
1361+[[package]]
1362+name = "icu_normalizer"
1363+version = "2.3.0"
1364+source = "registry+https://github.com/rust-lang/crates.io-index"
1365+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
1366+dependencies = [
1367+ "icu_collections",
1368+ "icu_normalizer_data",
1369+ "icu_properties",
1370+ "icu_provider",
1371+ "smallvec",
1372+ "zerovec",
1373+]
1374+
1375+[[package]]
1376+name = "icu_normalizer_data"
1377+version = "2.3.0"
1378+source = "registry+https://github.com/rust-lang/crates.io-index"
1379+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
1380+
1381+[[package]]
1382+name = "icu_properties"
1383+version = "2.3.0"
1384+source = "registry+https://github.com/rust-lang/crates.io-index"
1385+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
1386+dependencies = [
1387+ "displaydoc",
1388+ "icu_collections",
1389+ "icu_locale_core",
1390+ "icu_properties_data",
1391+ "icu_provider",
1392+ "zerotrie",
1393+ "zerovec",
1394+]
1395+
1396+[[package]]
1397+name = "icu_properties_data"
1398+version = "2.3.0"
1399+source = "registry+https://github.com/rust-lang/crates.io-index"
1400+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
1401+
1402+[[package]]
1403+name = "icu_provider"
1404+version = "2.3.1"
1405+source = "registry+https://github.com/rust-lang/crates.io-index"
1406+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
1407+dependencies = [
1408+ "displaydoc",
1409+ "icu_locale_core",
1410+ "writeable",
1411+ "yoke",
1412+ "zerofrom",
1413+ "zerotrie",
1414+ "zerovec",
1415+]
1416+
1417+[[package]]
1418+name = "idna"
1419+version = "1.1.0"
1420+source = "registry+https://github.com/rust-lang/crates.io-index"
1421+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
1422+dependencies = [
1423+ "idna_adapter",
1424+ "smallvec",
1425+ "utf8_iter",
1426+]
1427+
1428+[[package]]
1429+name = "idna_adapter"
1430+version = "1.2.2"
1431+source = "registry+https://github.com/rust-lang/crates.io-index"
1432+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
1433+dependencies = [
1434+ "icu_normalizer",
1435+ "icu_properties",
1436+]
1437+
1438+[[package]]
1439+name = "imbl"
1440+version = "6.1.0"
1441+source = "registry+https://github.com/rust-lang/crates.io-index"
1442+checksum = "0fade8ae6828627ad1fa094a891eccfb25150b383047190a3648d66d06186501"
1443+dependencies = [
1444+ "archery",
1445+ "bitmaps",
1446+ "imbl-sized-chunks",
1447+ "rand_core 0.9.5",
1448+ "rand_xoshiro",
1449+ "serde",
1450+ "version_check",
1451+]
1452+
1453+[[package]]
1454+name = "imbl-sized-chunks"
1455+version = "0.1.3"
1456+source = "registry+https://github.com/rust-lang/crates.io-index"
1457+checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d"
1458+dependencies = [
1459+ "bitmaps",
1460+]
1461+
1462+[[package]]
1463+name = "impartial-ord"
1464+version = "2.0.0"
1465+source = "registry+https://github.com/rust-lang/crates.io-index"
1466+checksum = "bc680e03d1a08bdc1c01b60e0c39723430ced63d8cabd93cee0449b6ba916a25"
1467+dependencies = [
1468+ "proc-macro2",
1469+ "quote",
1470+ "syn 3.0.6",
1471+]
1472+
1473+[[package]]
1474+name = "include_dir"
1475+version = "0.7.4"
1476+source = "registry+https://github.com/rust-lang/crates.io-index"
1477+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
1478+dependencies = [
1479+ "include_dir_macros",
1480+]
1481+
1482+[[package]]
1483+name = "include_dir_macros"
1484+version = "0.7.4"
1485+source = "registry+https://github.com/rust-lang/crates.io-index"
1486+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
1487+dependencies = [
1488+ "proc-macro2",
1489+ "quote",
1490+]
1491+
1492+[[package]]
1493+name = "indexmap"
1494+version = "2.14.2"
1495+source = "registry+https://github.com/rust-lang/crates.io-index"
1496+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
1497+dependencies = [
1498+ "equivalent",
1499+ "hashbrown 0.17.1",
1500+ "serde",
1501+ "serde_core",
1502+]
1503+
1504+[[package]]
1505+name = "inout"
1506+version = "0.1.4"
1507+source = "registry+https://github.com/rust-lang/crates.io-index"
1508+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
1509+dependencies = [
1510+ "block-padding",
1511+ "generic-array",
1512+]
1513+
1514+[[package]]
1515+name = "ipnet"
1516+version = "2.12.2"
1517+source = "registry+https://github.com/rust-lang/crates.io-index"
1518+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
1519+
1520+[[package]]
1521+name = "itertools"
1522+version = "0.10.5"
1523+source = "registry+https://github.com/rust-lang/crates.io-index"
1524+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
1525+dependencies = [
1526+ "either",
1527+]
1528+
1529+[[package]]
1530+name = "itertools"
1531+version = "0.14.0"
1532+source = "registry+https://github.com/rust-lang/crates.io-index"
1533+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
1534+dependencies = [
1535+ "either",
1536+]
1537+
1538+[[package]]
1539+name = "itoa"
1540+version = "1.0.18"
1541+source = "registry+https://github.com/rust-lang/crates.io-index"
1542+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1543+
1544+[[package]]
1545+name = "jni"
1546+version = "0.22.4"
1547+source = "registry+https://github.com/rust-lang/crates.io-index"
1548+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
1549+dependencies = [
1550+ "cfg-if",
1551+ "combine",
1552+ "jni-macros",
1553+ "jni-sys",
1554+ "log",
1555+ "simd_cesu8",
1556+ "thiserror 2.0.21",
1557+ "walkdir",
1558+ "windows-link",
1559+]
1560+
1561+[[package]]
1562+name = "jni-macros"
1563+version = "0.22.4"
1564+source = "registry+https://github.com/rust-lang/crates.io-index"
1565+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
1566+dependencies = [
1567+ "proc-macro2",
1568+ "quote",
1569+ "rustc_version",
1570+ "simd_cesu8",
1571+ "syn 2.0.119",
1572+]
1573+
1574+[[package]]
1575+name = "jni-sys"
1576+version = "0.4.1"
1577+source = "registry+https://github.com/rust-lang/crates.io-index"
1578+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
1579+dependencies = [
1580+ "jni-sys-macros",
1581+]
1582+
1583+[[package]]
1584+name = "jni-sys-macros"
1585+version = "0.4.1"
1586+source = "registry+https://github.com/rust-lang/crates.io-index"
1587+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
1588+dependencies = [
1589+ "quote",
1590+ "syn 2.0.119",
1591+]
1592+
1593+[[package]]
1594+name = "jobserver"
1595+version = "0.1.35"
1596+source = "registry+https://github.com/rust-lang/crates.io-index"
1597+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
1598+dependencies = [
1599+ "getrandom 0.4.3",
1600+ "libc",
1601+]
1602+
1603+[[package]]
1604+name = "js-sys"
1605+version = "0.3.105"
1606+source = "registry+https://github.com/rust-lang/crates.io-index"
1607+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
1608+dependencies = [
1609+ "cfg-if",
1610+ "futures-util",
1611+ "wasm-bindgen",
1612+]
1613+
1614+[[package]]
1615+name = "js_int"
1616+version = "0.2.2"
1617+source = "registry+https://github.com/rust-lang/crates.io-index"
1618+checksum = "d937f95470b270ce8b8950207715d71aa8e153c0d44c6684d59397ed4949160a"
1619+dependencies = [
1620+ "serde",
1621+]
1622+
1623+[[package]]
1624+name = "js_option"
1625+version = "0.2.0"
1626+source = "registry+https://github.com/rust-lang/crates.io-index"
1627+checksum = "c7dd3e281add16813cf673bf74a32249b0aa0d1c8117519a17b3ada5e8552b3c"
1628+dependencies = [
1629+ "serde_core",
1630+]
1631+
1632+[[package]]
1633+name = "konst"
1634+version = "0.4.3"
1635+source = "registry+https://github.com/rust-lang/crates.io-index"
1636+checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d"
1637+dependencies = [
1638+ "const_panic",
1639+ "typewit",
1640+]
1641+
1642+[[package]]
1643+name = "language-tags"
1644+version = "0.3.2"
1645+source = "registry+https://github.com/rust-lang/crates.io-index"
1646+checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
1647+
1648+[[package]]
1649+name = "lazy_static"
1650+version = "1.5.0"
1651+source = "registry+https://github.com/rust-lang/crates.io-index"
1652+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1653+
1654+[[package]]
1655+name = "libc"
1656+version = "0.2.189"
1657+source = "registry+https://github.com/rust-lang/crates.io-index"
1658+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
1659+
1660+[[package]]
1661+name = "libm"
1662+version = "0.2.16"
1663+source = "registry+https://github.com/rust-lang/crates.io-index"
1664+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1665+
1666+[[package]]
1667+name = "libsqlite3-sys"
1668+version = "0.35.0"
1669+source = "registry+https://github.com/rust-lang/crates.io-index"
1670+checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
1671+dependencies = [
1672+ "pkg-config",
1673+ "vcpkg",
1674+]
1675+
1676+[[package]]
1677+name = "linux-raw-sys"
1678+version = "0.12.1"
1679+source = "registry+https://github.com/rust-lang/crates.io-index"
1680+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
1681+
1682+[[package]]
1683+name = "litemap"
1684+version = "0.8.3"
1685+source = "registry+https://github.com/rust-lang/crates.io-index"
1686+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
1687+
1688+[[package]]
1689+name = "lock_api"
1690+version = "0.4.14"
1691+source = "registry+https://github.com/rust-lang/crates.io-index"
1692+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1693+dependencies = [
1694+ "scopeguard",
1695+]
1696+
1697+[[package]]
1698+name = "log"
1699+version = "0.4.34"
1700+source = "registry+https://github.com/rust-lang/crates.io-index"
1701+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
1702+
1703+[[package]]
1704+name = "loom"
1705+version = "0.7.2"
1706+source = "registry+https://github.com/rust-lang/crates.io-index"
1707+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
1708+dependencies = [
1709+ "cfg-if",
1710+ "generator",
1711+ "scoped-tls",
1712+ "tracing",
1713+ "tracing-subscriber",
1714+]
1715+
1716+[[package]]
1717+name = "lru-slab"
1718+version = "0.1.3"
1719+source = "registry+https://github.com/rust-lang/crates.io-index"
1720+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
1721+
1722+[[package]]
1723+name = "macltok"
1724+version = "0.1.0"
1725+dependencies = [
1726+ "futures-util",
1727+ "getrandom 0.2.17",
1728+ "getrandom 0.3.4",
1729+ "getrandom 0.4.3",
1730+ "http",
1731+ "imbl",
1732+ "js-sys",
1733+ "matrix-sdk",
1734+ "matrix-sdk-crypto",
1735+ "matrix-sdk-store-encryption",
1736+ "matrix-sdk-ui",
1737+ "matrix_indexed_db_futures",
1738+ "mime",
1739+ "reqwest",
1740+ "ruma",
1741+ "serde",
1742+ "sha2",
1743+ "thiserror 2.0.21",
1744+ "tokio",
1745+ "tracing",
1746+ "url",
1747+ "uuid",
1748+ "wasm-bindgen",
1749+ "wasm-bindgen-futures",
1750+ "web-sys",
1751+]
1752+
1753+[[package]]
1754+name = "macroific"
1755+version = "2.0.0"
1756+source = "registry+https://github.com/rust-lang/crates.io-index"
1757+checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285"
1758+dependencies = [
1759+ "macroific_attr_parse 2.0.0",
1760+ "macroific_core 2.0.0",
1761+ "macroific_macro 2.0.0",
1762+]
1763+
1764+[[package]]
1765+name = "macroific"
1766+version = "3.1.0"
1767+source = "registry+https://github.com/rust-lang/crates.io-index"
1768+checksum = "cc8ab95c98294764c0f1eacebdb5b027849201b71fbc383871fe991a1de7eb83"
1769+dependencies = [
1770+ "macroific_attr_parse 3.1.0",
1771+ "macroific_core 3.1.0",
1772+ "macroific_macro 3.1.0",
1773+]
1774+
1775+[[package]]
1776+name = "macroific_attr_parse"
1777+version = "2.0.0"
1778+source = "registry+https://github.com/rust-lang/crates.io-index"
1779+checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9"
1780+dependencies = [
1781+ "proc-macro2",
1782+ "quote",
1783+ "sealed 0.6.0",
1784+ "syn 2.0.119",
1785+]
1786+
1787+[[package]]
1788+name = "macroific_attr_parse"
1789+version = "3.1.0"
1790+source = "registry+https://github.com/rust-lang/crates.io-index"
1791+checksum = "bf9b7bdb92825e6a507392b12f0570ade17480be20f4ca951b9071b40deba1d1"
1792+dependencies = [
1793+ "proc-macro2",
1794+ "quote",
1795+ "sealed 0.7.0",
1796+ "syn 3.0.6",
1797+]
1798+
1799+[[package]]
1800+name = "macroific_core"
1801+version = "2.0.0"
1802+source = "registry+https://github.com/rust-lang/crates.io-index"
1803+checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3"
1804+dependencies = [
1805+ "proc-macro2",
1806+ "quote",
1807+ "sealed 0.6.0",
1808+ "syn 2.0.119",
1809+]
1810+
1811+[[package]]
1812+name = "macroific_core"
1813+version = "3.1.0"
1814+source = "registry+https://github.com/rust-lang/crates.io-index"
1815+checksum = "edc880da2bc00d6d87f748805e242d670e248d1b9b6fcef3190503210b6cf056"
1816+dependencies = [
1817+ "proc-macro2",
1818+ "quote",
1819+ "sealed 0.7.0",
1820+ "syn 3.0.6",
1821+]
1822+
1823+[[package]]
1824+name = "macroific_macro"
1825+version = "2.0.0"
1826+source = "registry+https://github.com/rust-lang/crates.io-index"
1827+checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b"
1828+dependencies = [
1829+ "macroific_attr_parse 2.0.0",
1830+ "macroific_core 2.0.0",
1831+ "proc-macro2",
1832+ "quote",
1833+ "syn 2.0.119",
1834+]
1835+
1836+[[package]]
1837+name = "macroific_macro"
1838+version = "3.1.0"
1839+source = "registry+https://github.com/rust-lang/crates.io-index"
1840+checksum = "201faf668c264432727fb64e44407bcfcf1f5f8153bf4ec248eb6bbe4a39b144"
1841+dependencies = [
1842+ "macroific_attr_parse 3.1.0",
1843+ "macroific_core 3.1.0",
1844+ "proc-macro2",
1845+ "quote",
1846+ "syn 3.0.6",
1847+]
1848+
1849+[[package]]
1850+name = "malice-core"
1851+version = "0.1.0"
1852+dependencies = [
1853+ "futures-util",
1854+ "http",
1855+ "macltok",
1856+ "ruma",
1857+ "serde",
1858+ "serde_json",
1859+]
1860+
1861+[[package]]
1862+name = "malice-worker"
1863+version = "0.1.0"
1864+dependencies = [
1865+ "futures-channel",
1866+ "futures-util",
1867+ "gloo-timers",
1868+ "js-sys",
1869+ "macltok",
1870+ "malice-core",
1871+ "ruma",
1872+ "serde_json",
1873+ "wasm-bindgen",
1874+ "wasm-bindgen-futures",
1875+ "wasm-bindgen-test",
1876+ "web-sys",
1877+]
1878+
1879+[[package]]
1880+name = "maplit"
1881+version = "1.0.2"
1882+source = "registry+https://github.com/rust-lang/crates.io-index"
1883+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
1884+
1885+[[package]]
1886+name = "markup5ever"
1887+version = "0.39.0"
1888+source = "registry+https://github.com/rust-lang/crates.io-index"
1889+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
1890+dependencies = [
1891+ "log",
1892+ "tendril",
1893+ "web_atoms",
1894+]
1895+
1896+[[package]]
1897+name = "matchers"
1898+version = "0.2.0"
1899+source = "registry+https://github.com/rust-lang/crates.io-index"
1900+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
1901+dependencies = [
1902+ "regex-automata",
1903+]
1904+
1905+[[package]]
1906+name = "matrix-pickle"
1907+version = "0.2.3"
1908+source = "registry+https://github.com/rust-lang/crates.io-index"
1909+checksum = "b3d65d46b7379dd0afa4a42f9b2269821d31afdee0111b5e0d74e3bee03553a0"
1910+dependencies = [
1911+ "matrix-pickle-derive",
1912+ "thiserror 2.0.21",
1913+]
1914+
1915+[[package]]
1916+name = "matrix-pickle-derive"
1917+version = "0.2.3"
1918+source = "registry+https://github.com/rust-lang/crates.io-index"
1919+checksum = "414b5e4c34009f2bc3fe35dd018f25755ca38858096574841c7332f99e2c7e77"
1920+dependencies = [
1921+ "proc-macro-crate",
1922+ "proc-macro2",
1923+ "quote",
1924+ "syn 2.0.119",
1925+]
1926+
1927+[[package]]
1928+name = "matrix-sdk"
1929+version = "0.18.0"
1930+source = "registry+https://github.com/rust-lang/crates.io-index"
1931+checksum = "7083d580527511ac5d9369e03b9f2b20902e76949f1b3964051f978e4d3756ae"
1932+dependencies = [
1933+ "anymap2",
1934+ "aquamarine",
1935+ "as_variant",
1936+ "async-channel",
1937+ "async-once-cell",
1938+ "async-stream",
1939+ "async-trait",
1940+ "backon",
1941+ "bytes",
1942+ "bytesize",
1943+ "cfg-if",
1944+ "event-listener",
1945+ "eyeball",
1946+ "eyeball-im",
1947+ "futures-core",
1948+ "futures-util",
1949+ "gloo-timers",
1950+ "http",
1951+ "imbl",
1952+ "indexmap",
1953+ "itertools 0.14.0",
1954+ "js_int",
1955+ "language-tags",
1956+ "matrix-sdk-base",
1957+ "matrix-sdk-common",
1958+ "matrix-sdk-indexeddb",
1959+ "matrix-sdk-sqlite",
1960+ "mime",
1961+ "mime2ext",
1962+ "oauth2",
1963+ "oauth2-reqwest",
1964+ "percent-encoding",
1965+ "pin-project-lite",
1966+ "reqwest",
1967+ "ruma",
1968+ "rustls",
1969+ "rustls-native-certs",
1970+ "rustls-pki-types",
1971+ "serde",
1972+ "serde_html_form",
1973+ "serde_json",
1974+ "sha2",
1975+ "tempfile",
1976+ "thiserror 2.0.21",
1977+ "tokio",
1978+ "tokio-stream",
1979+ "tokio-util",
1980+ "tracing",
1981+ "url",
1982+ "urlencoding",
1983+ "vodozemac",
1984+ "webpki-roots",
1985+ "zeroize",
1986+]
1987+
1988+[[package]]
1989+name = "matrix-sdk-base"
1990+version = "0.18.0"
1991+source = "registry+https://github.com/rust-lang/crates.io-index"
1992+checksum = "e09a917eb1f7643d9d9a06b2f131e2ceb39df6910a7b830dac18bfd6db37a1f5"
1993+dependencies = [
1994+ "as_variant",
1995+ "async-trait",
1996+ "bitflags",
1997+ "decancer",
1998+ "eyeball",
1999+ "eyeball-im",
2000+ "futures-util",
2001+ "growable-bloom-filter",
2002+ "matrix-sdk-common",
2003+ "matrix-sdk-crypto",
2004+ "matrix-sdk-store-encryption",
2005+ "regex",
2006+ "ruma",
2007+ "serde",
2008+ "serde_json",
2009+ "thiserror 2.0.21",
2010+ "tokio",
2011+ "tracing",
2012+ "unicode-normalization",
2013+]
2014+
2015+[[package]]
2016+name = "matrix-sdk-common"
2017+version = "0.18.0"
2018+source = "registry+https://github.com/rust-lang/crates.io-index"
2019+checksum = "2b9d1e0fee0f090180ef9457034adc547e80de9e6e3eb2e63aaac68a8476f836"
2020+dependencies = [
2021+ "eyeball-im",
2022+ "futures-core",
2023+ "futures-executor",
2024+ "futures-util",
2025+ "gloo-timers",
2026+ "imbl",
2027+ "ruma",
2028+ "serde",
2029+ "serde_json",
2030+ "thiserror 2.0.21",
2031+ "tokio",
2032+ "tracing",
2033+ "tracing-subscriber",
2034+ "wasm-bindgen",
2035+ "wasm-bindgen-futures",
2036+ "web-sys",
2037+]
2038+
2039+[[package]]
2040+name = "matrix-sdk-crypto"
2041+version = "0.18.0"
2042+source = "registry+https://github.com/rust-lang/crates.io-index"
2043+checksum = "c54afd2a326f51c13a6ad44ec86315a688fffeb3f1e287fb343e0e1836a3bdaf"
2044+dependencies = [
2045+ "aes",
2046+ "aquamarine",
2047+ "as_variant",
2048+ "async-trait",
2049+ "bs58",
2050+ "byteorder",
2051+ "cfg-if",
2052+ "ctr",
2053+ "eyeball",
2054+ "futures-core",
2055+ "futures-util",
2056+ "hkdf",
2057+ "hmac",
2058+ "itertools 0.14.0",
2059+ "js_option",
2060+ "matrix-sdk-common",
2061+ "pbkdf2",
2062+ "rand 0.10.3",
2063+ "rmp-serde",
2064+ "ruma",
2065+ "serde",
2066+ "serde_json",
2067+ "sha2",
2068+ "subtle",
2069+ "thiserror 2.0.21",
2070+ "time",
2071+ "tokio",
2072+ "tokio-stream",
2073+ "tracing",
2074+ "ulid",
2075+ "url",
2076+ "vodozemac",
2077+ "zeroize",
2078+]
2079+
2080+[[package]]
2081+name = "matrix-sdk-indexeddb"
2082+version = "0.18.0"
2083+source = "registry+https://github.com/rust-lang/crates.io-index"
2084+checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47"
2085+dependencies = [
2086+ "async-trait",
2087+ "base64 0.22.1",
2088+ "futures-util",
2089+ "getrandom 0.4.3",
2090+ "gloo-utils",
2091+ "growable-bloom-filter",
2092+ "hkdf",
2093+ "js-sys",
2094+ "matrix-sdk-base",
2095+ "matrix-sdk-crypto",
2096+ "matrix-sdk-store-encryption",
2097+ "matrix_indexed_db_futures",
2098+ "rmp-serde",
2099+ "ruma",
2100+ "serde",
2101+ "serde-wasm-bindgen",
2102+ "serde_json",
2103+ "sha2",
2104+ "thiserror 2.0.21",
2105+ "tokio",
2106+ "tracing",
2107+ "uuid",
2108+ "wasm-bindgen",
2109+ "web-sys",
2110+ "zeroize",
2111+]
2112+
2113+[[package]]
2114+name = "matrix-sdk-sqlite"
2115+version = "0.18.0"
2116+source = "registry+https://github.com/rust-lang/crates.io-index"
2117+checksum = "a49133429271005745f8d5a05362d2ba6567274777250236799927ca30dc0670"
2118+dependencies = [
2119+ "as_variant",
2120+ "async-trait",
2121+ "deadpool",
2122+ "deadpool-sync",
2123+ "itertools 0.14.0",
2124+ "matrix-sdk-base",
2125+ "matrix-sdk-crypto",
2126+ "matrix-sdk-store-encryption",
2127+ "num_cpus",
2128+ "rmp-serde",
2129+ "ruma",
2130+ "rusqlite",
2131+ "serde",
2132+ "serde_json",
2133+ "serde_path_to_error",
2134+ "thiserror 2.0.21",
2135+ "tokio",
2136+ "tracing",
2137+ "vodozemac",
2138+ "zeroize",
2139+]
2140+
2141+[[package]]
2142+name = "matrix-sdk-store-encryption"
2143+version = "0.18.0"
2144+source = "registry+https://github.com/rust-lang/crates.io-index"
2145+checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e"
2146+dependencies = [
2147+ "base64 0.22.1",
2148+ "blake3",
2149+ "chacha20poly1305",
2150+ "getrandom 0.2.17",
2151+ "getrandom 0.4.3",
2152+ "hmac",
2153+ "pbkdf2",
2154+ "rand 0.10.3",
2155+ "rmp-serde",
2156+ "serde",
2157+ "serde_json",
2158+ "sha2",
2159+ "thiserror 2.0.21",
2160+ "zeroize",
2161+]
2162+
2163+[[package]]
2164+name = "matrix-sdk-ui"
2165+version = "0.18.0"
2166+source = "registry+https://github.com/rust-lang/crates.io-index"
2167+checksum = "6d1f2b8a8cf57164d7cee4eb61c0b9f074b2e11d766a9391b7db0b9f2cbd83d8"
2168+dependencies = [
2169+ "as_variant",
2170+ "async-rx",
2171+ "async-stream",
2172+ "async_cell",
2173+ "bitflags",
2174+ "chrono",
2175+ "emojis",
2176+ "eyeball",
2177+ "eyeball-im",
2178+ "eyeball-im-util",
2179+ "futures-core",
2180+ "futures-util",
2181+ "fuzzy-matcher",
2182+ "growable-bloom-filter",
2183+ "imbl",
2184+ "indexmap",
2185+ "itertools 0.14.0",
2186+ "matrix-sdk",
2187+ "matrix-sdk-base",
2188+ "matrix-sdk-common",
2189+ "mime",
2190+ "pin-project-lite",
2191+ "ruma",
2192+ "serde",
2193+ "serde_json",
2194+ "thiserror 2.0.21",
2195+ "tokio",
2196+ "tokio-stream",
2197+ "tracing",
2198+ "unicode-normalization",
2199+ "unicode-segmentation",
2200+]
2201+
2202+[[package]]
2203+name = "matrix_indexed_db_futures"
2204+version = "0.7.0"
2205+source = "registry+https://github.com/rust-lang/crates.io-index"
2206+checksum = "245ff6a224b4df7b0c90dda2dd5a6eb46112708d49e8bdd8b007fccb09fea8e4"
2207+dependencies = [
2208+ "accessory",
2209+ "cfg-if",
2210+ "delegate-display",
2211+ "derive_more 2.1.1",
2212+ "fancy_constructor",
2213+ "futures-core",
2214+ "js-sys",
2215+ "matrix_indexed_db_futures_macros_internal",
2216+ "sealed 0.6.0",
2217+ "serde",
2218+ "serde-wasm-bindgen",
2219+ "smallvec",
2220+ "thiserror 2.0.21",
2221+ "tokio",
2222+ "wasm-bindgen",
2223+ "wasm-bindgen-futures",
2224+ "wasm_evt_listener",
2225+ "web-sys",
2226+ "web-time",
2227+]
2228+
2229+[[package]]
2230+name = "matrix_indexed_db_futures_macros_internal"
2231+version = "1.0.0"
2232+source = "registry+https://github.com/rust-lang/crates.io-index"
2233+checksum = "b428aee5c0fe9e5babd29e99d289b7f64718c444989aac0442d1fd6d3e3f66d1"
2234+dependencies = [
2235+ "macroific 2.0.0",
2236+ "proc-macro2",
2237+ "quote",
2238+ "syn 2.0.119",
2239+]
2240+
2241+[[package]]
2242+name = "memchr"
2243+version = "2.8.3"
2244+source = "registry+https://github.com/rust-lang/crates.io-index"
2245+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
2246+
2247+[[package]]
2248+name = "mime"
2249+version = "0.3.17"
2250+source = "registry+https://github.com/rust-lang/crates.io-index"
2251+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
2252+
2253+[[package]]
2254+name = "mime2ext"
2255+version = "0.1.54"
2256+source = "registry+https://github.com/rust-lang/crates.io-index"
2257+checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc"
2258+
2259+[[package]]
2260+name = "minicov"
2261+version = "0.3.8"
2262+source = "registry+https://github.com/rust-lang/crates.io-index"
2263+checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
2264+dependencies = [
2265+ "cc",
2266+ "walkdir",
2267+]
2268+
2269+[[package]]
2270+name = "miniz_oxide"
2271+version = "0.9.1"
2272+source = "registry+https://github.com/rust-lang/crates.io-index"
2273+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
2274+dependencies = [
2275+ "adler2",
2276+ "simd-adler32",
2277+]
2278+
2279+[[package]]
2280+name = "mio"
2281+version = "1.2.3"
2282+source = "registry+https://github.com/rust-lang/crates.io-index"
2283+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
2284+dependencies = [
2285+ "libc",
2286+ "wasi",
2287+ "windows-sys 0.61.2",
2288+]
2289+
2290+[[package]]
2291+name = "new_debug_unreachable"
2292+version = "1.0.6"
2293+source = "registry+https://github.com/rust-lang/crates.io-index"
2294+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
2295+
2296+[[package]]
2297+name = "nu-ansi-term"
2298+version = "0.50.3"
2299+source = "registry+https://github.com/rust-lang/crates.io-index"
2300+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
2301+dependencies = [
2302+ "windows-sys 0.61.2",
2303+]
2304+
2305+[[package]]
2306+name = "num-conv"
2307+version = "0.2.2"
2308+source = "registry+https://github.com/rust-lang/crates.io-index"
2309+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
2310+
2311+[[package]]
2312+name = "num-traits"
2313+version = "0.2.19"
2314+source = "registry+https://github.com/rust-lang/crates.io-index"
2315+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
2316+dependencies = [
2317+ "autocfg",
2318+ "libm",
2319+]
2320+
2321+[[package]]
2322+name = "num_cpus"
2323+version = "1.17.0"
2324+source = "registry+https://github.com/rust-lang/crates.io-index"
2325+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
2326+dependencies = [
2327+ "hermit-abi",
2328+ "libc",
2329+]
2330+
2331+[[package]]
2332+name = "oauth2"
2333+version = "5.0.0"
2334+source = "registry+https://github.com/rust-lang/crates.io-index"
2335+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
2336+dependencies = [
2337+ "base64 0.22.1",
2338+ "chrono",
2339+ "getrandom 0.2.17",
2340+ "http",
2341+ "rand 0.8.8",
2342+ "serde",
2343+ "serde_json",
2344+ "serde_path_to_error",
2345+ "sha2",
2346+ "thiserror 1.0.69",
2347+ "url",
2348+]
2349+
2350+[[package]]
2351+name = "oauth2-reqwest"
2352+version = "0.1.0-alpha.3"
2353+source = "registry+https://github.com/rust-lang/crates.io-index"
2354+checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2"
2355+dependencies = [
2356+ "oauth2",
2357+ "reqwest",
2358+]
2359+
2360+[[package]]
2361+name = "once_cell"
2362+version = "1.21.4"
2363+source = "registry+https://github.com/rust-lang/crates.io-index"
2364+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
2365+
2366+[[package]]
2367+name = "oorandom"
2368+version = "11.1.5"
2369+source = "registry+https://github.com/rust-lang/crates.io-index"
2370+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
2371+
2372+[[package]]
2373+name = "opaque-debug"
2374+version = "0.3.1"
2375+source = "registry+https://github.com/rust-lang/crates.io-index"
2376+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
2377+
2378+[[package]]
2379+name = "openssl-probe"
2380+version = "0.2.1"
2381+source = "registry+https://github.com/rust-lang/crates.io-index"
2382+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
2383+
2384+[[package]]
2385+name = "parking"
2386+version = "2.2.1"
2387+source = "registry+https://github.com/rust-lang/crates.io-index"
2388+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2389+
2390+[[package]]
2391+name = "parking_lot"
2392+version = "0.12.5"
2393+source = "registry+https://github.com/rust-lang/crates.io-index"
2394+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
2395+dependencies = [
2396+ "lock_api",
2397+ "parking_lot_core",
2398+]
2399+
2400+[[package]]
2401+name = "parking_lot_core"
2402+version = "0.9.12"
2403+source = "registry+https://github.com/rust-lang/crates.io-index"
2404+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
2405+dependencies = [
2406+ "cfg-if",
2407+ "libc",
2408+ "redox_syscall",
2409+ "smallvec",
2410+ "windows-link",
2411+]
2412+
2413+[[package]]
2414+name = "pbkdf2"
2415+version = "0.12.2"
2416+source = "registry+https://github.com/rust-lang/crates.io-index"
2417+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
2418+dependencies = [
2419+ "digest",
2420+]
2421+
2422+[[package]]
2423+name = "percent-encoding"
2424+version = "2.3.2"
2425+source = "registry+https://github.com/rust-lang/crates.io-index"
2426+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
2427+
2428+[[package]]
2429+name = "phf"
2430+version = "0.13.1"
2431+source = "registry+https://github.com/rust-lang/crates.io-index"
2432+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
2433+dependencies = [
2434+ "phf_shared",
2435+ "serde",
2436+]
2437+
2438+[[package]]
2439+name = "phf_codegen"
2440+version = "0.13.1"
2441+source = "registry+https://github.com/rust-lang/crates.io-index"
2442+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
2443+dependencies = [
2444+ "phf_generator",
2445+ "phf_shared",
2446+]
2447+
2448+[[package]]
2449+name = "phf_generator"
2450+version = "0.13.1"
2451+source = "registry+https://github.com/rust-lang/crates.io-index"
2452+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
2453+dependencies = [
2454+ "fastrand",
2455+ "phf_shared",
2456+]
2457+
2458+[[package]]
2459+name = "phf_shared"
2460+version = "0.13.1"
2461+source = "registry+https://github.com/rust-lang/crates.io-index"
2462+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
2463+dependencies = [
2464+ "siphasher",
2465+]
2466+
2467+[[package]]
2468+name = "pin-project-lite"
2469+version = "0.2.17"
2470+source = "registry+https://github.com/rust-lang/crates.io-index"
2471+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
2472+
2473+[[package]]
2474+name = "pkcs8"
2475+version = "0.10.2"
2476+source = "registry+https://github.com/rust-lang/crates.io-index"
2477+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
2478+dependencies = [
2479+ "der",
2480+ "spki",
2481+]
2482+
2483+[[package]]
2484+name = "pkg-config"
2485+version = "0.3.34"
2486+source = "registry+https://github.com/rust-lang/crates.io-index"
2487+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
2488+
2489+[[package]]
2490+name = "poly1305"
2491+version = "0.8.0"
2492+source = "registry+https://github.com/rust-lang/crates.io-index"
2493+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
2494+dependencies = [
2495+ "cpufeatures 0.2.17",
2496+ "opaque-debug",
2497+ "universal-hash",
2498+]
2499+
2500+[[package]]
2501+name = "potential_utf"
2502+version = "0.1.6"
2503+source = "registry+https://github.com/rust-lang/crates.io-index"
2504+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
2505+dependencies = [
2506+ "zerovec",
2507+]
2508+
2509+[[package]]
2510+name = "powerfmt"
2511+version = "0.2.0"
2512+source = "registry+https://github.com/rust-lang/crates.io-index"
2513+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
2514+
2515+[[package]]
2516+name = "ppv-lite86"
2517+version = "0.2.21"
2518+source = "registry+https://github.com/rust-lang/crates.io-index"
2519+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
2520+dependencies = [
2521+ "zerocopy",
2522+]
2523+
2524+[[package]]
2525+name = "precomputed-hash"
2526+version = "0.1.1"
2527+source = "registry+https://github.com/rust-lang/crates.io-index"
2528+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
2529+
2530+[[package]]
2531+name = "proc-macro-crate"
2532+version = "3.5.0"
2533+source = "registry+https://github.com/rust-lang/crates.io-index"
2534+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
2535+dependencies = [
2536+ "toml_edit",
2537+]
2538+
2539+[[package]]
2540+name = "proc-macro-error-attr2"
2541+version = "2.0.0"
2542+source = "registry+https://github.com/rust-lang/crates.io-index"
2543+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
2544+dependencies = [
2545+ "proc-macro2",
2546+ "quote",
2547+]
2548+
2549+[[package]]
2550+name = "proc-macro-error2"
2551+version = "2.0.1"
2552+source = "registry+https://github.com/rust-lang/crates.io-index"
2553+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
2554+dependencies = [
2555+ "proc-macro-error-attr2",
2556+ "proc-macro2",
2557+ "quote",
2558+]
2559+
2560+[[package]]
2561+name = "proc-macro2"
2562+version = "1.0.107"
2563+source = "registry+https://github.com/rust-lang/crates.io-index"
2564+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
2565+dependencies = [
2566+ "unicode-ident",
2567+]
2568+
2569+[[package]]
2570+name = "prost"
2571+version = "0.14.4"
2572+source = "registry+https://github.com/rust-lang/crates.io-index"
2573+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
2574+dependencies = [
2575+ "bytes",
2576+ "prost-derive",
2577+]
2578+
2579+[[package]]
2580+name = "prost-derive"
2581+version = "0.14.4"
2582+source = "registry+https://github.com/rust-lang/crates.io-index"
2583+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
2584+dependencies = [
2585+ "anyhow",
2586+ "itertools 0.14.0",
2587+ "proc-macro2",
2588+ "quote",
2589+ "syn 2.0.119",
2590+]
2591+
2592+[[package]]
2593+name = "quinn"
2594+version = "0.11.12"
2595+source = "registry+https://github.com/rust-lang/crates.io-index"
2596+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
2597+dependencies = [
2598+ "bytes",
2599+ "cfg_aliases",
2600+ "pin-project-lite",
2601+ "quinn-proto",
2602+ "quinn-udp",
2603+ "rustc-hash",
2604+ "rustls",
2605+ "socket2",
2606+ "thiserror 2.0.21",
2607+ "tokio",
2608+ "tracing",
2609+ "web-time",
2610+]
2611+
2612+[[package]]
2613+name = "quinn-proto"
2614+version = "0.11.18"
2615+source = "registry+https://github.com/rust-lang/crates.io-index"
2616+checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
2617+dependencies = [
2618+ "aws-lc-rs",
2619+ "bytes",
2620+ "getrandom 0.4.3",
2621+ "lru-slab",
2622+ "rand 0.10.3",
2623+ "rand_pcg",
2624+ "ring",
2625+ "rustc-hash",
2626+ "rustls",
2627+ "rustls-pki-types",
2628+ "slab",
2629+ "thiserror 2.0.21",
2630+ "tinyvec",
2631+ "tracing",
2632+ "web-time",
2633+]
2634+
2635+[[package]]
2636+name = "quinn-udp"
2637+version = "0.5.15"
2638+source = "registry+https://github.com/rust-lang/crates.io-index"
2639+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
2640+dependencies = [
2641+ "cfg_aliases",
2642+ "libc",
2643+ "once_cell",
2644+ "socket2",
2645+ "tracing",
2646+ "windows-sys 0.61.2",
2647+]
2648+
2649+[[package]]
2650+name = "quote"
2651+version = "1.0.47"
2652+source = "registry+https://github.com/rust-lang/crates.io-index"
2653+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
2654+dependencies = [
2655+ "proc-macro2",
2656+]
2657+
2658+[[package]]
2659+name = "r-efi"
2660+version = "5.3.0"
2661+source = "registry+https://github.com/rust-lang/crates.io-index"
2662+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
2663+
2664+[[package]]
2665+name = "r-efi"
2666+version = "6.0.0"
2667+source = "registry+https://github.com/rust-lang/crates.io-index"
2668+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
2669+
2670+[[package]]
2671+name = "rand"
2672+version = "0.8.8"
2673+source = "registry+https://github.com/rust-lang/crates.io-index"
2674+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
2675+dependencies = [
2676+ "libc",
2677+ "rand_chacha 0.3.1",
2678+ "rand_core 0.6.4",
2679+]
2680+
2681+[[package]]
2682+name = "rand"
2683+version = "0.9.5"
2684+source = "registry+https://github.com/rust-lang/crates.io-index"
2685+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
2686+dependencies = [
2687+ "rand_chacha 0.9.0",
2688+ "rand_core 0.9.5",
2689+]
2690+
2691+[[package]]
2692+name = "rand"
2693+version = "0.10.3"
2694+source = "registry+https://github.com/rust-lang/crates.io-index"
2695+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
2696+dependencies = [
2697+ "chacha20 0.10.2",
2698+ "getrandom 0.4.3",
2699+ "rand_core 0.10.1",
2700+]
2701+
2702+[[package]]
2703+name = "rand_chacha"
2704+version = "0.3.1"
2705+source = "registry+https://github.com/rust-lang/crates.io-index"
2706+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
2707+dependencies = [
2708+ "ppv-lite86",
2709+ "rand_core 0.6.4",
2710+]
2711+
2712+[[package]]
2713+name = "rand_chacha"
2714+version = "0.9.0"
2715+source = "registry+https://github.com/rust-lang/crates.io-index"
2716+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
2717+dependencies = [
2718+ "ppv-lite86",
2719+ "rand_core 0.9.5",
2720+]
2721+
2722+[[package]]
2723+name = "rand_core"
2724+version = "0.6.4"
2725+source = "registry+https://github.com/rust-lang/crates.io-index"
2726+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
2727+dependencies = [
2728+ "getrandom 0.2.17",
2729+]
2730+
2731+[[package]]
2732+name = "rand_core"
2733+version = "0.9.5"
2734+source = "registry+https://github.com/rust-lang/crates.io-index"
2735+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
2736+dependencies = [
2737+ "getrandom 0.3.4",
2738+]
2739+
2740+[[package]]
2741+name = "rand_core"
2742+version = "0.10.1"
2743+source = "registry+https://github.com/rust-lang/crates.io-index"
2744+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
2745+
2746+[[package]]
2747+name = "rand_pcg"
2748+version = "0.10.2"
2749+source = "registry+https://github.com/rust-lang/crates.io-index"
2750+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
2751+dependencies = [
2752+ "rand_core 0.10.1",
2753+]
2754+
2755+[[package]]
2756+name = "rand_xoshiro"
2757+version = "0.7.0"
2758+source = "registry+https://github.com/rust-lang/crates.io-index"
2759+checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
2760+dependencies = [
2761+ "rand_core 0.9.5",
2762+]
2763+
2764+[[package]]
2765+name = "readlock"
2766+version = "0.1.11"
2767+source = "registry+https://github.com/rust-lang/crates.io-index"
2768+checksum = "6da6f291b23556edd9edaf655a0be2ad8ef8002ff5f1bca62b264f3f58b53f34"
2769+
2770+[[package]]
2771+name = "readlock-tokio"
2772+version = "0.1.6"
2773+source = "registry+https://github.com/rust-lang/crates.io-index"
2774+checksum = "fc7e264f9ec4f3d112e8e2f214e8e7cb5cf3b83278f3570b7e00bfe13d3bd8ff"
2775+dependencies = [
2776+ "tokio",
2777+]
2778+
2779+[[package]]
2780+name = "redox_syscall"
2781+version = "0.5.18"
2782+source = "registry+https://github.com/rust-lang/crates.io-index"
2783+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
2784+dependencies = [
2785+ "bitflags",
2786+]
2787+
2788+[[package]]
2789+name = "regex"
2790+version = "1.13.1"
2791+source = "registry+https://github.com/rust-lang/crates.io-index"
2792+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
2793+dependencies = [
2794+ "aho-corasick",
2795+ "memchr",
2796+ "regex-automata",
2797+ "regex-syntax",
2798+]
2799+
2800+[[package]]
2801+name = "regex-automata"
2802+version = "0.4.18"
2803+source = "registry+https://github.com/rust-lang/crates.io-index"
2804+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
2805+dependencies = [
2806+ "aho-corasick",
2807+ "memchr",
2808+ "regex-syntax",
2809+]
2810+
2811+[[package]]
2812+name = "regex-syntax"
2813+version = "0.8.11"
2814+source = "registry+https://github.com/rust-lang/crates.io-index"
2815+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
2816+
2817+[[package]]
2818+name = "reqwest"
2819+version = "0.13.5"
2820+source = "registry+https://github.com/rust-lang/crates.io-index"
2821+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
2822+dependencies = [
2823+ "base64 0.23.1",
2824+ "bytes",
2825+ "futures-core",
2826+ "futures-util",
2827+ "h2",
2828+ "http",
2829+ "http-body",
2830+ "http-body-util",
2831+ "hyper",
2832+ "hyper-rustls",
2833+ "hyper-util",
2834+ "js-sys",
2835+ "log",
2836+ "percent-encoding",
2837+ "pin-project-lite",
2838+ "quinn",
2839+ "rustls",
2840+ "rustls-pki-types",
2841+ "rustls-platform-verifier",
2842+ "sync_wrapper",
2843+ "tokio",
2844+ "tokio-rustls",
2845+ "tokio-util",
2846+ "tower",
2847+ "tower-http",
2848+ "tower-service",
2849+ "url",
2850+ "wasm-bindgen",
2851+ "wasm-bindgen-futures",
2852+ "wasm-streams",
2853+ "web-sys",
2854+]
2855+
2856+[[package]]
2857+name = "ring"
2858+version = "0.17.14"
2859+source = "registry+https://github.com/rust-lang/crates.io-index"
2860+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
2861+dependencies = [
2862+ "cc",
2863+ "cfg-if",
2864+ "getrandom 0.2.17",
2865+ "libc",
2866+ "untrusted",
2867+ "windows-sys 0.52.0",
2868+]
2869+
2870+[[package]]
2871+name = "rmp"
2872+version = "0.8.15"
2873+source = "registry+https://github.com/rust-lang/crates.io-index"
2874+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
2875+dependencies = [
2876+ "num-traits",
2877+]
2878+
2879+[[package]]
2880+name = "rmp-serde"
2881+version = "1.3.1"
2882+source = "registry+https://github.com/rust-lang/crates.io-index"
2883+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
2884+dependencies = [
2885+ "rmp",
2886+ "serde",
2887+]
2888+
2889+[[package]]
2890+name = "ruma"
2891+version = "0.16.0"
2892+source = "registry+https://github.com/rust-lang/crates.io-index"
2893+checksum = "ee4fe5bfacdb0e95e733da3b6c37d98edf46447a4a8e8dea824e0da266d8ad59"
2894+dependencies = [
2895+ "assign",
2896+ "js_int",
2897+ "js_option",
2898+ "ruma-client-api",
2899+ "ruma-common",
2900+ "ruma-events",
2901+ "ruma-html",
2902+ "web-time",
2903+]
2904+
2905+[[package]]
2906+name = "ruma-client-api"
2907+version = "0.24.0"
2908+source = "registry+https://github.com/rust-lang/crates.io-index"
2909+checksum = "bf7ca43a888ca569168d7e3901f4dd14a777b860bb19f4c08e35414162eb261c"
2910+dependencies = [
2911+ "as_variant",
2912+ "assign",
2913+ "bytes",
2914+ "http",
2915+ "js_int",
2916+ "js_option",
2917+ "maplit",
2918+ "ruma-common",
2919+ "ruma-events",
2920+ "serde",
2921+ "serde_html_form",
2922+ "serde_json",
2923+ "thiserror 2.0.21",
2924+ "url",
2925+ "web-time",
2926+]
2927+
2928+[[package]]
2929+name = "ruma-common"
2930+version = "0.19.0"
2931+source = "registry+https://github.com/rust-lang/crates.io-index"
2932+checksum = "2c3b4f00112791b490acce57df1ce3eb3f88899b045bebcff8a29f75369640cc"
2933+dependencies = [
2934+ "as_variant",
2935+ "base64 0.22.1",
2936+ "bytes",
2937+ "date_header",
2938+ "form_urlencoded",
2939+ "getrandom 0.4.3",
2940+ "http",
2941+ "indexmap",
2942+ "js_int",
2943+ "konst",
2944+ "percent-encoding",
2945+ "rand 0.10.3",
2946+ "regex",
2947+ "ruma-identifiers-validation",
2948+ "ruma-macros",
2949+ "serde",
2950+ "serde_html_form",
2951+ "serde_json",
2952+ "thiserror 2.0.21",
2953+ "time",
2954+ "tracing",
2955+ "url",
2956+ "uuid",
2957+ "web-time",
2958+ "wildmatch",
2959+ "zeroize",
2960+]
2961+
2962+[[package]]
2963+name = "ruma-events"
2964+version = "0.34.0"
2965+source = "registry+https://github.com/rust-lang/crates.io-index"
2966+checksum = "85d2f90830fc131691349b96a69ff53444eb6c3e8dc7869c77961b43cfaf3344"
2967+dependencies = [
2968+ "as_variant",
2969+ "indexmap",
2970+ "js_int",
2971+ "js_option",
2972+ "ruma-common",
2973+ "ruma-html",
2974+ "ruma-macros",
2975+ "serde",
2976+ "serde_json",
2977+ "thiserror 2.0.21",
2978+ "tracing",
2979+ "web-time",
2980+ "wildmatch",
2981+ "zeroize",
2982+]
2983+
2984+[[package]]
2985+name = "ruma-html"
2986+version = "0.8.0"
2987+source = "registry+https://github.com/rust-lang/crates.io-index"
2988+checksum = "48d33a944650f4bbd2188dd204d39dd87a9a1498f14b3a13252910c90ed7cd43"
2989+dependencies = [
2990+ "as_variant",
2991+ "html5ever",
2992+ "tracing",
2993+ "wildmatch",
2994+]
2995+
2996+[[package]]
2997+name = "ruma-identifiers-validation"
2998+version = "0.12.1"
2999+source = "registry+https://github.com/rust-lang/crates.io-index"
3000+checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432"
3001+dependencies = [
3002+ "js_int",
3003+ "thiserror 2.0.21",
3004+]
3005+
3006+[[package]]
3007+name = "ruma-macros"
3008+version = "0.19.0"
3009+source = "registry+https://github.com/rust-lang/crates.io-index"
3010+checksum = "c8cfb39eaa9b9fd389126ff941e060b496add5cbbfef559d80c46e571dda459c"
3011+dependencies = [
3012+ "as_variant",
3013+ "cfg-if",
3014+ "proc-macro-crate",
3015+ "proc-macro2",
3016+ "quote",
3017+ "ruma-identifiers-validation",
3018+ "serde",
3019+ "syn 2.0.119",
3020+ "toml",
3021+]
3022+
3023+[[package]]
3024+name = "rusqlite"
3025+version = "0.37.0"
3026+source = "registry+https://github.com/rust-lang/crates.io-index"
3027+checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
3028+dependencies = [
3029+ "bitflags",
3030+ "fallible-iterator",
3031+ "fallible-streaming-iterator",
3032+ "hashlink",
3033+ "libsqlite3-sys",
3034+ "smallvec",
3035+]
3036+
3037+[[package]]
3038+name = "rustc-hash"
3039+version = "2.1.3"
3040+source = "registry+https://github.com/rust-lang/crates.io-index"
3041+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
3042+
3043+[[package]]
3044+name = "rustc_version"
3045+version = "0.4.1"
3046+source = "registry+https://github.com/rust-lang/crates.io-index"
3047+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
3048+dependencies = [
3049+ "semver",
3050+]
3051+
3052+[[package]]
3053+name = "rustix"
3054+version = "1.1.5"
3055+source = "registry+https://github.com/rust-lang/crates.io-index"
3056+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
3057+dependencies = [
3058+ "bitflags",
3059+ "errno",
3060+ "libc",
3061+ "linux-raw-sys",
3062+ "windows-sys 0.61.2",
3063+]
3064+
3065+[[package]]
3066+name = "rustls"
3067+version = "0.23.45"
3068+source = "registry+https://github.com/rust-lang/crates.io-index"
3069+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
3070+dependencies = [
3071+ "aws-lc-rs",
3072+ "log",
3073+ "once_cell",
3074+ "rustls-pki-types",
3075+ "rustls-webpki",
3076+ "subtle",
3077+ "zeroize",
3078+]
3079+
3080+[[package]]
3081+name = "rustls-native-certs"
3082+version = "0.8.4"
3083+source = "registry+https://github.com/rust-lang/crates.io-index"
3084+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
3085+dependencies = [
3086+ "openssl-probe",
3087+ "rustls-pki-types",
3088+ "schannel",
3089+ "security-framework",
3090+]
3091+
3092+[[package]]
3093+name = "rustls-pki-types"
3094+version = "1.15.1"
3095+source = "registry+https://github.com/rust-lang/crates.io-index"
3096+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
3097+dependencies = [
3098+ "web-time",
3099+ "zeroize",
3100+]
3101+
3102+[[package]]
3103+name = "rustls-platform-verifier"
3104+version = "0.7.1"
3105+source = "registry+https://github.com/rust-lang/crates.io-index"
3106+checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
3107+dependencies = [
3108+ "core-foundation",
3109+ "core-foundation-sys",
3110+ "jni",
3111+ "log",
3112+ "once_cell",
3113+ "rustls",
3114+ "rustls-native-certs",
3115+ "rustls-platform-verifier-android",
3116+ "rustls-webpki",
3117+ "security-framework",
3118+ "security-framework-sys",
3119+ "webpki-root-certs",
3120+ "windows-sys 0.61.2",
3121+]
3122+
3123+[[package]]
3124+name = "rustls-platform-verifier-android"
3125+version = "0.2.0"
3126+source = "registry+https://github.com/rust-lang/crates.io-index"
3127+checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
3128+
3129+[[package]]
3130+name = "rustls-webpki"
3131+version = "0.103.15"
3132+source = "registry+https://github.com/rust-lang/crates.io-index"
3133+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
3134+dependencies = [
3135+ "aws-lc-rs",
3136+ "ring",
3137+ "rustls-pki-types",
3138+ "untrusted",
3139+]
3140+
3141+[[package]]
3142+name = "rustversion"
3143+version = "1.0.23"
3144+source = "registry+https://github.com/rust-lang/crates.io-index"
3145+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
3146+
3147+[[package]]
3148+name = "same-file"
3149+version = "1.0.6"
3150+source = "registry+https://github.com/rust-lang/crates.io-index"
3151+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
3152+dependencies = [
3153+ "winapi-util",
3154+]
3155+
3156+[[package]]
3157+name = "schannel"
3158+version = "0.1.29"
3159+source = "registry+https://github.com/rust-lang/crates.io-index"
3160+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
3161+dependencies = [
3162+ "windows-sys 0.61.2",
3163+]
3164+
3165+[[package]]
3166+name = "scoped-tls"
3167+version = "1.0.1"
3168+source = "registry+https://github.com/rust-lang/crates.io-index"
3169+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
3170+
3171+[[package]]
3172+name = "scopeguard"
3173+version = "1.2.0"
3174+source = "registry+https://github.com/rust-lang/crates.io-index"
3175+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
3176+
3177+[[package]]
3178+name = "sealed"
3179+version = "0.6.0"
3180+source = "registry+https://github.com/rust-lang/crates.io-index"
3181+checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
3182+dependencies = [
3183+ "proc-macro2",
3184+ "quote",
3185+ "syn 2.0.119",
3186+]
3187+
3188+[[package]]
3189+name = "sealed"
3190+version = "0.7.0"
3191+source = "registry+https://github.com/rust-lang/crates.io-index"
3192+checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"
3193+dependencies = [
3194+ "proc-macro2",
3195+ "quote",
3196+ "syn 3.0.6",
3197+]
3198+
3199+[[package]]
3200+name = "security-framework"
3201+version = "3.7.0"
3202+source = "registry+https://github.com/rust-lang/crates.io-index"
3203+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
3204+dependencies = [
3205+ "bitflags",
3206+ "core-foundation",
3207+ "core-foundation-sys",
3208+ "libc",
3209+ "security-framework-sys",
3210+]
3211+
3212+[[package]]
3213+name = "security-framework-sys"
3214+version = "2.17.0"
3215+source = "registry+https://github.com/rust-lang/crates.io-index"
3216+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
3217+dependencies = [
3218+ "core-foundation-sys",
3219+ "libc",
3220+]
3221+
3222+[[package]]
3223+name = "semver"
3224+version = "1.0.28"
3225+source = "registry+https://github.com/rust-lang/crates.io-index"
3226+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
3227+
3228+[[package]]
3229+name = "serde"
3230+version = "1.0.229"
3231+source = "registry+https://github.com/rust-lang/crates.io-index"
3232+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
3233+dependencies = [
3234+ "serde_core",
3235+ "serde_derive",
3236+]
3237+
3238+[[package]]
3239+name = "serde-wasm-bindgen"
3240+version = "0.6.5"
3241+source = "registry+https://github.com/rust-lang/crates.io-index"
3242+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
3243+dependencies = [
3244+ "js-sys",
3245+ "serde",
3246+ "wasm-bindgen",
3247+]
3248+
3249+[[package]]
3250+name = "serde_bytes"
3251+version = "0.11.19"
3252+source = "registry+https://github.com/rust-lang/crates.io-index"
3253+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
3254+dependencies = [
3255+ "serde",
3256+ "serde_core",
3257+]
3258+
3259+[[package]]
3260+name = "serde_core"
3261+version = "1.0.229"
3262+source = "registry+https://github.com/rust-lang/crates.io-index"
3263+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
3264+dependencies = [
3265+ "serde_derive",
3266+]
3267+
3268+[[package]]
3269+name = "serde_derive"
3270+version = "1.0.229"
3271+source = "registry+https://github.com/rust-lang/crates.io-index"
3272+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
3273+dependencies = [
3274+ "proc-macro2",
3275+ "quote",
3276+ "syn 3.0.6",
3277+]
3278+
3279+[[package]]
3280+name = "serde_html_form"
3281+version = "0.4.1"
3282+source = "registry+https://github.com/rust-lang/crates.io-index"
3283+checksum = "8f0346d7a342ab90f405cfc08f25d15075f944f42fcabbc5eac923829fa6d228"
3284+dependencies = [
3285+ "form_urlencoded",
3286+ "indexmap",
3287+ "itoa",
3288+ "serde_core",
3289+ "zmij",
3290+]
3291+
3292+[[package]]
3293+name = "serde_json"
3294+version = "1.0.151"
3295+source = "registry+https://github.com/rust-lang/crates.io-index"
3296+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
3297+dependencies = [
3298+ "itoa",
3299+ "memchr",
3300+ "serde",
3301+ "serde_core",
3302+ "zmij",
3303+]
3304+
3305+[[package]]
3306+name = "serde_path_to_error"
3307+version = "0.1.20"
3308+source = "registry+https://github.com/rust-lang/crates.io-index"
3309+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
3310+dependencies = [
3311+ "itoa",
3312+ "serde",
3313+ "serde_core",
3314+]
3315+
3316+[[package]]
3317+name = "serde_spanned"
3318+version = "1.1.1"
3319+source = "registry+https://github.com/rust-lang/crates.io-index"
3320+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
3321+dependencies = [
3322+ "serde_core",
3323+]
3324+
3325+[[package]]
3326+name = "sha2"
3327+version = "0.10.9"
3328+source = "registry+https://github.com/rust-lang/crates.io-index"
3329+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
3330+dependencies = [
3331+ "cfg-if",
3332+ "cpufeatures 0.2.17",
3333+ "digest",
3334+]
3335+
3336+[[package]]
3337+name = "sharded-slab"
3338+version = "0.1.7"
3339+source = "registry+https://github.com/rust-lang/crates.io-index"
3340+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
3341+dependencies = [
3342+ "lazy_static",
3343+]
3344+
3345+[[package]]
3346+name = "shlex"
3347+version = "2.0.1"
3348+source = "registry+https://github.com/rust-lang/crates.io-index"
3349+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
3350+
3351+[[package]]
3352+name = "signature"
3353+version = "2.2.0"
3354+source = "registry+https://github.com/rust-lang/crates.io-index"
3355+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
3356+dependencies = [
3357+ "rand_core 0.6.4",
3358+]
3359+
3360+[[package]]
3361+name = "simd-adler32"
3362+version = "0.3.10"
3363+source = "registry+https://github.com/rust-lang/crates.io-index"
3364+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
3365+
3366+[[package]]
3367+name = "simd_cesu8"
3368+version = "1.2.0"
3369+source = "registry+https://github.com/rust-lang/crates.io-index"
3370+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
3371+dependencies = [
3372+ "rustc_version",
3373+ "simdutf8",
3374+]
3375+
3376+[[package]]
3377+name = "simdutf8"
3378+version = "0.1.5"
3379+source = "registry+https://github.com/rust-lang/crates.io-index"
3380+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
3381+
3382+[[package]]
3383+name = "siphasher"
3384+version = "1.0.4"
3385+source = "registry+https://github.com/rust-lang/crates.io-index"
3386+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
3387+
3388+[[package]]
3389+name = "slab"
3390+version = "0.4.12"
3391+source = "registry+https://github.com/rust-lang/crates.io-index"
3392+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
3393+
3394+[[package]]
3395+name = "smallvec"
3396+version = "1.16.2"
3397+source = "registry+https://github.com/rust-lang/crates.io-index"
3398+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
3399+dependencies = [
3400+ "serde",
3401+]
3402+
3403+[[package]]
3404+name = "socket2"
3405+version = "0.6.5"
3406+source = "registry+https://github.com/rust-lang/crates.io-index"
3407+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
3408+dependencies = [
3409+ "libc",
3410+ "windows-sys 0.61.2",
3411+]
3412+
3413+[[package]]
3414+name = "spki"
3415+version = "0.7.3"
3416+source = "registry+https://github.com/rust-lang/crates.io-index"
3417+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
3418+dependencies = [
3419+ "base64ct",
3420+ "der",
3421+]
3422+
3423+[[package]]
3424+name = "stable_deref_trait"
3425+version = "1.2.1"
3426+source = "registry+https://github.com/rust-lang/crates.io-index"
3427+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
3428+
3429+[[package]]
3430+name = "string_cache"
3431+version = "0.9.0"
3432+source = "registry+https://github.com/rust-lang/crates.io-index"
3433+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
3434+dependencies = [
3435+ "new_debug_unreachable",
3436+ "parking_lot",
3437+ "phf_shared",
3438+ "precomputed-hash",
3439+]
3440+
3441+[[package]]
3442+name = "string_cache_codegen"
3443+version = "0.6.1"
3444+source = "registry+https://github.com/rust-lang/crates.io-index"
3445+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
3446+dependencies = [
3447+ "phf_generator",
3448+ "phf_shared",
3449+ "proc-macro2",
3450+ "quote",
3451+]
3452+
3453+[[package]]
3454+name = "subtle"
3455+version = "2.6.1"
3456+source = "registry+https://github.com/rust-lang/crates.io-index"
3457+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
3458+
3459+[[package]]
3460+name = "syn"
3461+version = "1.0.109"
3462+source = "registry+https://github.com/rust-lang/crates.io-index"
3463+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
3464+dependencies = [
3465+ "proc-macro2",
3466+ "quote",
3467+ "unicode-ident",
3468+]
3469+
3470+[[package]]
3471+name = "syn"
3472+version = "2.0.119"
3473+source = "registry+https://github.com/rust-lang/crates.io-index"
3474+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
3475+dependencies = [
3476+ "proc-macro2",
3477+ "quote",
3478+ "unicode-ident",
3479+]
3480+
3481+[[package]]
3482+name = "syn"
3483+version = "3.0.6"
3484+source = "registry+https://github.com/rust-lang/crates.io-index"
3485+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
3486+dependencies = [
3487+ "proc-macro2",
3488+ "quote",
3489+ "unicode-ident",
3490+]
3491+
3492+[[package]]
3493+name = "sync_wrapper"
3494+version = "1.0.2"
3495+source = "registry+https://github.com/rust-lang/crates.io-index"
3496+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
3497+dependencies = [
3498+ "futures-core",
3499+]
3500+
3501+[[package]]
3502+name = "synstructure"
3503+version = "0.14.0"
3504+source = "registry+https://github.com/rust-lang/crates.io-index"
3505+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
3506+dependencies = [
3507+ "proc-macro2",
3508+ "quote",
3509+ "syn 3.0.6",
3510+]
3511+
3512+[[package]]
3513+name = "tempfile"
3514+version = "3.27.0"
3515+source = "registry+https://github.com/rust-lang/crates.io-index"
3516+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
3517+dependencies = [
3518+ "fastrand",
3519+ "once_cell",
3520+ "rustix",
3521+ "windows-sys 0.61.2",
3522+]
3523+
3524+[[package]]
3525+name = "tendril"
3526+version = "0.5.1"
3527+source = "registry+https://github.com/rust-lang/crates.io-index"
3528+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
3529+dependencies = [
3530+ "new_debug_unreachable",
3531+]
3532+
3533+[[package]]
3534+name = "thiserror"
3535+version = "1.0.69"
3536+source = "registry+https://github.com/rust-lang/crates.io-index"
3537+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
3538+dependencies = [
3539+ "thiserror-impl 1.0.69",
3540+]
3541+
3542+[[package]]
3543+name = "thiserror"
3544+version = "2.0.21"
3545+source = "registry+https://github.com/rust-lang/crates.io-index"
3546+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
3547+dependencies = [
3548+ "thiserror-impl 2.0.21",
3549+]
3550+
3551+[[package]]
3552+name = "thiserror-impl"
3553+version = "1.0.69"
3554+source = "registry+https://github.com/rust-lang/crates.io-index"
3555+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
3556+dependencies = [
3557+ "proc-macro2",
3558+ "quote",
3559+ "syn 2.0.119",
3560+]
3561+
3562+[[package]]
3563+name = "thiserror-impl"
3564+version = "2.0.21"
3565+source = "registry+https://github.com/rust-lang/crates.io-index"
3566+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
3567+dependencies = [
3568+ "proc-macro2",
3569+ "quote",
3570+ "syn 3.0.6",
3571+]
3572+
3573+[[package]]
3574+name = "thread_local"
3575+version = "1.1.10"
3576+source = "registry+https://github.com/rust-lang/crates.io-index"
3577+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
3578+dependencies = [
3579+ "cfg-if",
3580+]
3581+
3582+[[package]]
3583+name = "time"
3584+version = "0.3.55"
3585+source = "registry+https://github.com/rust-lang/crates.io-index"
3586+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
3587+dependencies = [
3588+ "deranged",
3589+ "num-conv",
3590+ "powerfmt",
3591+ "serde_core",
3592+ "time-core",
3593+ "time-macros",
3594+]
3595+
3596+[[package]]
3597+name = "time-core"
3598+version = "0.1.9"
3599+source = "registry+https://github.com/rust-lang/crates.io-index"
3600+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
3601+
3602+[[package]]
3603+name = "time-macros"
3604+version = "0.2.32"
3605+source = "registry+https://github.com/rust-lang/crates.io-index"
3606+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
3607+dependencies = [
3608+ "num-conv",
3609+ "time-core",
3610+]
3611+
3612+[[package]]
3613+name = "tinystr"
3614+version = "0.8.4"
3615+source = "registry+https://github.com/rust-lang/crates.io-index"
3616+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
3617+dependencies = [
3618+ "displaydoc",
3619+ "zerovec",
3620+]
3621+
3622+[[package]]
3623+name = "tinyvec"
3624+version = "1.13.3"
3625+source = "registry+https://github.com/rust-lang/crates.io-index"
3626+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
3627+
3628+[[package]]
3629+name = "tokio"
3630+version = "1.53.1"
3631+source = "registry+https://github.com/rust-lang/crates.io-index"
3632+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
3633+dependencies = [
3634+ "bytes",
3635+ "libc",
3636+ "mio",
3637+ "pin-project-lite",
3638+ "socket2",
3639+ "tokio-macros",
3640+ "windows-sys 0.61.2",
3641+]
3642+
3643+[[package]]
3644+name = "tokio-macros"
3645+version = "2.7.2"
3646+source = "registry+https://github.com/rust-lang/crates.io-index"
3647+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
3648+dependencies = [
3649+ "proc-macro2",
3650+ "quote",
3651+ "syn 3.0.6",
3652+]
3653+
3654+[[package]]
3655+name = "tokio-rustls"
3656+version = "0.26.5"
3657+source = "registry+https://github.com/rust-lang/crates.io-index"
3658+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
3659+dependencies = [
3660+ "rustls",
3661+ "tokio",
3662+]
3663+
3664+[[package]]
3665+name = "tokio-stream"
3666+version = "0.1.19"
3667+source = "registry+https://github.com/rust-lang/crates.io-index"
3668+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
3669+dependencies = [
3670+ "futures-core",
3671+ "pin-project-lite",
3672+ "tokio",
3673+ "tokio-util",
3674+]
3675+
3676+[[package]]
3677+name = "tokio-util"
3678+version = "0.7.19"
3679+source = "registry+https://github.com/rust-lang/crates.io-index"
3680+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
3681+dependencies = [
3682+ "bytes",
3683+ "futures-core",
3684+ "futures-sink",
3685+ "libc",
3686+ "pin-project-lite",
3687+ "tokio",
3688+]
3689+
3690+[[package]]
3691+name = "toml"
3692+version = "1.1.6+spec-1.1.0"
3693+source = "registry+https://github.com/rust-lang/crates.io-index"
3694+checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
3695+dependencies = [
3696+ "serde_core",
3697+ "serde_spanned",
3698+ "toml_datetime",
3699+ "toml_parser",
3700+ "winnow",
3701+]
3702+
3703+[[package]]
3704+name = "toml_datetime"
3705+version = "1.1.1+spec-1.1.0"
3706+source = "registry+https://github.com/rust-lang/crates.io-index"
3707+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
3708+dependencies = [
3709+ "serde_core",
3710+]
3711+
3712+[[package]]
3713+name = "toml_edit"
3714+version = "0.25.15+spec-1.1.0"
3715+source = "registry+https://github.com/rust-lang/crates.io-index"
3716+checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
3717+dependencies = [
3718+ "indexmap",
3719+ "toml_datetime",
3720+ "toml_parser",
3721+ "winnow",
3722+]
3723+
3724+[[package]]
3725+name = "toml_parser"
3726+version = "1.1.3+spec-1.1.0"
3727+source = "registry+https://github.com/rust-lang/crates.io-index"
3728+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
3729+dependencies = [
3730+ "winnow",
3731+]
3732+
3733+[[package]]
3734+name = "tower"
3735+version = "0.5.3"
3736+source = "registry+https://github.com/rust-lang/crates.io-index"
3737+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
3738+dependencies = [
3739+ "futures-core",
3740+ "futures-util",
3741+ "pin-project-lite",
3742+ "sync_wrapper",
3743+ "tokio",
3744+ "tower-layer",
3745+ "tower-service",
3746+]
3747+
3748+[[package]]
3749+name = "tower-http"
3750+version = "0.6.11"
3751+source = "registry+https://github.com/rust-lang/crates.io-index"
3752+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
3753+dependencies = [
3754+ "async-compression",
3755+ "bitflags",
3756+ "bytes",
3757+ "futures-core",
3758+ "futures-util",
3759+ "http",
3760+ "http-body",
3761+ "http-body-util",
3762+ "pin-project-lite",
3763+ "tokio",
3764+ "tokio-util",
3765+ "tower",
3766+ "tower-layer",
3767+ "tower-service",
3768+ "url",
3769+]
3770+
3771+[[package]]
3772+name = "tower-layer"
3773+version = "0.3.3"
3774+source = "registry+https://github.com/rust-lang/crates.io-index"
3775+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
3776+
3777+[[package]]
3778+name = "tower-service"
3779+version = "0.3.3"
3780+source = "registry+https://github.com/rust-lang/crates.io-index"
3781+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
3782+
3783+[[package]]
3784+name = "tracing"
3785+version = "0.1.44"
3786+source = "registry+https://github.com/rust-lang/crates.io-index"
3787+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
3788+dependencies = [
3789+ "pin-project-lite",
3790+ "tracing-attributes",
3791+ "tracing-core",
3792+]
3793+
3794+[[package]]
3795+name = "tracing-attributes"
3796+version = "0.1.31"
3797+source = "registry+https://github.com/rust-lang/crates.io-index"
3798+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
3799+dependencies = [
3800+ "proc-macro2",
3801+ "quote",
3802+ "syn 2.0.119",
3803+]
3804+
3805+[[package]]
3806+name = "tracing-core"
3807+version = "0.1.36"
3808+source = "registry+https://github.com/rust-lang/crates.io-index"
3809+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
3810+dependencies = [
3811+ "once_cell",
3812+ "valuable",
3813+]
3814+
3815+[[package]]
3816+name = "tracing-log"
3817+version = "0.2.0"
3818+source = "registry+https://github.com/rust-lang/crates.io-index"
3819+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
3820+dependencies = [
3821+ "log",
3822+ "once_cell",
3823+ "tracing-core",
3824+]
3825+
3826+[[package]]
3827+name = "tracing-subscriber"
3828+version = "0.3.23"
3829+source = "registry+https://github.com/rust-lang/crates.io-index"
3830+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
3831+dependencies = [
3832+ "matchers",
3833+ "nu-ansi-term",
3834+ "once_cell",
3835+ "regex-automata",
3836+ "sharded-slab",
3837+ "smallvec",
3838+ "thread_local",
3839+ "tracing",
3840+ "tracing-core",
3841+ "tracing-log",
3842+]
3843+
3844+[[package]]
3845+name = "try-lock"
3846+version = "0.2.5"
3847+source = "registry+https://github.com/rust-lang/crates.io-index"
3848+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
3849+
3850+[[package]]
3851+name = "typenum"
3852+version = "1.20.1"
3853+source = "registry+https://github.com/rust-lang/crates.io-index"
3854+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
3855+
3856+[[package]]
3857+name = "typewit"
3858+version = "1.15.2"
3859+source = "registry+https://github.com/rust-lang/crates.io-index"
3860+checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6"
3861+
3862+[[package]]
3863+name = "ulid"
3864+version = "1.2.1"
3865+source = "registry+https://github.com/rust-lang/crates.io-index"
3866+checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
3867+dependencies = [
3868+ "rand 0.9.5",
3869+ "web-time",
3870+]
3871+
3872+[[package]]
3873+name = "unicode-ident"
3874+version = "1.0.26"
3875+source = "registry+https://github.com/rust-lang/crates.io-index"
3876+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
3877+
3878+[[package]]
3879+name = "unicode-normalization"
3880+version = "0.1.25"
3881+source = "registry+https://github.com/rust-lang/crates.io-index"
3882+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
3883+dependencies = [
3884+ "tinyvec",
3885+]
3886+
3887+[[package]]
3888+name = "unicode-segmentation"
3889+version = "1.13.3"
3890+source = "registry+https://github.com/rust-lang/crates.io-index"
3891+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
3892+
3893+[[package]]
3894+name = "unicode-xid"
3895+version = "0.2.6"
3896+source = "registry+https://github.com/rust-lang/crates.io-index"
3897+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
3898+
3899+[[package]]
3900+name = "universal-hash"
3901+version = "0.5.1"
3902+source = "registry+https://github.com/rust-lang/crates.io-index"
3903+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
3904+dependencies = [
3905+ "crypto-common",
3906+ "subtle",
3907+]
3908+
3909+[[package]]
3910+name = "untrusted"
3911+version = "0.9.0"
3912+source = "registry+https://github.com/rust-lang/crates.io-index"
3913+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
3914+
3915+[[package]]
3916+name = "url"
3917+version = "2.5.8"
3918+source = "registry+https://github.com/rust-lang/crates.io-index"
3919+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
3920+dependencies = [
3921+ "form_urlencoded",
3922+ "idna",
3923+ "percent-encoding",
3924+ "serde",
3925+ "serde_derive",
3926+]
3927+
3928+[[package]]
3929+name = "urlencoding"
3930+version = "2.1.3"
3931+source = "registry+https://github.com/rust-lang/crates.io-index"
3932+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
3933+
3934+[[package]]
3935+name = "utf8_iter"
3936+version = "1.0.4"
3937+source = "registry+https://github.com/rust-lang/crates.io-index"
3938+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
3939+
3940+[[package]]
3941+name = "uuid"
3942+version = "1.26.1"
3943+source = "registry+https://github.com/rust-lang/crates.io-index"
3944+checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
3945+dependencies = [
3946+ "getrandom 0.4.3",
3947+ "js-sys",
3948+ "serde_core",
3949+ "wasm-bindgen",
3950+]
3951+
3952+[[package]]
3953+name = "valuable"
3954+version = "0.1.1"
3955+source = "registry+https://github.com/rust-lang/crates.io-index"
3956+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
3957+
3958+[[package]]
3959+name = "vcpkg"
3960+version = "0.2.15"
3961+source = "registry+https://github.com/rust-lang/crates.io-index"
3962+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
3963+
3964+[[package]]
3965+name = "version_check"
3966+version = "0.9.5"
3967+source = "registry+https://github.com/rust-lang/crates.io-index"
3968+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
3969+
3970+[[package]]
3971+name = "vodozemac"
3972+version = "0.10.0"
3973+source = "registry+https://github.com/rust-lang/crates.io-index"
3974+checksum = "b98bf83c0992966775b8012f194b07b44928996163e5a05b741b43891571ae5b"
3975+dependencies = [
3976+ "aes",
3977+ "arrayvec",
3978+ "base64 0.22.1",
3979+ "base64ct",
3980+ "cbc",
3981+ "chacha20poly1305",
3982+ "curve25519-dalek",
3983+ "ed25519-dalek",
3984+ "getrandom 0.2.17",
3985+ "hkdf",
3986+ "hmac",
3987+ "matrix-pickle",
3988+ "prost",
3989+ "rand 0.8.8",
3990+ "serde",
3991+ "serde_bytes",
3992+ "serde_json",
3993+ "sha2",
3994+ "subtle",
3995+ "thiserror 2.0.21",
3996+ "x25519-dalek",
3997+ "zeroize",
3998+]
3999+
4000+[[package]]
4001+name = "walkdir"
4002+version = "2.5.0"
4003+source = "registry+https://github.com/rust-lang/crates.io-index"
4004+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
4005+dependencies = [
4006+ "same-file",
4007+ "winapi-util",
4008+]
4009+
4010+[[package]]
4011+name = "want"
4012+version = "0.3.1"
4013+source = "registry+https://github.com/rust-lang/crates.io-index"
4014+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
4015+dependencies = [
4016+ "try-lock",
4017+]
4018+
4019+[[package]]
4020+name = "wasi"
4021+version = "0.11.1+wasi-snapshot-preview1"
4022+source = "registry+https://github.com/rust-lang/crates.io-index"
4023+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
4024+
4025+[[package]]
4026+name = "wasip2"
4027+version = "1.0.4+wasi-0.2.12"
4028+source = "registry+https://github.com/rust-lang/crates.io-index"
4029+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
4030+dependencies = [
4031+ "wit-bindgen",
4032+]
4033+
4034+[[package]]
4035+name = "wasm-bindgen"
4036+version = "0.2.128"
4037+source = "registry+https://github.com/rust-lang/crates.io-index"
4038+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
4039+dependencies = [
4040+ "cfg-if",
4041+ "once_cell",
4042+ "rustversion",
4043+ "wasm-bindgen-macro",
4044+ "wasm-bindgen-shared",
4045+]
4046+
4047+[[package]]
4048+name = "wasm-bindgen-futures"
4049+version = "0.4.78"
4050+source = "registry+https://github.com/rust-lang/crates.io-index"
4051+checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
4052+dependencies = [
4053+ "js-sys",
4054+ "wasm-bindgen",
4055+]
4056+
4057+[[package]]
4058+name = "wasm-bindgen-macro"
4059+version = "0.2.128"
4060+source = "registry+https://github.com/rust-lang/crates.io-index"
4061+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
4062+dependencies = [
4063+ "quote",
4064+ "wasm-bindgen-macro-support",
4065+]
4066+
4067+[[package]]
4068+name = "wasm-bindgen-macro-support"
4069+version = "0.2.128"
4070+source = "registry+https://github.com/rust-lang/crates.io-index"
4071+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
4072+dependencies = [
4073+ "bumpalo",
4074+ "proc-macro2",
4075+ "quote",
4076+ "syn 3.0.6",
4077+ "wasm-bindgen-shared",
4078+]
4079+
4080+[[package]]
4081+name = "wasm-bindgen-shared"
4082+version = "0.2.128"
4083+source = "registry+https://github.com/rust-lang/crates.io-index"
4084+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
4085+dependencies = [
4086+ "unicode-ident",
4087+]
4088+
4089+[[package]]
4090+name = "wasm-bindgen-test"
4091+version = "0.3.78"
4092+source = "registry+https://github.com/rust-lang/crates.io-index"
4093+checksum = "45863ef0bef521c12124eb39d9a38513c47db75f22e503c06beacd40afeb35db"
4094+dependencies = [
4095+ "async-trait",
4096+ "cast",
4097+ "js-sys",
4098+ "libm",
4099+ "minicov",
4100+ "nu-ansi-term",
4101+ "num-traits",
4102+ "oorandom",
4103+ "serde",
4104+ "serde_json",
4105+ "wasm-bindgen",
4106+ "wasm-bindgen-futures",
4107+ "wasm-bindgen-test-macro",
4108+ "wasm-bindgen-test-shared",
4109+]
4110+
4111+[[package]]
4112+name = "wasm-bindgen-test-macro"
4113+version = "0.3.78"
4114+source = "registry+https://github.com/rust-lang/crates.io-index"
4115+checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12"
4116+dependencies = [
4117+ "proc-macro2",
4118+ "quote",
4119+ "syn 3.0.6",
4120+]
4121+
4122+[[package]]
4123+name = "wasm-bindgen-test-shared"
4124+version = "0.2.128"
4125+source = "registry+https://github.com/rust-lang/crates.io-index"
4126+checksum = "f37b4f992cebe528ef34964ae69681ac0fe7080071e7298e46008f9d380302af"
4127+
4128+[[package]]
4129+name = "wasm-streams"
4130+version = "0.5.0"
4131+source = "registry+https://github.com/rust-lang/crates.io-index"
4132+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
4133+dependencies = [
4134+ "futures-util",
4135+ "js-sys",
4136+ "wasm-bindgen",
4137+ "wasm-bindgen-futures",
4138+ "web-sys",
4139+]
4140+
4141+[[package]]
4142+name = "wasm_evt_listener"
4143+version = "0.1.0"
4144+source = "registry+https://github.com/rust-lang/crates.io-index"
4145+checksum = "dc92d6378b411ed94839112a36d9dbc77143451d85b05dfb0cce93a78dab1963"
4146+dependencies = [
4147+ "accessory",
4148+ "derivative",
4149+ "derive_more 1.0.0",
4150+ "fancy_constructor",
4151+ "futures-core",
4152+ "js-sys",
4153+ "smallvec",
4154+ "tokio",
4155+ "wasm-bindgen",
4156+ "web-sys",
4157+]
4158+
4159+[[package]]
4160+name = "web-sys"
4161+version = "0.3.105"
4162+source = "registry+https://github.com/rust-lang/crates.io-index"
4163+checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
4164+dependencies = [
4165+ "js-sys",
4166+ "wasm-bindgen",
4167+]
4168+
4169+[[package]]
4170+name = "web-time"
4171+version = "1.1.0"
4172+source = "registry+https://github.com/rust-lang/crates.io-index"
4173+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
4174+dependencies = [
4175+ "js-sys",
4176+ "serde",
4177+ "wasm-bindgen",
4178+]
4179+
4180+[[package]]
4181+name = "web_atoms"
4182+version = "0.2.6"
4183+source = "registry+https://github.com/rust-lang/crates.io-index"
4184+checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
4185+dependencies = [
4186+ "phf",
4187+ "phf_codegen",
4188+ "string_cache",
4189+ "string_cache_codegen",
4190+]
4191+
4192+[[package]]
4193+name = "webpki-root-certs"
4194+version = "1.0.9"
4195+source = "registry+https://github.com/rust-lang/crates.io-index"
4196+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
4197+dependencies = [
4198+ "rustls-pki-types",
4199+]
4200+
4201+[[package]]
4202+name = "webpki-roots"
4203+version = "1.0.9"
4204+source = "registry+https://github.com/rust-lang/crates.io-index"
4205+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
4206+dependencies = [
4207+ "rustls-pki-types",
4208+]
4209+
4210+[[package]]
4211+name = "wildmatch"
4212+version = "2.6.1"
4213+source = "registry+https://github.com/rust-lang/crates.io-index"
4214+checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68"
4215+
4216+[[package]]
4217+name = "winapi-util"
4218+version = "0.1.11"
4219+source = "registry+https://github.com/rust-lang/crates.io-index"
4220+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
4221+dependencies = [
4222+ "windows-sys 0.61.2",
4223+]
4224+
4225+[[package]]
4226+name = "windows-core"
4227+version = "0.62.2"
4228+source = "registry+https://github.com/rust-lang/crates.io-index"
4229+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
4230+dependencies = [
4231+ "windows-implement",
4232+ "windows-interface",
4233+ "windows-link",
4234+ "windows-result",
4235+ "windows-strings",
4236+]
4237+
4238+[[package]]
4239+name = "windows-implement"
4240+version = "0.60.2"
4241+source = "registry+https://github.com/rust-lang/crates.io-index"
4242+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
4243+dependencies = [
4244+ "proc-macro2",
4245+ "quote",
4246+ "syn 2.0.119",
4247+]
4248+
4249+[[package]]
4250+name = "windows-interface"
4251+version = "0.59.3"
4252+source = "registry+https://github.com/rust-lang/crates.io-index"
4253+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
4254+dependencies = [
4255+ "proc-macro2",
4256+ "quote",
4257+ "syn 2.0.119",
4258+]
4259+
4260+[[package]]
4261+name = "windows-link"
4262+version = "0.2.1"
4263+source = "registry+https://github.com/rust-lang/crates.io-index"
4264+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
4265+
4266+[[package]]
4267+name = "windows-result"
4268+version = "0.4.1"
4269+source = "registry+https://github.com/rust-lang/crates.io-index"
4270+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
4271+dependencies = [
4272+ "windows-link",
4273+]
4274+
4275+[[package]]
4276+name = "windows-strings"
4277+version = "0.5.1"
4278+source = "registry+https://github.com/rust-lang/crates.io-index"
4279+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
4280+dependencies = [
4281+ "windows-link",
4282+]
4283+
4284+[[package]]
4285+name = "windows-sys"
4286+version = "0.52.0"
4287+source = "registry+https://github.com/rust-lang/crates.io-index"
4288+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
4289+dependencies = [
4290+ "windows-targets",
4291+]
4292+
4293+[[package]]
4294+name = "windows-sys"
4295+version = "0.61.2"
4296+source = "registry+https://github.com/rust-lang/crates.io-index"
4297+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
4298+dependencies = [
4299+ "windows-link",
4300+]
4301+
4302+[[package]]
4303+name = "windows-targets"
4304+version = "0.52.6"
4305+source = "registry+https://github.com/rust-lang/crates.io-index"
4306+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
4307+dependencies = [
4308+ "windows_aarch64_gnullvm",
4309+ "windows_aarch64_msvc",
4310+ "windows_i686_gnu",
4311+ "windows_i686_gnullvm",
4312+ "windows_i686_msvc",
4313+ "windows_x86_64_gnu",
4314+ "windows_x86_64_gnullvm",
4315+ "windows_x86_64_msvc",
4316+]
4317+
4318+[[package]]
4319+name = "windows_aarch64_gnullvm"
4320+version = "0.52.6"
4321+source = "registry+https://github.com/rust-lang/crates.io-index"
4322+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
4323+
4324+[[package]]
4325+name = "windows_aarch64_msvc"
4326+version = "0.52.6"
4327+source = "registry+https://github.com/rust-lang/crates.io-index"
4328+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
4329+
4330+[[package]]
4331+name = "windows_i686_gnu"
4332+version = "0.52.6"
4333+source = "registry+https://github.com/rust-lang/crates.io-index"
4334+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
4335+
4336+[[package]]
4337+name = "windows_i686_gnullvm"
4338+version = "0.52.6"
4339+source = "registry+https://github.com/rust-lang/crates.io-index"
4340+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
4341+
4342+[[package]]
4343+name = "windows_i686_msvc"
4344+version = "0.52.6"
4345+source = "registry+https://github.com/rust-lang/crates.io-index"
4346+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
4347+
4348+[[package]]
4349+name = "windows_x86_64_gnu"
4350+version = "0.52.6"
4351+source = "registry+https://github.com/rust-lang/crates.io-index"
4352+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
4353+
4354+[[package]]
4355+name = "windows_x86_64_gnullvm"
4356+version = "0.52.6"
4357+source = "registry+https://github.com/rust-lang/crates.io-index"
4358+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
4359+
4360+[[package]]
4361+name = "windows_x86_64_msvc"
4362+version = "0.52.6"
4363+source = "registry+https://github.com/rust-lang/crates.io-index"
4364+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
4365+
4366+[[package]]
4367+name = "winnow"
4368+version = "1.0.4"
4369+source = "registry+https://github.com/rust-lang/crates.io-index"
4370+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
4371+dependencies = [
4372+ "memchr",
4373+]
4374+
4375+[[package]]
4376+name = "wit-bindgen"
4377+version = "0.57.1"
4378+source = "registry+https://github.com/rust-lang/crates.io-index"
4379+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
4380+
4381+[[package]]
4382+name = "writeable"
4383+version = "0.6.4"
4384+source = "registry+https://github.com/rust-lang/crates.io-index"
4385+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
4386+
4387+[[package]]
4388+name = "x25519-dalek"
4389+version = "2.0.1"
4390+source = "registry+https://github.com/rust-lang/crates.io-index"
4391+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
4392+dependencies = [
4393+ "curve25519-dalek",
4394+ "rand_core 0.6.4",
4395+ "serde",
4396+ "zeroize",
4397+]
4398+
4399+[[package]]
4400+name = "xxhash-rust"
4401+version = "0.8.18"
4402+source = "registry+https://github.com/rust-lang/crates.io-index"
4403+checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
4404+
4405+[[package]]
4406+name = "yoke"
4407+version = "0.8.3"
4408+source = "registry+https://github.com/rust-lang/crates.io-index"
4409+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
4410+dependencies = [
4411+ "stable_deref_trait",
4412+ "yoke-derive",
4413+ "zerofrom",
4414+]
4415+
4416+[[package]]
4417+name = "yoke-derive"
4418+version = "0.8.3"
4419+source = "registry+https://github.com/rust-lang/crates.io-index"
4420+checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
4421+dependencies = [
4422+ "proc-macro2",
4423+ "quote",
4424+ "syn 3.0.6",
4425+ "synstructure",
4426+]
4427+
4428+[[package]]
4429+name = "zerocopy"
4430+version = "0.8.59"
4431+source = "registry+https://github.com/rust-lang/crates.io-index"
4432+checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
4433+dependencies = [
4434+ "zerocopy-derive",
4435+]
4436+
4437+[[package]]
4438+name = "zerocopy-derive"
4439+version = "0.8.59"
4440+source = "registry+https://github.com/rust-lang/crates.io-index"
4441+checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
4442+dependencies = [
4443+ "proc-macro2",
4444+ "quote",
4445+ "syn 2.0.119",
4446+]
4447+
4448+[[package]]
4449+name = "zerofrom"
4450+version = "0.1.8"
4451+source = "registry+https://github.com/rust-lang/crates.io-index"
4452+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
4453+dependencies = [
4454+ "zerofrom-derive",
4455+]
4456+
4457+[[package]]
4458+name = "zerofrom-derive"
4459+version = "0.1.8"
4460+source = "registry+https://github.com/rust-lang/crates.io-index"
4461+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
4462+dependencies = [
4463+ "proc-macro2",
4464+ "quote",
4465+ "syn 3.0.6",
4466+ "synstructure",
4467+]
4468+
4469+[[package]]
4470+name = "zeroize"
4471+version = "1.9.0"
4472+source = "registry+https://github.com/rust-lang/crates.io-index"
4473+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
4474+dependencies = [
4475+ "zeroize_derive",
4476+]
4477+
4478+[[package]]
4479+name = "zeroize_derive"
4480+version = "1.5.0"
4481+source = "registry+https://github.com/rust-lang/crates.io-index"
4482+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
4483+dependencies = [
4484+ "proc-macro2",
4485+ "quote",
4486+ "syn 2.0.119",
4487+]
4488+
4489+[[package]]
4490+name = "zerotrie"
4491+version = "0.2.5"
4492+source = "registry+https://github.com/rust-lang/crates.io-index"
4493+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
4494+dependencies = [
4495+ "displaydoc",
4496+ "yoke",
4497+ "zerofrom",
4498+]
4499+
4500+[[package]]
4501+name = "zerovec"
4502+version = "0.11.8"
4503+source = "registry+https://github.com/rust-lang/crates.io-index"
4504+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
4505+dependencies = [
4506+ "yoke",
4507+ "zerofrom",
4508+ "zerovec-derive",
4509+]
4510+
4511+[[package]]
4512+name = "zerovec-derive"
4513+version = "0.11.6"
4514+source = "registry+https://github.com/rust-lang/crates.io-index"
4515+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
4516+dependencies = [
4517+ "proc-macro2",
4518+ "quote",
4519+ "syn 3.0.6",
4520+]
4521+
4522+[[package]]
4523+name = "zlib-rs"
4524+version = "0.6.8"
4525+source = "registry+https://github.com/rust-lang/crates.io-index"
4526+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
4527+
4528+[[package]]
4529+name = "zmij"
4530+version = "1.0.23"
4531+source = "registry+https://github.com/rust-lang/crates.io-index"
4532+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Added file: crates/malice-worker/Cargo.toml+47 additions
@@ -0,0 +1,47 @@
1+[package]
2+name = "malice-worker"
3+version = "0.1.0"
4+edition = "2024"
5+publish = false
6+
7+# malice-worker's wasm profile needs are workspace-wide settings (debug info off
8+# for dev, panic=abort and LTO for release) that would otherwise leak into
9+# malice-server's native build if this crate stayed in the root workspace, so it
10+# gets its own, like macltok's harness/web.
11+[workspace]
12+
13+[lib]
14+crate-type = ["cdylib", "rlib"]
15+
16+[target.'cfg(target_family = "wasm")'.dependencies]
17+macltok = { path = "../../vendor/macltok", features = ["indexeddb", "serde", "mirror"] }
18+malice-core = { path = "../malice-core" }
19+ruma = { version = "0.16", default-features = false }
20+futures-util = { version = "0.3", default-features = false, features = ["std"] }
21+futures-channel = "0.3"
22+serde_json = "1"
23+# pinned exactly so flake.nix's wasmBindgenCli, built for this locked version, matches
24+# what wasm-bindgen-test-runner needs at test time
25+wasm-bindgen = "=0.2.128"
26+wasm-bindgen-futures = "=0.4.78"
27+js-sys = "=0.3.105"
28+
29+[dev-dependencies]
30+wasm-bindgen-test = "0.3"
31+web-sys = { version = "0.3", features = ["SharedWorkerGlobalScope"] }
32+gloo-timers = { version = "0.3", features = ["futures"] }
33+
34+[lints.rust]
35+unsafe_code = "forbid"
36+
37+# with debuginfo the test wasm balloons and can OOM the test runner, per macltok's
38+# own harness/web/Cargo.toml
39+[profile.dev]
40+debug = false
41+
42+[profile.release]
43+opt-level = "z"
44+lto = true
45+codegen-units = 1
46+panic = "abort"
47+strip = true
Added file: crates/malice-worker/run.nu+92 additions
@@ -0,0 +1,92 @@
1+# drives malice-worker's Host in a headless firefox shared worker against a throwaway
2+# continuwuity, `nix develop -c nu crates/malice-worker/run.nu`
3+
4+const crate = path self .
5+const root = path self ../..
6+const username = "malice-harness"
7+const password = "malice-harness-password"
8+const room_name = "malice-harness"
9+const seeded = "seeded by run.nu"
10+
11+# the shared build wrapper sets CARGO_TARGET_DIR, the nix sandbox does not
12+def target [] { $env.CARGO_TARGET_DIR? | default ($root | path join target) }
13+
14+def scratch [] { target | path join harness }
15+
16+def start-homeserver [] {
17+ let dir = scratch
18+ rm -rf $dir
19+ mkdir $dir
20+ let port = 6267
21+ let config = $dir | path join homeserver.toml
22+ let log = $dir | path join homeserver.log
23+ {
24+ global: {
25+ server_name: "localhost"
26+ database_path: ($dir | path join db)
27+ address: ["127.0.0.1"]
28+ port: $port
29+ allow_federation: false
30+ new_user_displayname_suffix: ""
31+ }
32+ } | to toml | save $config
33+ # the first account otherwise needs a one-time token that is only ever printed to the log
34+ let create = $"users create-user ($username) ($password)"
35+ let pid = ^bash -c 'conduwuit -c "$1" --execute "$2" > "$3" 2>&1 & echo $!' _ $config $create $log | str trim
36+ let url = $'http://127.0.0.1:($port)'
37+ for _ in 1..100 {
38+ if (try { login $url | ignore; true } catch { false }) {
39+ return { pid: $pid, url: $url }
40+ }
41+ sleep 100ms
42+ }
43+ try { stop $pid }
44+ print (open --raw $log | lines | last 20 | str join "\n")
45+ error make { msg: "homeserver did not come up" }
46+}
47+
48+def login [url: string] {
49+ let body = { type: "m.login.password", identifier: { type: "m.id.user", user: $username }, password: $password }
50+ http post --content-type application/json $'($url)/_matrix/client/v3/login' $body
51+}
52+
53+def seed [url: string] {
54+ let auth = [Authorization $'Bearer ((login $url).access_token)']
55+ let room = http post --headers $auth --content-type application/json $'($url)/_matrix/client/v3/createRoom' { name: $room_name }
56+ let room_id = $room.room_id | url encode --all
57+ let message = { msgtype: "m.text", body: $seeded }
58+ http put --headers $auth --content-type application/json $'($url)/_matrix/client/v3/rooms/($room_id)/send/m.room.message/seed' $message | ignore
59+}
60+
61+def stop [pid: string] {
62+ ^kill -9 $pid
63+}
64+
65+def main [] {
66+ let server = start-homeserver
67+ let result = try {
68+ let versions = http get $'($server.url)/_matrix/client/versions'
69+ if not ($versions.unstable_features | get -o 'org.matrix.simplified_msc3575' | default false) {
70+ error make { msg: "homeserver does not advertise simplified sliding sync" }
71+ }
72+ seed $server.url
73+ cd $crate
74+ with-env {
75+ MALICE_HOMESERVER: $server.url
76+ MALICE_USERNAME: $username
77+ MALICE_PASSWORD: $password
78+ MALICE_ROOM_NAME: $room_name
79+ MALICE_SEEDED: $seeded
80+ GECKODRIVER: (which geckodriver | get 0.path)
81+ WASM_BINDGEN_TEST_TIMEOUT: "180"
82+ } {
83+ ^cargo test --target wasm32-unknown-unknown --test host
84+ }
85+ null
86+ } catch {|error| $error }
87+ stop $server.pid
88+ if $result != null {
89+ print $'homeserver log at (scratch)/homeserver.log'
90+ error make { msg: $result.msg }
91+ }
92+}
Added file: crates/malice-worker/src/account.rs+206 additions
@@ -0,0 +1,206 @@
1+use std::rc::Rc;
2+use std::sync::Arc;
3+
4+use futures_util::StreamExt;
5+use macltok::{
6+ AccountError, ClientHandle, EngineConfig, Mirror, MirrorHandle, PasswordLogin, Retain, Session,
7+ SessionStatus, SessionStore, StateChange,
8+};
9+
10+use crate::HostInner;
11+
12+pub struct Account {
13+ pub handle: ClientHandle,
14+ pub mirror: MirrorHandle,
15+ pub sessions: Arc<dyn SessionStore>,
16+ /// dropping this cancels the account's forwarding task, releasing its
17+ /// `ClientHandle` clone; see the note on `Drop for Host`
18+ _cancel: futures_channel::oneshot::Sender<()>,
19+ engine_stopped: futures_channel::oneshot::Receiver<()>,
20+ mirror_stopped: futures_channel::oneshot::Receiver<()>,
21+}
22+
23+impl Account {
24+ /// The store lock frees only once the engine task has returned and every
25+ /// `ClientHandle` clone is gone, so this waits on both spawned tasks.
26+ pub async fn shutdown(self) {
27+ let _ = self.handle.shutdown().await;
28+ let Account {
29+ handle,
30+ _cancel,
31+ engine_stopped,
32+ mirror_stopped,
33+ ..
34+ } = self;
35+ drop((handle, _cancel));
36+ let _ = engine_stopped.await;
37+ let _ = mirror_stopped.await;
38+ }
39+}
40+
41+pub struct LoginFailure {
42+ pub kind: &'static str,
43+ pub message: String,
44+}
45+
46+impl LoginFailure {
47+ fn new(kind: &'static str, message: impl Into<String>) -> Self {
48+ Self {
49+ kind,
50+ message: message.into(),
51+ }
52+ }
53+}
54+
55+pub fn store_name(account: &str) -> String {
56+ format!("malice-{account}")
57+}
58+
59+pub fn username_matches(submitted: &str, user_id: &ruma::OwnedUserId) -> bool {
60+ let submitted = submitted.trim();
61+ submitted == user_id.as_str() || submitted == user_id.localpart()
62+}
63+
64+pub fn restore_error_kind(error: &AccountError) -> (&'static str, String) {
65+ match error {
66+ AccountError::StoreInUse => (
67+ "store_in_use",
68+ "another connection is using this session".to_owned(),
69+ ),
70+ error => (
71+ "stale",
72+ format!("restoring persisted session failed: {error}"),
73+ ),
74+ }
75+}
76+
77+pub async fn login_with(
78+ login: PasswordLogin,
79+ sessions: Arc<dyn SessionStore>,
80+) -> Result<Session, LoginFailure> {
81+ match Session::login_password(login, sessions).await {
82+ Ok(session) => Ok(session),
83+ Err(AccountError::StoreInUse) => Err(LoginFailure::new(
84+ "store_in_use",
85+ "another engine is using this session",
86+ )),
87+ Err(AccountError::Unsaved { session, .. }) => session
88+ .save()
89+ .await
90+ .map_err(|_| LoginFailure::new("upstream", "could not save the session")),
91+ Err(error) => Err(LoginFailure::new(
92+ "unauthorized",
93+ format!(
94+ "login failed: {}",
95+ malice_core::login::describe_error(&error)
96+ ),
97+ )),
98+ }
99+}
100+
101+/// Waits for the mirror to learn the user id, or for the session to leave
102+/// `Active` before that happens. Unlike the server's `wait_authenticated`,
103+/// there is no timeout: a wasm host has no request deadline to honour.
104+async fn wait_authenticated(mirror: &MirrorHandle) -> Option<String> {
105+ let mut updates = mirror.subscribe();
106+ loop {
107+ let update = updates.next().await?;
108+ match update.change {
109+ StateChange::State(state) => {
110+ if let Some(user_id) = &state.user_id {
111+ return Some(user_id.to_string());
112+ }
113+ if state.session != SessionStatus::Active {
114+ return None;
115+ }
116+ }
117+ StateChange::Authenticated(user_id) => return Some(user_id.to_string()),
118+ StateChange::Session(status) if status != SessionStatus::Active => return None,
119+ _ => {}
120+ }
121+ }
122+}
123+
124+impl HostInner {
125+ pub async fn start_account(
126+ self: &Rc<Self>,
127+ account: String,
128+ session: Session,
129+ sessions: Arc<dyn SessionStore>,
130+ ) -> Result<String, String> {
131+ let (engine, handle, events) = session
132+ .build(EngineConfig::default())
133+ .await
134+ .map_err(|error| format!("starting sync engine: {error}"))?;
135+ let (mirror, mirror_handle) = Mirror::new(handle.clone(), events);
136+
137+ let (cancel_tx, cancel_rx) = futures_channel::oneshot::channel::<()>();
138+ let (engine_stopped_tx, engine_stopped) = futures_channel::oneshot::channel::<()>();
139+ let (mirror_stopped_tx, mirror_stopped) = futures_channel::oneshot::channel::<()>();
140+ {
141+ let inner = self.clone();
142+ let account = account.clone();
143+ let mirror_handle = mirror_handle.clone();
144+ let forward = async move {
145+ let mut events = malice_core::feed::subscribe(&mirror_handle);
146+ while let Some(event) = events.next().await {
147+ if let Ok(json) = serde_json::to_string(&event) {
148+ inner.call_on_event(&account, &json);
149+ }
150+ }
151+ };
152+ let combined = Box::pin(futures_util::future::join(mirror.run(), forward));
153+ wasm_bindgen_futures::spawn_local(async move {
154+ let _stopped = mirror_stopped_tx;
155+ futures_util::future::select(combined, cancel_rx).await;
156+ });
157+ }
158+ {
159+ let inner = self.clone();
160+ let account = account.clone();
161+ wasm_bindgen_futures::spawn_local(async move {
162+ let _stopped = engine_stopped_tx;
163+ let retain = match engine.run().await {
164+ Ok(termination) => termination.retain(),
165+ Err(_error) => Retain::RecordAndStore,
166+ };
167+ inner.remove_account(&account, retain).await;
168+ });
169+ }
170+
171+ let Some(user_id) = wait_authenticated(&mirror_handle).await else {
172+ return Err("sync engine stopped before authenticating".to_owned());
173+ };
174+
175+ self.accounts.borrow_mut().insert(
176+ account,
177+ Account {
178+ handle,
179+ mirror: mirror_handle,
180+ sessions,
181+ _cancel: cancel_tx,
182+ engine_stopped,
183+ mirror_stopped,
184+ },
185+ );
186+ Ok(user_id)
187+ }
188+
189+ /// Removes the account and applies its retention. The map's `remove` is
190+ /// the arbitration point between a racing explicit logout and the
191+ /// engine's own termination, same as the server's `Option::take` on its
192+ /// `JoinHandle`: whichever gets here first does the work, the other
193+ /// finds nothing and returns.
194+ pub async fn remove_account(self: &Rc<Self>, account: &str, retain: Retain) {
195+ let Some(entry) = self.accounts.borrow_mut().remove(account) else {
196+ return;
197+ };
198+ let result = match retain {
199+ Retain::RecordAndStore => Ok(()),
200+ Retain::Store => entry.sessions.soft_log_out().await,
201+ Retain::Nothing => entry.sessions.discard().await,
202+ };
203+ drop(result);
204+ self.emit_session_ended(account, retain);
205+ }
206+}
Added file: crates/malice-worker/src/lib.rs+260 additions
@@ -0,0 +1,260 @@
1+#![cfg(target_family = "wasm")]
2+
3+mod account;
4+mod wire;
5+
6+use std::cell::RefCell;
7+use std::collections::HashMap;
8+use std::rc::Rc;
9+use std::sync::Arc;
10+
11+use macltok::{
12+ EncryptedSessionStore, MediaId, MediaLocation, MediaSize, PasswordLogin, Restore, Retain,
13+ Session, SessionStore, StoreConfig,
14+};
15+use malice_core::error::CoreError;
16+use malice_core::feed::WireEvent;
17+use malice_core::login::LoginInput;
18+use malice_core::media::THUMBNAIL_SIZE;
19+use malice_core::request::{Request, dispatch};
20+use wasm_bindgen::prelude::*;
21+
22+use account::{Account, login_with, restore_error_kind, store_name, username_matches};
23+use wire::{core_error_value, error_value, reply_value, to_js_value};
24+
25+pub(crate) struct HostInner {
26+ accounts: RefCell<HashMap<String, Account>>,
27+ on_event: js_sys::Function,
28+ on_fatal: js_sys::Function,
29+}
30+
31+impl HostInner {
32+ fn call_on_event(&self, account: &str, json: &str) {
33+ let _ = self.on_event.call2(
34+ &JsValue::NULL,
35+ &JsValue::from_str(account),
36+ &JsValue::from_str(json),
37+ );
38+ }
39+
40+ fn call_on_fatal(&self, message: &str) {
41+ let _ = self
42+ .on_fatal
43+ .call1(&JsValue::NULL, &JsValue::from_str(message));
44+ }
45+
46+ fn emit_session_ended(&self, account: &str, retain: Retain) {
47+ let event = WireEvent::SessionEnded {
48+ retain: retain.into(),
49+ };
50+ if let Ok(json) = serde_json::to_string(&event) {
51+ self.call_on_event(account, &json);
52+ }
53+ }
54+}
55+
56+#[wasm_bindgen]
57+pub struct Host(Rc<HostInner>);
58+
59+#[wasm_bindgen]
60+impl Host {
61+ #[wasm_bindgen(constructor)]
62+ pub fn new(on_event: js_sys::Function, on_fatal: js_sys::Function) -> Host {
63+ let inner = Rc::new(HostInner {
64+ accounts: RefCell::new(HashMap::new()),
65+ on_event,
66+ on_fatal,
67+ });
68+ let weak = Rc::downgrade(&inner);
69+ macltok::on_panic(move |message| {
70+ if let Some(inner) = weak.upgrade() {
71+ inner.call_on_fatal(message);
72+ }
73+ });
74+ Host(inner)
75+ }
76+
77+ pub async fn restore(&self, account: String, passphrase: String) -> Result<JsValue, JsValue> {
78+ let sessions: Arc<dyn SessionStore> = Arc::new(EncryptedSessionStore::indexeddb(
79+ StoreConfig::indexeddb(store_name(&account), passphrase),
80+ ));
81+ match Session::restore(sessions.clone()).await {
82+ Ok(None) => Ok(to_js_value(&serde_json::json!({ "status": "none" }))),
83+ Ok(Some(Restore::SoftLoggedOut(soft))) => Ok(to_js_value(&serde_json::json!({
84+ "status": "soft_logged_out",
85+ "user_id": soft.user_id.to_string(),
86+ "homeserver": soft.homeserver,
87+ }))),
88+ Ok(Some(Restore::Resumed(session))) => {
89+ match self.0.start_account(account, session, sessions).await {
90+ Ok(user_id) => Ok(to_js_value(&serde_json::json!({
91+ "status": "resumed",
92+ "user_id": user_id,
93+ }))),
94+ Err(message) => Err(error_value("unavailable", message)),
95+ }
96+ }
97+ Err(error) => {
98+ let (kind, message) = restore_error_kind(&error);
99+ Err(error_value(kind, message))
100+ }
101+ }
102+ }
103+
104+ pub async fn login(
105+ &self,
106+ account: String,
107+ passphrase: String,
108+ homeserver: String,
109+ username: String,
110+ password: String,
111+ ) -> Result<JsValue, JsValue> {
112+ let input = LoginInput {
113+ homeserver,
114+ username,
115+ password,
116+ };
117+ let input = match input.validate(true) {
118+ Ok(input) => input,
119+ Err(error) => return Err(core_error_value(error)),
120+ };
121+ let password = input.password.clone();
122+ let sessions: Arc<dyn SessionStore> = Arc::new(EncryptedSessionStore::indexeddb(
123+ StoreConfig::indexeddb(store_name(&account), passphrase),
124+ ));
125+ let login = match Session::restore(sessions.clone()).await {
126+ Ok(Some(Restore::SoftLoggedOut(soft)))
127+ if username_matches(&input.username, &soft.user_id) =>
128+ {
129+ soft.password_login(input.password)
130+ }
131+ _ => PasswordLogin::new(input.homeserver, input.username, input.password)
132+ .with_device_display_name("malice web"),
133+ };
134+ let session = match login_with(login, sessions.clone()).await {
135+ Ok(session) => session,
136+ Err(failure) => {
137+ let message = malice_core::login::sanitise_auth_error(&failure.message, &password);
138+ return Err(error_value(failure.kind, message));
139+ }
140+ };
141+ match self.0.start_account(account, session, sessions).await {
142+ Ok(user_id) => Ok(to_js_value(&serde_json::json!({ "user_id": user_id }))),
143+ Err(message) => Err(error_value("upstream", message)),
144+ }
145+ }
146+
147+ pub async fn logout(&self, account: String) -> Result<(), JsValue> {
148+ let handle = self
149+ .0
150+ .accounts
151+ .borrow()
152+ .get(&account)
153+ .map(|entry| entry.handle.clone());
154+ if let Some(handle) = handle {
155+ let _ = handle.logout().await;
156+ }
157+ self.0.remove_account(&account, Retain::Nothing).await;
158+ Ok(())
159+ }
160+
161+ pub async fn call(
162+ &self,
163+ account: String,
164+ request_json: String,
165+ bytes: Option<js_sys::Uint8Array>,
166+ ) -> Result<JsValue, JsValue> {
167+ let (handle, mirror) = {
168+ let accounts = self.0.accounts.borrow();
169+ let Some(entry) = accounts.get(&account) else {
170+ return Err(error_value("engine_stopped", "account is not running"));
171+ };
172+ (entry.handle.clone(), entry.mirror.clone())
173+ };
174+ let mut request: Request = serde_json::from_str(&request_json)
175+ .map_err(|error| error_value("bad_request", error.to_string()))?;
176+ if let Request::Upload { bytes: slot, .. } = &mut request {
177+ *slot = bytes.map(|array| array.to_vec()).unwrap_or_default();
178+ }
179+ match dispatch(&handle, &mirror, request).await {
180+ Ok(reply) => Ok(reply_value(reply)),
181+ Err(error) => Err(core_error_value(error)),
182+ }
183+ }
184+
185+ /// Where full-size media lives and the bearer token to fetch it, for the
186+ /// service worker to stream directly. Host-only: unlike `call`, this never
187+ /// goes through `malice_core::request::Request`, so the hosted server has
188+ /// no way to ask for a token over HTTP.
189+ pub async fn locate(
190+ &self,
191+ account: String,
192+ media_id: String,
193+ size: String,
194+ ) -> Result<JsValue, JsValue> {
195+ let handle = {
196+ let accounts = self.0.accounts.borrow();
197+ let Some(entry) = accounts.get(&account) else {
198+ return Err(error_value("engine_stopped", "account is not running"));
199+ };
200+ entry.handle.clone()
201+ };
202+ if media_id.is_empty() || media_id.len() > 512 {
203+ return Err(error_value("bad_request", "invalid media_id"));
204+ }
205+ let media_id = MediaId::from(media_id);
206+ let size = match size.as_str() {
207+ "Thumbnail" => MediaSize::Thumbnail {
208+ width: THUMBNAIL_SIZE,
209+ height: THUMBNAIL_SIZE,
210+ },
211+ "Full" => MediaSize::Full,
212+ _ => return Err(error_value("bad_request", "invalid size")),
213+ };
214+ let location = handle
215+ .media_source(&media_id, size)
216+ .await
217+ .map_err(|error| core_error_value(CoreError::from(error)))?;
218+ let token = match &location {
219+ MediaLocation::Local => None,
220+ _ => handle.access_token(),
221+ };
222+ let location = serde_json::to_value(&location)
223+ .map_err(|error| error_value("upstream", error.to_string()))?;
224+ Ok(to_js_value(&serde_json::json!({
225+ "location": location,
226+ "token": token,
227+ })))
228+ }
229+
230+ /// Stops every engine and resolves once their store locks are free.
231+ pub async fn shutdown(&self) {
232+ let accounts: Vec<Account> = self
233+ .0
234+ .accounts
235+ .borrow_mut()
236+ .drain()
237+ .map(|(_, account)| account)
238+ .collect();
239+ futures_util::future::join_all(accounts.into_iter().map(Account::shutdown)).await;
240+ }
241+
242+ pub fn snapshot(&self, account: String) -> Option<String> {
243+ let accounts = self.0.accounts.borrow();
244+ let entry = accounts.get(&account)?;
245+ let event = malice_core::feed::snapshot_event(&entry.mirror.snapshot());
246+ serde_json::to_string(&event).ok()
247+ }
248+}
249+
250+impl Drop for Host {
251+ /// Dropping the handles alone left the engine running in the browser
252+ /// harness, so each engine gets an explicit shutdown. Nothing can await it
253+ /// here, so a caller that must reuse the store straight away calls
254+ /// `shutdown` first.
255+ fn drop(&mut self) {
256+ for (_, account) in self.0.accounts.borrow_mut().drain() {
257+ wasm_bindgen_futures::spawn_local(account.shutdown());
258+ }
259+ }
260+}
Added file: crates/malice-worker/src/wire.rs+56 additions
@@ -0,0 +1,56 @@
1+use wasm_bindgen::JsValue;
2+
3+use malice_core::error::{CoreError, Failure};
4+use malice_core::request::Reply;
5+
6+pub fn to_js_value(value: &serde_json::Value) -> JsValue {
7+ js_sys::JSON::parse(&value.to_string()).unwrap_or(JsValue::NULL)
8+}
9+
10+pub fn error_value(kind: &str, message: impl AsRef<str>) -> JsValue {
11+ to_js_value(&serde_json::json!({ "kind": kind, "message": message.as_ref() }))
12+}
13+
14+pub fn failure_kind(failure: Failure) -> &'static str {
15+ match failure {
16+ Failure::BadRequest => "bad_request",
17+ Failure::NotFound => "not_found",
18+ Failure::Conflict => "conflict",
19+ Failure::Busy => "busy",
20+ Failure::TooLarge => "too_large",
21+ Failure::Unsupported => "unsupported",
22+ Failure::EngineStopped => "engine_stopped",
23+ Failure::Upstream => "upstream",
24+ }
25+}
26+
27+pub fn core_error_value(error: CoreError) -> JsValue {
28+ error_value(failure_kind(error.kind), error.message)
29+}
30+
31+pub fn reply_value(reply: Reply) -> JsValue {
32+ match reply {
33+ Reply::Done => js_sys::Object::new().into(),
34+ Reply::Json(value) => to_js_value(&serde_json::json!({ "json": value })),
35+ Reply::Avatar {
36+ bytes,
37+ content_type,
38+ } => bytes_value(bytes, Some(content_type)),
39+ Reply::Attachment {
40+ bytes,
41+ content_type,
42+ } => bytes_value(bytes, content_type),
43+ }
44+}
45+
46+fn bytes_value(bytes: Vec<u8>, content_type: Option<&str>) -> JsValue {
47+ let object = js_sys::Object::new();
48+ let array = js_sys::Uint8Array::from(bytes.as_slice());
49+ let content_type = match content_type {
50+ Some(content_type) => JsValue::from_str(content_type),
51+ None => JsValue::NULL,
52+ };
53+ js_sys::Reflect::set(&object, &JsValue::from_str("bytes"), &array).ok();
54+ js_sys::Reflect::set(&object, &JsValue::from_str("content_type"), &content_type).ok();
55+ object.into()
56+}
Added file: crates/malice-worker/tests/host.rs+224 additions
@@ -0,0 +1,224 @@
1+//! needs a live homeserver; run.nu starts and seeds one and bakes its details in at build time
2+
3+use futures_channel::mpsc;
4+use futures_util::StreamExt;
5+use futures_util::future::{Either, select};
6+use gloo_timers::future::TimeoutFuture;
7+use malice_worker::Host;
8+use serde_json::Value;
9+use wasm_bindgen::JsCast;
10+use wasm_bindgen::closure::Closure;
11+use wasm_bindgen_test::*;
12+
13+wasm_bindgen_test_configure!(run_in_shared_worker);
14+
15+const PASSPHRASE: &str = "host-test-passphrase";
16+const PHASE_TIMEOUT_MS: f64 = 90_000.0;
17+
18+#[wasm_bindgen_test]
19+async fn logs_in_syncs_and_logs_out() {
20+ assert!(js_sys::global().is_instance_of::<web_sys::SharedWorkerGlobalScope>());
21+
22+ let account = "host-contract".to_string();
23+ let (on_event, mut events) = event_channel();
24+ let (on_fatal, _fatal) = message_channel();
25+ let host = Host::new(on_event, on_fatal);
26+
27+ let login = host
28+ .login(
29+ account.clone(),
30+ PASSPHRASE.into(),
31+ env!("MALICE_HOMESERVER").into(),
32+ env!("MALICE_USERNAME").into(),
33+ env!("MALICE_PASSWORD").into(),
34+ )
35+ .await
36+ .unwrap_or_else(|error| panic!("login failed: {:?}", as_json(error)));
37+ assert!(as_json(login).get("user_id").is_some());
38+
39+ let room_id = find_room(&host, &account, env!("MALICE_ROOM_NAME"), deadline()).await;
40+
41+ let opened = host
42+ .call(account.clone(), open_request(&room_id), None)
43+ .await
44+ .unwrap_or_else(|error| panic!("open failed: {:?}", as_json(error)));
45+ assert_eq!(as_json(opened), Value::Object(Default::default()));
46+
47+ let seeded = env!("MALICE_SEEDED");
48+ wait_for_text(&mut events, &account, seeded, deadline()).await;
49+
50+ let sent = format!("{seeded}, answered by host-contract");
51+ let sent_reply = host
52+ .call(account.clone(), send_request(&room_id, &sent), None)
53+ .await
54+ .unwrap_or_else(|error| panic!("send failed: {:?}", as_json(error)));
55+ assert_eq!(as_json(sent_reply), Value::Object(Default::default()));
56+ wait_for_text(&mut events, &account, &sent, deadline()).await;
57+
58+ let avatar_error = host
59+ .call(
60+ account.clone(),
61+ r#"{"op":"avatar","mxc":"not-a-mxc"}"#.into(),
62+ None,
63+ )
64+ .await
65+ .expect_err("an invalid mxc URI must be rejected");
66+ assert_eq!(
67+ as_json(avatar_error).get("kind").and_then(Value::as_str),
68+ Some("bad_request")
69+ );
70+
71+ host.logout(account.clone())
72+ .await
73+ .expect("logout should discard the store cleanly");
74+ let restored = host
75+ .restore(account, PASSPHRASE.into())
76+ .await
77+ .expect("restoring a discarded store should not error");
78+ assert_eq!(
79+ as_json(restored).get("status").and_then(Value::as_str),
80+ Some("none")
81+ );
82+}
83+
84+/// the Web Lock is released by a promise settling, so a fresh host may still see it held for
85+/// a tick after `shutdown` resolves
86+#[wasm_bindgen_test]
87+async fn restore_after_shutdown_frees_the_store() {
88+ let account = "host-drop".to_string();
89+ let (on_event, _events) = event_channel();
90+ let (on_fatal, _fatal) = message_channel();
91+ let host = Host::new(on_event, on_fatal);
92+ host.login(
93+ account.clone(),
94+ PASSPHRASE.into(),
95+ env!("MALICE_HOMESERVER").into(),
96+ env!("MALICE_USERNAME").into(),
97+ env!("MALICE_PASSWORD").into(),
98+ )
99+ .await
100+ .unwrap_or_else(|error| panic!("login failed: {:?}", as_json(error)));
101+ let shutdown = Box::pin(host.shutdown());
102+ if let Either::Right(_) = select(shutdown, TimeoutFuture::new(PHASE_TIMEOUT_MS as u32)).await {
103+ panic!("shutdown never finished");
104+ }
105+ drop(host);
106+
107+ let (on_event, _events) = event_channel();
108+ let (on_fatal, _fatal) = message_channel();
109+ let host = Host::new(on_event, on_fatal);
110+ let end = deadline();
111+ let restored = loop {
112+ match host.restore(account.clone(), PASSPHRASE.into()).await {
113+ Err(error)
114+ if as_json(error.clone()).get("kind").and_then(Value::as_str)
115+ == Some("store_in_use") =>
116+ {
117+ assert!(
118+ js_sys::Date::now() < end,
119+ "the shut down host's store lock never freed"
120+ );
121+ TimeoutFuture::new(100).await;
122+ }
123+ outcome => {
124+ break outcome.expect("restore once the shut down host's engine has stopped");
125+ }
126+ }
127+ };
128+ assert_eq!(
129+ as_json(restored).get("status").and_then(Value::as_str),
130+ Some("resumed")
131+ );
132+
133+ host.logout(account)
134+ .await
135+ .expect("logout should discard the store cleanly");
136+}
137+
138+fn open_request(room_id: &str) -> String {
139+ serde_json::json!({ "op": "open", "room_id": room_id }).to_string()
140+}
141+
142+fn send_request(room_id: &str, body: &str) -> String {
143+ serde_json::json!({ "op": "send", "room_id": room_id, "body": body }).to_string()
144+}
145+
146+fn deadline() -> f64 {
147+ js_sys::Date::now() + PHASE_TIMEOUT_MS
148+}
149+
150+fn as_json(value: wasm_bindgen::JsValue) -> Value {
151+ let text = js_sys::JSON::stringify(&value)
152+ .ok()
153+ .and_then(|text| text.as_string())
154+ .unwrap_or_default();
155+ serde_json::from_str(&text).unwrap_or(Value::Null)
156+}
157+
158+fn event_channel() -> (js_sys::Function, mpsc::UnboundedReceiver<(String, String)>) {
159+ let (tx, rx) = mpsc::unbounded();
160+ let closure = Closure::wrap(Box::new(move |account: String, json: String| {
161+ let _ = tx.unbounded_send((account, json));
162+ }) as Box<dyn FnMut(String, String)>);
163+ (closure.into_js_value().unchecked_into(), rx)
164+}
165+
166+fn message_channel() -> (js_sys::Function, mpsc::UnboundedReceiver<String>) {
167+ let (tx, rx) = mpsc::unbounded();
168+ let closure = Closure::wrap(Box::new(move |message: String| {
169+ let _ = tx.unbounded_send(message);
170+ }) as Box<dyn FnMut(String)>);
171+ (closure.into_js_value().unchecked_into(), rx)
172+}
173+
174+/// polls the `rooms` op rather than the event stream, since the seeded room's arrival
175+/// order relative to the subscription's own snapshot isn't worth racing against
176+async fn find_room(host: &Host, account: &str, room_name: &str, deadline: f64) -> String {
177+ loop {
178+ let reply = host
179+ .call(account.to_string(), r#"{"op":"rooms"}"#.to_string(), None)
180+ .await
181+ .unwrap_or_else(|error| panic!("rooms failed: {:?}", as_json(error)));
182+ let rooms = as_json(reply);
183+ let found = rooms
184+ .get("json")
185+ .and_then(|json| json.get("rooms"))
186+ .and_then(Value::as_array)
187+ .and_then(|rooms| {
188+ rooms
189+ .iter()
190+ .find(|room| room.get("name").and_then(Value::as_str) == Some(room_name))
191+ })
192+ .and_then(|room| room.get("room_id"))
193+ .and_then(Value::as_str)
194+ .map(str::to_owned);
195+ if let Some(room_id) = found {
196+ return room_id;
197+ }
198+ assert!(
199+ js_sys::Date::now() < deadline,
200+ "the seeded room never appeared"
201+ );
202+ TimeoutFuture::new(200).await;
203+ }
204+}
205+
206+async fn wait_for_text(
207+ events: &mut mpsc::UnboundedReceiver<(String, String)>,
208+ account: &str,
209+ text: &str,
210+ deadline: f64,
211+) {
212+ loop {
213+ let left = (deadline - js_sys::Date::now()).max(0.0) as u32;
214+ match select(Box::pin(events.next()), Box::pin(TimeoutFuture::new(left))).await {
215+ Either::Left((Some((event_account, json)), _)) => {
216+ if event_account == account && json.contains(text) {
217+ return;
218+ }
219+ }
220+ Either::Left((None, _)) => panic!("event channel closed while waiting for {text:?}"),
221+ Either::Right(_) => panic!("timed out waiting for {text:?}"),
222+ }
223+ }
224+}
Added file: flake.lock+66 additions
@@ -0,0 +1,66 @@
1+{
2+ "nodes": {
3+ "fenix": {
4+ "inputs": {
5+ "nixpkgs": [
6+ "nixpkgs"
7+ ],
8+ "rust-analyzer-src": "rust-analyzer-src"
9+ },
10+ "locked": {
11+ "lastModified": 1790420830,
12+ "narHash": "sha256-z3OT9zp96drkDc2qZlC9WRXMpKaysdMDJiwWpLVeiOU=",
13+ "owner": "nix-community",
14+ "repo": "fenix",
15+ "rev": "5f7e7d793cb2553410f857554de86f277ebe2f71",
16+ "type": "github"
17+ },
18+ "original": {
19+ "owner": "nix-community",
20+ "repo": "fenix",
21+ "type": "github"
22+ }
23+ },
24+ "nixpkgs": {
25+ "locked": {
26+ "lastModified": 1790149054,
27+ "narHash": "sha256-FfjXg6mAzKQBCY+7V9keGWOXG4DV2Eym/qA987lFl8A=",
28+ "owner": "NixOS",
29+ "repo": "nixpkgs",
30+ "rev": "8825bebf6324e0579d012936eff73379af284b6d",
31+ "type": "github"
32+ },
33+ "original": {
34+ "owner": "NixOS",
35+ "ref": "nixpkgs-unstable",
36+ "repo": "nixpkgs",
37+ "type": "github"
38+ }
39+ },
40+ "root": {
41+ "inputs": {
42+ "fenix": "fenix",
43+ "nixpkgs": "nixpkgs"
44+ }
45+ },
46+ "rust-analyzer-src": {
47+ "flake": false,
48+ "locked": {
49+ "lastModified": 1790305964,
50+ "narHash": "sha256-2qzlUJB59Rq0XIjVGf42XqqaTwXoRLIFX4S7sW1t2iw=",
51+ "owner": "rust-lang",
52+ "repo": "rust-analyzer",
53+ "rev": "1ad44dc58e65304b594063e70c144ecb58643671",
54+ "type": "github"
55+ },
56+ "original": {
57+ "owner": "rust-lang",
58+ "ref": "nightly",
59+ "repo": "rust-analyzer",
60+ "type": "github"
61+ }
62+ }
63+ },
64+ "root": "root",
65+ "version": 7
66+}
Added file: flake.nix+230 additions
@@ -0,0 +1,230 @@
1+{
2+ description = "Malice: a hosted Matrix client backed by macltok";
3+
4+ inputs = {
5+ nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
6+ fenix = {
7+ url = "github:nix-community/fenix";
8+ inputs.nixpkgs.follows = "nixpkgs";
9+ };
10+ };
11+
12+ outputs =
13+ { self, nixpkgs, fenix }:
14+ let
15+ systems = [
16+ "x86_64-linux"
17+ "aarch64-linux"
18+ ];
19+ forAllSystems = nixpkgs.lib.genAttrs systems;
20+ workerLock = builtins.fromTOML (builtins.readFile ./crates/malice-worker/Cargo.lock);
21+ lockedVersion =
22+ name: (nixpkgs.lib.findFirst (p: p.name == name) null workerLock.package).version;
23+ # wasm-bindgen-test-runner refuses a module built by any other wasm-bindgen version
24+ wasmBindgenCli =
25+ pkgs:
26+ pkgs.buildWasmBindgenCli rec {
27+ src = pkgs.fetchCrate {
28+ pname = "wasm-bindgen-cli";
29+ version = lockedVersion "wasm-bindgen";
30+ hash = "sha256-a7lcXJnnZkYReja+iUO7NqqrWyv3toxnUgQb8s4IS5s=";
31+ };
32+ cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
33+ inherit src;
34+ inherit (src) pname version;
35+ hash = "sha256-R1Tas33Ursy8kqsxguAkG0ZhNed2n5uFTAhw1l2qlLY=";
36+ };
37+ };
38+ wasmToolchain =
39+ system:
40+ let
41+ toolchains = fenix.packages.${system};
42+ in
43+ toolchains.combine [
44+ toolchains.stable.cargo
45+ toolchains.stable.clippy
46+ toolchains.stable.rustc
47+ toolchains.stable.rustfmt
48+ toolchains.stable.rust-src
49+ toolchains.targets.wasm32-unknown-unknown.stable.rust-std
50+ ];
51+ in
52+ {
53+ packages = forAllSystems (
54+ system:
55+ let
56+ pkgs = nixpkgs.legacyPackages.${system};
57+ wasmPlatform = pkgs.makeRustPlatform {
58+ cargo = wasmToolchain system;
59+ rustc = wasmToolchain system;
60+ };
61+ web = pkgs.buildNpmPackage {
62+ pname = "malice-web";
63+ version = "0.1.0";
64+ src = ./web;
65+ npmDepsHash = "sha256-Q00XwHH/+kfZTAPBKUlkx3hAtYvSpDw2opj22nGTIMY=";
66+ installPhase = ''
67+ runHook preInstall
68+ mkdir -p $out/share/malice
69+ cp -R dist/. $out/share/malice/
70+ runHook postInstall
71+ '';
72+ };
73+ backend = pkgs.rustPlatform.buildRustPackage {
74+ pname = "malice-server";
75+ version = "0.1.0";
76+ src = ./.;
77+ cargoLock.lockFile = ./Cargo.lock;
78+ nativeBuildInputs = [ pkgs.pkg-config ];
79+ buildInputs = [ pkgs.sqlite ];
80+ # preBuild = "export NIX_BUILD_CORES=2";
81+ preCheck = ''
82+ cargo build --release --bin malice --target ${pkgs.stdenv.hostPlatform.rust.rustcTarget} --offline --locked
83+ '';
84+ SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
85+ };
86+ server = pkgs.symlinkJoin {
87+ name = "malice";
88+ paths = [
89+ backend
90+ web
91+ ];
92+ nativeBuildInputs = [ pkgs.makeWrapper ];
93+ postBuild = ''
94+ wrapProgram $out/bin/malice --set MALICE_WEB_DIR ${web}/share/malice
95+ '';
96+ };
97+ # the wasm-bindgen `--target web` glue for malice-worker's `Host`, consumed by
98+ # web/src/worker/pkg (gitignored, worker-web's TS imports it as ./pkg/malice_worker.js)
99+ workerWasm = wasmPlatform.buildRustPackage {
100+ pname = "malice-worker-wasm";
101+ version = "0.1.0";
102+ src = self;
103+ cargoRoot = "crates/malice-worker";
104+ cargoLock.lockFile = ./crates/malice-worker/Cargo.lock;
105+ nativeBuildInputs = [ (wasmBindgenCli pkgs) ];
106+ buildPhase = ''
107+ runHook preBuild
108+ cd crates/malice-worker
109+ cargo build --offline --frozen --release --lib --target wasm32-unknown-unknown
110+ mkdir -p $out
111+ wasm-bindgen --target web --out-dir $out --out-name malice_worker \
112+ target/wasm32-unknown-unknown/release/malice_worker.wasm
113+ runHook postBuild
114+ '';
115+ doCheck = false;
116+ installPhase = "true";
117+ };
118+ web-device = pkgs.buildNpmPackage {
119+ pname = "malice-web-device";
120+ version = "0.1.0";
121+ src = ./web;
122+ npmDepsHash = "sha256-Q00XwHH/+kfZTAPBKUlkx3hAtYvSpDw2opj22nGTIMY=";
123+ postPatch = ''
124+ mkdir -p src/worker/pkg
125+ cp -R ${workerWasm}/. src/worker/pkg/
126+ '';
127+ npmBuildScript = "build:device";
128+ installPhase = ''
129+ runHook preInstall
130+ mkdir -p $out/share/malice
131+ cp -R dist-device/. $out/share/malice/
132+ runHook postInstall
133+ '';
134+ };
135+ local = pkgs.symlinkJoin {
136+ name = "malice-local";
137+ paths = [
138+ backend
139+ web-device
140+ ];
141+ nativeBuildInputs = [ pkgs.makeWrapper ];
142+ postBuild = ''
143+ wrapProgram $out/bin/malice \
144+ --set MALICE_WEB_DIR ${web-device}/share/malice \
145+ --set MALICE_STATIC_ONLY 1
146+ '';
147+ };
148+ in
149+ {
150+ inherit web server web-device local;
151+ default = server;
152+ }
153+ );
154+
155+ devShells = forAllSystems (
156+ system:
157+ let
158+ pkgs = nixpkgs.legacyPackages.${system};
159+ in
160+ {
161+ default = pkgs.mkShell {
162+ packages = [
163+ (wasmToolchain system)
164+ (wasmBindgenCli pkgs)
165+ pkgs.rust-analyzer
166+ pkgs.nodejs_22
167+ pkgs.pkg-config
168+ pkgs.sqlite
169+ pkgs.firefox
170+ pkgs.geckodriver
171+ pkgs.matrix-continuwuity
172+ pkgs.nushell
173+ ];
174+ # fenix's rustc links through its bundled rust-lld, which skips nix's ld-wrapper and so
175+ # the store rpaths, leaving the native server unable to find libsqlite3 at run time
176+ "CARGO_TARGET_${nixpkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] pkgs.stdenv.hostPlatform.config)}_RUSTFLAGS" =
177+ "-C linker-features=-lld";
178+ };
179+ }
180+ );
181+
182+ nixosModules.malice = import ./nix/module.nix { inherit self; };
183+ nixosModules.default = self.nixosModules.malice;
184+ checks = forAllSystems (
185+ system:
186+ let
187+ pkgs = nixpkgs.legacyPackages.${system};
188+ wasmPlatform = pkgs.makeRustPlatform {
189+ cargo = wasmToolchain system;
190+ rustc = wasmToolchain system;
191+ };
192+ # continuwuity refuses to start without /etc/resolv.conf, which the build sandbox lacks, or with
193+ # stdenv's deliberately bogus SSL_CERT_FILE. no exec, run.nu launches through a shell that exits at
194+ # once and would trip --die-with-parent, so this shell stays as the parent run.nu kills
195+ sandboxedHomeserver = pkgs.writeShellScriptBin "conduwuit" ''
196+ export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
197+ ${pkgs.lib.getExe pkgs.bubblewrap} --die-with-parent --dev-bind / / --tmpfs /etc \
198+ --ro-bind ${pkgs.writeText "resolv.conf" "nameserver 127.0.0.1\n"} /etc/resolv.conf \
199+ ${pkgs.lib.getExe' pkgs.matrix-continuwuity "conduwuit"} "$@"
200+ '';
201+ in
202+ {
203+ default = self.packages.${system}.default;
204+ worker-wasm = self.packages.${system}.web-device;
205+ worker-browser = wasmPlatform.buildRustPackage {
206+ pname = "malice-worker-browser-check";
207+ version = "0.1.0";
208+ src = self;
209+ cargoRoot = "crates/malice-worker";
210+ cargoLock.lockFile = ./crates/malice-worker/Cargo.lock;
211+ nativeBuildInputs = [
212+ (wasmBindgenCli pkgs)
213+ pkgs.firefox
214+ pkgs.geckodriver
215+ sandboxedHomeserver
216+ pkgs.nushell
217+ ];
218+ buildPhase = ''
219+ runHook preBuild
220+ export HOME=$TMPDIR
221+ nu --no-config-file crates/malice-worker/run.nu
222+ runHook postBuild
223+ '';
224+ doCheck = false;
225+ installPhase = "touch $out";
226+ };
227+ }
228+ );
229+ };
230+}
Added file: nix/module.nix+158 additions
@@ -0,0 +1,158 @@
1+{ self }:
2+{
3+ config,
4+ lib,
5+ pkgs,
6+ ...
7+}:
8+let
9+ system = pkgs.stdenv.hostPlatform.system;
10+ hostedCfg = config.services.malice;
11+ deviceCfg = config.programs.malice;
12+ inherit (lib)
13+ mkEnableOption
14+ mkIf
15+ mkMerge
16+ mkOption
17+ types
18+ ;
19+ deviceUrl = "http://127.0.0.1:${toString deviceCfg.port}/";
20+ browserCommand =
21+ if lib.hasInfix "%u" deviceCfg.browser then
22+ lib.replaceStrings [ "%u" ] [ deviceUrl ] deviceCfg.browser
23+ else
24+ "${deviceCfg.browser} ${deviceUrl}";
25+ malicOpen = pkgs.writeShellScriptBin "malice-open" ''
26+ exec ${browserCommand}
27+ '';
28+ malicDesktopItem = pkgs.makeDesktopItem {
29+ name = "malice";
30+ desktopName = "Malice";
31+ comment = "Open the on-device Malice Matrix client";
32+ exec = "${malicOpen}/bin/malice-open";
33+ terminal = false;
34+ categories = [
35+ "Network"
36+ "InstantMessaging"
37+ ];
38+ };
39+in
40+{
41+ options.services.malice = {
42+ enable = mkEnableOption "Malice hosted Matrix client";
43+
44+ package = mkOption {
45+ type = types.package;
46+ default = self.packages.${system}.default;
47+ description = "Malice server and bundled Svelte frontend.";
48+ };
49+
50+ listenAddress = mkOption {
51+ type = types.str;
52+ default = "127.0.0.1";
53+ description = "Address to bind. Keep behind an HTTPS reverse proxy for secure browser cookies.";
54+ };
55+
56+ port = mkOption {
57+ type = types.port;
58+ default = 3000;
59+ description = "HTTP listener port.";
60+ };
61+
62+ openFirewall = mkOption {
63+ type = types.bool;
64+ default = false;
65+ description = "Open the HTTP listener port in the firewall.";
66+ };
67+ };
68+
69+ options.programs.malice = {
70+ enable = mkEnableOption "Malice on-device Matrix client";
71+
72+ package = mkOption {
73+ type = types.package;
74+ default = self.packages.${system}.local;
75+ description = "The malice binary wrapped for the on-device build (MALICE_WEB_DIR and MALICE_STATIC_ONLY set).";
76+ };
77+
78+ port = mkOption {
79+ type = types.port;
80+ default = 38465;
81+ description = ''
82+ Loopback port the socket-activated server listens on. This port is
83+ part of the browser origin malice runs at, so changing it hides
84+ accounts logged in at the old origin (their data is still on disk,
85+ just unreachable at the new URL). Everyone logged into this machine
86+ at once shares this one port; per-user ports are not supported yet.
87+ '';
88+ };
89+
90+ browser = mkOption {
91+ type = types.str;
92+ default = "xdg-open %u";
93+ description = ''
94+ Command the desktop entry runs to open malice. `%u` (desktop-entry
95+ convention) is replaced with malice's URL; if the command has no
96+ `%u`, the URL is appended after a space instead. E.g. "chromium
97+ --app=%u" to open malice in an app window instead of a browser tab.
98+ '';
99+ };
100+ };
101+
102+ config = mkMerge [
103+ (mkIf hostedCfg.enable {
104+ systemd.services.malice = {
105+ description = "Malice hosted Matrix client";
106+ wantedBy = [ "multi-user.target" ];
107+ after = [ "network-online.target" ];
108+ wants = [ "network-online.target" ];
109+ environment = {
110+ MALICE_BIND = "${hostedCfg.listenAddress}:${toString hostedCfg.port}";
111+ MALICE_DATA_DIR = "/var/lib/malice";
112+ MALICE_WEB_DIR = "${self.packages.${system}.web}/share/malice";
113+ MALICE_SESSION_COOKIE_SECURE = "true";
114+ };
115+ serviceConfig = {
116+ ExecStart = lib.getExe' hostedCfg.package "malice";
117+ DynamicUser = true;
118+ StateDirectory = "malice";
119+ StateDirectoryMode = "0700";
120+ UMask = "0077";
121+ Restart = "on-failure";
122+ RestartSec = 5;
123+ NoNewPrivileges = true;
124+ ProtectSystem = "strict";
125+ ProtectHome = true;
126+ PrivateTmp = true;
127+ RestrictAddressFamilies = [
128+ "AF_INET"
129+ "AF_INET6"
130+ "AF_UNIX"
131+ ];
132+ };
133+ };
134+ networking.firewall.allowedTCPPorts = lib.optionals hostedCfg.openFirewall [ hostedCfg.port ];
135+ })
136+ (mkIf deviceCfg.enable {
137+ systemd.user.sockets.malice = {
138+ description = "Malice on-device Matrix client socket";
139+ wantedBy = [ "sockets.target" ];
140+ socketConfig.ListenStream = "127.0.0.1:${toString deviceCfg.port}";
141+ };
142+ systemd.user.services.malice = {
143+ description = "Malice on-device Matrix client";
144+ environment = {
145+ MALICE_STATIC_ONLY = "1";
146+ MALICE_IDLE_EXIT_SECS = "300";
147+ };
148+ serviceConfig = {
149+ ExecStart = lib.getExe' deviceCfg.package "malice";
150+ };
151+ };
152+ environment.systemPackages = [
153+ malicOpen
154+ malicDesktopItem
155+ ];
156+ })
157+ ];
158+}
Added file: vendor/macltok/Cargo.toml+78 additions
@@ -0,0 +1,78 @@
1+[package]
2+name = "macltok"
3+version = "0.1.0"
4+edition = "2024"
5+description = "A compact application backend on top of the Matrix Rust SDK"
6+
7+[features]
8+default = []
9+mirror = []
10+serde = ["dep:serde", "imbl/serde"]
11+sqlite = ["matrix-sdk/sqlite", "serde", "dep:url", "dep:matrix-sdk-store-encryption"]
12+bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"]
13+indexeddb = [
14+ "matrix-sdk/indexeddb",
15+ "serde",
16+ "dep:url",
17+ "dep:matrix-sdk-store-encryption",
18+ "dep:indexed_db_futures",
19+ "dep:js-sys",
20+ "dep:wasm-bindgen",
21+ "dep:wasm-bindgen-futures",
22+ "dep:web-sys",
23+]
24+
25+[dependencies]
26+futures-util = { version = "0.3.31", default-features = false, features = ["std"] }
27+http = "1"
28+imbl = { version = "6.1.0", default-features = false }
29+matrix-sdk = { version = "0.18.0", default-features = false, features = ["e2e-encryption"] }
30+matrix-sdk-crypto = { version = "0.18.0", default-features = false }
31+matrix-sdk-store-encryption = { version = "0.18.0", optional = true }
32+matrix-sdk-ui = { version = "0.18.0", default-features = false }
33+ruma = { version = "0.16.0", default-features = false }
34+serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }
35+thiserror = "2.0.16"
36+mime = "0.3.17"
37+sha2 = "0.10.9"
38+tokio = { version = "1.47.1", features = ["macros", "sync"] }
39+tracing = "0.1.41"
40+url = { version = "2.5.7", optional = true }
41+
42+[target.'cfg(target_family = "wasm")'.dependencies]
43+getrandom = { version = "0.2", features = ["js"] }
44+getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }
45+getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }
46+indexed_db_futures = { package = "matrix_indexed_db_futures", version = "0.7.0", optional = true, features = ["typed-arrays"] }
47+js-sys = { version = "0.3.105", optional = true }
48+matrix-sdk = { version = "0.18.0", default-features = false, features = ["js"] }
49+matrix-sdk-ui = { version = "0.18.0", default-features = false, features = ["js"] }
50+# the sdk's own wasm reqwest leaves out stream, which the bounded media download needs
51+reqwest = { version = "0.13.1", default-features = false, features = ["stream"] }
52+uuid = { version = "1", features = ["js"] }
53+wasm-bindgen = { version = "0.2.128", optional = true }
54+wasm-bindgen-futures = { version = "0.4.78", optional = true }
55+web-sys = { version = "0.3.105", optional = true, features = [
56+ "IdbFactory",
57+ "IdbOpenDbRequest",
58+ "IdbRequest",
59+ "Window",
60+ "WorkerGlobalScope",
61+] }
62+
63+[dev-dependencies]
64+matrix-sdk = { version = "0.18.0", default-features = false, features = ["testing"] }
65+matrix-sdk-test = "0.18.0"
66+serde_json = "1.0.143"
67+tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal", "time"] }
68+wiremock = "0.6.5"
69+
70+[[example]]
71+name = "headless"
72+required-features = ["sqlite"]
73+
74+[lints.rust]
75+unsafe_code = "forbid"
76+
77+[lints.clippy]
78+all = "warn"
Added file: vendor/macltok/examples/headless.rs+119 additions
@@ -0,0 +1,119 @@
1+use std::sync::Arc;
2+
3+use macltok::{
4+ EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Retain, RoomMembership,
5+ Session, SessionStore, StoreConfig, TimelineContent, TimelineDiff, TimelineId, TimelineItem,
6+ imbl::Vector,
7+};
8+
9+#[tokio::main]
10+async fn main() -> Result<(), Box<dyn std::error::Error>> {
11+ let store_path = std::path::PathBuf::from(required("MATRIX_STORE_PATH")?);
12+ let passphrase = required("MATRIX_STORE_PASSPHRASE")?;
13+ let sessions = Arc::new(EncryptedSessionStore::file(
14+ store_path.with_extension("session"),
15+ StoreConfig::sqlite(&store_path, &passphrase),
16+ ));
17+ let session = match Session::restore(sessions.clone()).await? {
18+ Some(Restore::Resumed(session)) => session,
19+ Some(Restore::SoftLoggedOut(soft)) => {
20+ Session::login_password(
21+ soft.password_login(required("MATRIX_PASSWORD")?),
22+ sessions.clone(),
23+ )
24+ .await?
25+ }
26+ None => {
27+ Session::login_password(
28+ PasswordLogin::new(
29+ required("MATRIX_HOMESERVER")?,
30+ required("MATRIX_USERNAME")?,
31+ required("MATRIX_PASSWORD")?,
32+ )
33+ .with_device_display_name("macltok headless example"),
34+ sessions.clone(),
35+ )
36+ .await?
37+ }
38+ };
39+ let (engine, handle, mut events) = session.build(EngineConfig::default()).await?;
40+ let running = tokio::spawn(engine.run());
41+ let mut opened: Option<TimelineId> = None;
42+ let mut items = Vector::new();
43+ let mut stale = false;
44+
45+ loop {
46+ tokio::select! {
47+ event = events.recv() => {
48+ let Some(event) = event else { break };
49+ match event {
50+ Event::RoomsChanged if opened.is_none() => {
51+ let Some(room) = handle
52+ .rooms()
53+ .await
54+ .into_iter()
55+ .find(|room| room.membership == RoomMembership::Joined)
56+ else {
57+ continue;
58+ };
59+ opened = Some(room.room_id.clone().into());
60+ // answers arrive on their own, the event loop keeps draining meanwhile
61+ let handle = handle.clone();
62+ tokio::spawn(async move {
63+ if let Err(error) = handle.open_room(room.room_id).await {
64+ eprintln!("could not open {error}");
65+ }
66+ });
67+ }
68+ Event::TimelineReset { timeline_id, items: snapshot }
69+ if opened.as_ref() == Some(&timeline_id) =>
70+ {
71+ items = snapshot;
72+ stale = false;
73+ print_messages(&items);
74+ }
75+ Event::TimelineChanged { timeline_id, diffs }
76+ if !stale && opened.as_ref() == Some(&timeline_id) =>
77+ {
78+ if TimelineDiff::try_apply_all(diffs, &mut items).is_err() {
79+ stale = true;
80+ let handle = handle.clone();
81+ tokio::spawn(async move { handle.resync(timeline_id).await });
82+ continue;
83+ }
84+ print_messages(&items);
85+ }
86+ Event::Stopped => break,
87+ _ => {}
88+ }
89+ }
90+ result = tokio::signal::ctrl_c() => {
91+ result?;
92+ let handle = handle.clone();
93+ tokio::spawn(async move { handle.shutdown().await });
94+ }
95+ }
96+ }
97+ let termination = running.await??;
98+ drop((handle, events));
99+ match termination.retain() {
100+ Retain::RecordAndStore => {}
101+ Retain::Store => sessions.soft_log_out().await?,
102+ Retain::Nothing => sessions.discard().await?,
103+ }
104+ Ok(())
105+}
106+
107+fn print_messages(items: &Vector<TimelineItem>) {
108+ for item in items {
109+ if let TimelineItem::Event(event) = item
110+ && let TimelineContent::Message(message) = &event.content
111+ {
112+ println!("{} {}", event.sender.name(), message.body);
113+ }
114+ }
115+}
116+
117+fn required(name: &str) -> Result<String, Box<dyn std::error::Error>> {
118+ std::env::var(name).map_err(|_| format!("{name} is required").into())
119+}
Added file: vendor/macltok/src/account.rs+689 additions
@@ -0,0 +1,689 @@
1+#[cfg(feature = "sqlite")]
2+use std::path::PathBuf;
3+#[cfg(not(target_family = "wasm"))]
4+use std::sync::atomic::{AtomicBool, Ordering};
5+use std::{collections::HashMap, sync::Arc};
6+
7+use matrix_sdk::{
8+ Client, ClientBuilder, SendOutsideWasm, SessionChange, SessionMeta, SyncOutsideWasm,
9+ ThreadingSupport,
10+ authentication::{
11+ AuthSession,
12+ matrix::MatrixSession,
13+ oauth::{
14+ ClientId, ClientRegistrationData, CsrfToken, OAuthSession, UserSession,
15+ registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},
16+ },
17+ },
18+ cross_process_lock::CrossProcessLockConfig,
19+ encryption::{BackupDownloadStrategy, EncryptionSettings},
20+ ruma::{OwnedDeviceId, OwnedUserId, serde::Raw},
21+};
22+use serde::{Deserialize, Serialize};
23+use thiserror::Error;
24+use tokio::sync::{broadcast, mpsc};
25+use url::Url;
26+
27+use crate::{
28+ ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, EventStream, SessionStore,
29+ engine::{Persistence, StoreLock},
30+ error::SourceError,
31+};
32+
33+/// a logged in client and the `SessionStore` its record lives in. the engine it builds
34+/// saves every token rotation there, unless told `without_persistence`
35+pub struct Session {
36+ client: Client,
37+ tracking: Tracking,
38+ store_lock: StoreLock,
39+}
40+
41+/// taken as soon as the client exists, since the sdk can refresh before there's an engine
42+struct Tracking {
43+ session_changes: broadcast::Receiver<SessionChange>,
44+ persistence: Option<Persistence>,
45+ #[cfg(not(target_family = "wasm"))]
46+ inline: Arc<AtomicBool>,
47+}
48+
49+#[non_exhaustive]
50+pub struct PasswordLogin {
51+ pub homeserver: String,
52+ pub username: String,
53+ pub password: String,
54+ pub device_display_name: Option<String>,
55+ /// set after a soft logout so the store's keys still belong to us
56+ pub device_id: Option<OwnedDeviceId>,
57+}
58+
59+impl PasswordLogin {
60+ #[must_use]
61+ pub fn new(
62+ homeserver: impl Into<String>,
63+ username: impl Into<String>,
64+ password: impl Into<String>,
65+ ) -> Self {
66+ Self {
67+ homeserver: homeserver.into(),
68+ username: username.into(),
69+ password: password.into(),
70+ device_display_name: None,
71+ device_id: None,
72+ }
73+ }
74+
75+ #[must_use]
76+ pub fn with_device_display_name(mut self, name: impl Into<String>) -> Self {
77+ self.device_display_name = Some(name.into());
78+ self
79+ }
80+
81+ #[must_use]
82+ pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {
83+ self.device_id = Some(device_id);
84+ self
85+ }
86+}
87+
88+#[non_exhaustive]
89+pub struct OAuthLogin {
90+ pub homeserver: String,
91+ pub redirect_uri: String,
92+ pub client_uri: String,
93+ pub client_name: Option<String>,
94+ pub client_id: Option<String>,
95+ pub device_id: Option<OwnedDeviceId>,
96+ pub application_type: OAuthApplicationType,
97+}
98+
99+impl OAuthLogin {
100+ #[must_use]
101+ pub fn new(
102+ homeserver: impl Into<String>,
103+ redirect_uri: impl Into<String>,
104+ client_uri: impl Into<String>,
105+ application_type: OAuthApplicationType,
106+ ) -> Self {
107+ Self {
108+ homeserver: homeserver.into(),
109+ redirect_uri: redirect_uri.into(),
110+ client_uri: client_uri.into(),
111+ client_name: None,
112+ client_id: None,
113+ device_id: None,
114+ application_type,
115+ }
116+ }
117+
118+ #[must_use]
119+ pub fn with_client_name(mut self, name: impl Into<String>) -> Self {
120+ self.client_name = Some(name.into());
121+ self
122+ }
123+
124+ /// a statically registered client id, skipping dynamic registration
125+ #[must_use]
126+ pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
127+ self.client_id = Some(client_id.into());
128+ self
129+ }
130+
131+ #[must_use]
132+ pub fn with_device_id(mut self, device_id: OwnedDeviceId) -> Self {
133+ self.device_id = Some(device_id);
134+ self
135+ }
136+}
137+
138+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139+#[non_exhaustive]
140+pub enum OAuthApplicationType {
141+ Native,
142+ Web,
143+}
144+
145+pub struct PendingOAuthLogin {
146+ client: Client,
147+ sessions: Arc<dyn SessionStore>,
148+ tracking: Option<(Tracking, StoreLock)>,
149+ authorization_url: String,
150+ state: Option<CsrfToken>,
151+}
152+
153+impl PendingOAuthLogin {
154+ #[must_use]
155+ pub fn authorization_url(&self) -> &str {
156+ &self.authorization_url
157+ }
158+
159+ #[must_use]
160+ pub fn registered_client_id(&self) -> String {
161+ self.client
162+ .oauth()
163+ .client_id()
164+ .expect("OAuth client was registered before authorization")
165+ .as_str()
166+ .to_owned()
167+ }
168+
169+ #[must_use]
170+ pub const fn is_pending(&self) -> bool {
171+ self.state.is_some()
172+ }
173+
174+ pub async fn finish(&mut self, callback_url: &str) -> Result<Session, AccountError> {
175+ let callback_url = Url::parse(callback_url)
176+ .map_err(|error| account_error("parsing the OAuth callback URL", error))?;
177+ let state = self
178+ .state
179+ .take()
180+ .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;
181+ let oauth = self.client.oauth();
182+ if let Err(error) = oauth.finish_login(callback_url.into()).await {
183+ oauth.abort_login(&state).await;
184+ return Err(account_error("OAuth login", error));
185+ }
186+ let (tracking, store_lock) = self
187+ .tracking
188+ .take()
189+ .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;
190+ Session::saved(
191+ self.client.clone(),
192+ self.sessions.clone(),
193+ tracking,
194+ store_lock,
195+ )
196+ .await
197+ }
198+
199+ pub async fn abort(mut self) {
200+ if let Some(state) = self.state.take() {
201+ self.client.oauth().abort_login(&state).await;
202+ }
203+ }
204+}
205+
206+pub struct StoreConfig {
207+ location: StoreLocation,
208+ passphrase: String,
209+}
210+
211+enum StoreLocation {
212+ #[cfg(feature = "sqlite")]
213+ Sqlite(PathBuf),
214+ #[cfg(feature = "indexeddb")]
215+ IndexedDb(String),
216+}
217+
218+impl StoreConfig {
219+ #[cfg(feature = "sqlite")]
220+ #[must_use]
221+ pub fn sqlite(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {
222+ Self {
223+ location: StoreLocation::Sqlite(path.into()),
224+ passphrase: passphrase.into(),
225+ }
226+ }
227+
228+ #[cfg(feature = "indexeddb")]
229+ #[must_use]
230+ pub fn indexeddb(name: impl Into<String>, passphrase: impl Into<String>) -> Self {
231+ Self {
232+ location: StoreLocation::IndexedDb(name.into()),
233+ passphrase: passphrase.into(),
234+ }
235+ }
236+
237+ /// delete the store's databases, once nothing has them open
238+ pub async fn delete(&self) -> Result<(), AccountError> {
239+ match &self.location {
240+ #[cfg(feature = "sqlite")]
241+ StoreLocation::Sqlite(path) => crate::session_store::delete_sqlite_store(path)
242+ .map_err(|error| account_error("deleting the Matrix store", error)),
243+ #[cfg(feature = "indexeddb")]
244+ StoreLocation::IndexedDb(name) => crate::session_store::delete_indexeddb_store(name)
245+ .await
246+ .map_err(|error| error.into_account_error("deleting the Matrix store")),
247+ }
248+ }
249+
250+ /// taken before the client opens the store, and held until the engine and every
251+ /// `ClientHandle` are gone
252+ pub(crate) async fn lock(&self) -> Result<StoreLock, AccountError> {
253+ match &self.location {
254+ #[cfg(feature = "sqlite")]
255+ StoreLocation::Sqlite(path) => StoreLock::file(path),
256+ #[cfg(feature = "indexeddb")]
257+ StoreLocation::IndexedDb(name) => StoreLock::web(name).await,
258+ }
259+ }
260+
261+ pub(crate) fn passphrase(&self) -> &str {
262+ &self.passphrase
263+ }
264+
265+ #[cfg(feature = "indexeddb")]
266+ pub(crate) fn indexeddb_name(&self) -> &str {
267+ let StoreLocation::IndexedDb(name) = &self.location;
268+ name
269+ }
270+}
271+
272+/// Serializable authentication secrets. A `SessionStore` keeps it, and the engine
273+/// replaces it there on every token rotation.
274+#[derive(Clone, Deserialize, Serialize)]
275+pub struct SessionRecord {
276+ homeserver: String,
277+ authentication: AuthenticationRecord,
278+}
279+
280+#[derive(Clone, Deserialize, Serialize)]
281+#[serde(tag = "type", content = "session", rename_all = "snake_case")]
282+enum AuthenticationRecord {
283+ Matrix(MatrixSession),
284+ OAuth {
285+ client_id: ClientId,
286+ user: UserSession,
287+ },
288+ SoftLoggedOut {
289+ meta: SessionMeta,
290+ client_id: Option<ClientId>,
291+ },
292+}
293+
294+impl SessionRecord {
295+ #[must_use]
296+ pub fn homeserver(&self) -> &str {
297+ &self.homeserver
298+ }
299+
300+ pub(crate) fn soft_logged_out(self) -> Self {
301+ let authentication = match self.authentication {
302+ AuthenticationRecord::Matrix(session) => AuthenticationRecord::SoftLoggedOut {
303+ meta: session.meta,
304+ client_id: None,
305+ },
306+ AuthenticationRecord::OAuth { client_id, user } => {
307+ AuthenticationRecord::SoftLoggedOut {
308+ meta: user.meta,
309+ client_id: Some(client_id),
310+ }
311+ }
312+ soft @ AuthenticationRecord::SoftLoggedOut { .. } => soft,
313+ };
314+ Self {
315+ homeserver: self.homeserver,
316+ authentication,
317+ }
318+ }
319+}
320+
321+pub enum Restore {
322+ Resumed(Session),
323+ SoftLoggedOut(SoftLogout),
324+}
325+
326+/// who to log in again as. the store keeps this device's e2ee keys, so a login
327+/// as any other device would orphan them
328+#[derive(Clone, Debug)]
329+#[non_exhaustive]
330+pub struct SoftLogout {
331+ pub homeserver: String,
332+ pub user_id: OwnedUserId,
333+ pub device_id: OwnedDeviceId,
334+ /// set when the login was OAuth, for `OAuthLogin::with_client_id`
335+ pub client_id: Option<String>,
336+}
337+
338+impl SoftLogout {
339+ #[must_use]
340+ pub fn password_login(&self, password: impl Into<String>) -> PasswordLogin {
341+ PasswordLogin::new(self.homeserver.clone(), self.user_id.as_str(), password)
342+ .with_device_id(self.device_id.clone())
343+ }
344+}
345+
346+#[derive(Debug, Error)]
347+pub enum AccountError {
348+ #[error("{context} failed")]
349+ Operation {
350+ context: &'static str,
351+ #[source]
352+ source: Box<SourceError>,
353+ },
354+ #[error("the homeserver returned no authenticated session")]
355+ MissingSession,
356+ #[error("the SDK returned an unsupported authentication session")]
357+ UnsupportedAuthentication,
358+ #[error("the OAuth callback was already submitted")]
359+ OAuthCallbackAlreadySubmitted,
360+ /// an IndexedDB delete is waiting on the store's other connections. it goes through
361+ /// once they close, and opening the store again waits behind it
362+ #[error("the store is still open elsewhere, it will be deleted once that closes")]
363+ StoreDeletePending,
364+ /// another engine, in this process or another, has the store. only one runs per store
365+ #[error("another engine is using this store")]
366+ StoreInUse,
367+ /// the login worked but its first record didn't save. the server already has the
368+ /// device, so retry the save rather than log in again
369+ #[error("saving the new login's record failed")]
370+ Unsaved {
371+ session: Box<UnsavedSession>,
372+ #[source]
373+ source: Box<AccountError>,
374+ },
375+ #[error(transparent)]
376+ Engine(#[from] EngineError),
377+}
378+
379+/// a live login whose record never reached its `SessionStore`
380+pub struct UnsavedSession {
381+ session: Session,
382+ sessions: Arc<dyn SessionStore>,
383+}
384+
385+impl UnsavedSession {
386+ pub async fn save(self) -> Result<Session, AccountError> {
387+ let saved = match session_record(&self.session.client) {
388+ Ok(record) => self.sessions.save(&record).await,
389+ Err(error) => Err(error),
390+ };
391+ match saved {
392+ Ok(()) => Ok(self.session),
393+ Err(source) => Err(AccountError::Unsaved {
394+ session: Box::new(self),
395+ source: Box::new(source),
396+ }),
397+ }
398+ }
399+
400+ /// the record to keep somewhere else, if the store won't take it
401+ pub fn record(&self) -> Result<SessionRecord, AccountError> {
402+ session_record(&self.session.client)
403+ }
404+}
405+
406+impl std::fmt::Debug for UnsavedSession {
407+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408+ formatter
409+ .debug_struct("UnsavedSession")
410+ .finish_non_exhaustive()
411+ }
412+}
413+
414+impl Session {
415+ pub async fn login_password(
416+ login: PasswordLogin,
417+ sessions: Arc<dyn SessionStore>,
418+ ) -> Result<Self, AccountError> {
419+ let store_lock = sessions.store().lock().await?;
420+ let client = matrix_client_builder(sessions.store())
421+ .server_name_or_homeserver_url(login.homeserver.trim())
422+ .build()
423+ .await
424+ .map_err(|error| account_error("building the Matrix client", error))?;
425+ let tracking = Tracking::start(&client, sessions.clone())?;
426+ let mut request = client
427+ .matrix_auth()
428+ .login_username(login.username.trim(), &login.password)
429+ .request_refresh_token();
430+ if let Some(name) = login.device_display_name.as_deref() {
431+ request = request.initial_device_display_name(name);
432+ }
433+ if let Some(device_id) = login.device_id.as_deref() {
434+ request = request.device_id(device_id.as_str());
435+ }
436+ request
437+ .send()
438+ .await
439+ .map_err(|error| account_error("password login", error))?;
440+ Self::saved(client, sessions, tracking, store_lock).await
441+ }
442+
443+ /// the pending login lives only in this process, so the one that begins it has to finish it
444+ pub async fn begin_oauth(
445+ login: OAuthLogin,
446+ sessions: Arc<dyn SessionStore>,
447+ ) -> Result<PendingOAuthLogin, AccountError> {
448+ let redirect_uri = Url::parse(login.redirect_uri.trim())
449+ .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;
450+ let client_uri = Url::parse(login.client_uri.trim())
451+ .map_err(|error| account_error("parsing the OAuth client URI", error))?;
452+ let store_lock = sessions.store().lock().await?;
453+ let client = matrix_client_builder(sessions.store())
454+ .server_name_or_homeserver_url(login.homeserver.trim())
455+ .build()
456+ .await
457+ .map_err(|error| account_error("building the Matrix client", error))?;
458+ let tracking = Tracking::start(&client, sessions.clone())?;
459+ let application_type = match login.application_type {
460+ OAuthApplicationType::Native => ApplicationType::Native,
461+ OAuthApplicationType::Web => ApplicationType::Web,
462+ };
463+ let mut metadata = ClientMetadata::new(
464+ application_type,
465+ vec![OAuthGrantType::AuthorizationCode {
466+ redirect_uris: vec![redirect_uri.clone()],
467+ }],
468+ Localized::new(client_uri, []),
469+ );
470+ metadata.client_name = login.client_name.map(|name| Localized::new(name, []));
471+ let metadata = Raw::new(&metadata)
472+ .map_err(|error| account_error("serializing OAuth client metadata", error))?;
473+ let mut registration = ClientRegistrationData::new(metadata);
474+ if let Some(client_id) = login.client_id {
475+ registration.static_registrations = Some(HashMap::from([(
476+ client.homeserver(),
477+ ClientId::new(client_id),
478+ )]));
479+ }
480+ let authorization = client
481+ .oauth()
482+ .login(redirect_uri, login.device_id, Some(registration), None)
483+ .build()
484+ .await
485+ .map_err(|error| account_error("starting OAuth login", error))?;
486+ Ok(PendingOAuthLogin {
487+ client,
488+ sessions,
489+ tracking: Some((tracking, store_lock)),
490+ authorization_url: authorization.url.to_string(),
491+ state: Some(authorization.state),
492+ })
493+ }
494+
495+ /// Restore the record `sessions` holds, or `None` when it holds none.
496+ /// The caller must also handle rejection and explicit logout.
497+ pub async fn restore(sessions: Arc<dyn SessionStore>) -> Result<Option<Restore>, AccountError> {
498+ let Some(record) = sessions.load().await? else {
499+ return Ok(None);
500+ };
501+ let authentication = match record.authentication {
502+ AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),
503+ AuthenticationRecord::OAuth { client_id, user } => {
504+ OAuthSession { client_id, user }.into()
505+ }
506+ AuthenticationRecord::SoftLoggedOut { meta, client_id } => {
507+ return Ok(Some(Restore::SoftLoggedOut(SoftLogout {
508+ homeserver: record.homeserver,
509+ user_id: meta.user_id,
510+ device_id: meta.device_id,
511+ client_id: client_id.map(|client_id| client_id.as_str().to_owned()),
512+ })));
513+ }
514+ };
515+ let store_lock = sessions.store().lock().await?;
516+ let client = matrix_client_builder(sessions.store())
517+ .homeserver_url(&record.homeserver)
518+ .build()
519+ .await
520+ .map_err(|error| account_error("reopening the Matrix store", error))?;
521+ let tracking = Tracking::start(&client, sessions)?;
522+ client
523+ .restore_session(authentication)
524+ .await
525+ .map_err(|error| account_error("restoring the authenticated session", error))?;
526+ Ok(Some(Restore::Resumed(Self {
527+ client,
528+ tracking,
529+ store_lock,
530+ })))
531+ }
532+
533+ /// leave the record to the host, which then saves `ClientHandle::session_record()`
534+ /// on every `SessionTokensChanged`
535+ #[must_use]
536+ pub fn without_persistence(mut self) -> Self {
537+ self.tracking.persistence = None;
538+ #[cfg(not(target_family = "wasm"))]
539+ self.tracking.inline.store(false, Ordering::Release);
540+ self
541+ }
542+
543+ pub async fn build(
544+ self,
545+ config: EngineConfig,
546+ ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {
547+ Engine::with_persistence(
548+ self.client,
549+ config,
550+ self.tracking.session_changes,
551+ self.tracking.persistence,
552+ Some(self.store_lock),
553+ )
554+ .await
555+ .map_err(AccountError::from)
556+ }
557+
558+ async fn saved(
559+ client: Client,
560+ sessions: Arc<dyn SessionStore>,
561+ tracking: Tracking,
562+ store_lock: StoreLock,
563+ ) -> Result<Self, AccountError> {
564+ UnsavedSession {
565+ session: Self {
566+ client,
567+ tracking,
568+ store_lock,
569+ },
570+ sessions,
571+ }
572+ .save()
573+ .await
574+ }
575+}
576+
577+impl Tracking {
578+ fn start(client: &Client, sessions: Arc<dyn SessionStore>) -> Result<Self, AccountError> {
579+ let session_changes = client.subscribe_to_session_changes();
580+ #[cfg_attr(target_family = "wasm", allow(unused_variables))]
581+ let (results, inline_results) = mpsc::unbounded_channel();
582+ #[cfg(not(target_family = "wasm"))]
583+ let inline = save_inline(client, sessions.clone(), results)?;
584+ Ok(Self {
585+ session_changes,
586+ persistence: Some(Persistence {
587+ save: Box::new(move |client| {
588+ let sessions = sessions.clone();
589+ Box::pin(async move {
590+ let record = session_record(&client).map_err(save_failed)?;
591+ sessions.save(&record).await.map_err(save_failed)
592+ })
593+ }),
594+ inline: inline_results,
595+ }),
596+ #[cfg(not(target_family = "wasm"))]
597+ inline,
598+ })
599+ }
600+}
601+
602+/// the sdk calls this inside its refresh, after it has the new tokens and before it uses
603+/// them, so a store that writes there and then can't lose a refresh token to a crash
604+#[cfg(not(target_family = "wasm"))]
605+fn save_inline(
606+ client: &Client,
607+ sessions: Arc<dyn SessionStore>,
608+ results: mpsc::UnboundedSender<Result<(), crate::Error>>,
609+) -> Result<Arc<AtomicBool>, AccountError> {
610+ let enabled = Arc::new(AtomicBool::new(true));
611+ let saving = enabled.clone();
612+ client
613+ .set_session_callbacks(
614+ // only asked for with the cross process refresh lock, which we never enable
615+ Box::new(|client| {
616+ client
617+ .session_tokens()
618+ .ok_or_else(|| AccountError::MissingSession.into())
619+ }),
620+ Box::new(move |client| {
621+ if !saving.load(Ordering::Acquire) {
622+ return Ok(());
623+ }
624+ let record = session_record(&client)?;
625+ if let Some(result) = sessions.save_blocking(&record) {
626+ let _ = results.send(result.map_err(save_failed));
627+ }
628+ Ok(())
629+ }),
630+ )
631+ .map_err(|error| account_error("registering the session callbacks", error))?;
632+ Ok(enabled)
633+}
634+
635+fn save_failed(error: AccountError) -> crate::Error {
636+ let message = format!("saving the session record failed, {error}");
637+ crate::Error::with_source(ErrorKind::Store, message, error)
638+}
639+
640+pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {
641+ let authentication = match client.session().ok_or(AccountError::MissingSession)? {
642+ AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),
643+ AuthSession::OAuth(session) => {
644+ let OAuthSession { client_id, user } = *session;
645+ AuthenticationRecord::OAuth { client_id, user }
646+ }
647+ _ => return Err(AccountError::UnsupportedAuthentication),
648+ };
649+ Ok(SessionRecord {
650+ homeserver: client.homeserver().to_string(),
651+ authentication,
652+ })
653+}
654+
655+fn encryption_settings() -> EncryptionSettings {
656+ EncryptionSettings {
657+ auto_enable_cross_signing: true,
658+ backup_download_strategy: BackupDownloadStrategy::OneShot,
659+ auto_enable_backups: true,
660+ }
661+}
662+
663+/// `StoreLock` already keeps every other client off the store, and the sdk's lease would cost
664+/// a store write per locked section and a task renewing it every 50ms
665+fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {
666+ let builder = Client::builder()
667+ .with_encryption_settings(encryption_settings())
668+ .with_threading_support(ThreadingSupport::Enabled {
669+ with_subscriptions: false,
670+ })
671+ .handle_refresh_tokens()
672+ .cross_process_store_config(CrossProcessLockConfig::SingleProcess);
673+ match &store.location {
674+ #[cfg(feature = "sqlite")]
675+ StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),
676+ #[cfg(feature = "indexeddb")]
677+ StoreLocation::IndexedDb(name) => builder.indexeddb_store(name, Some(&store.passphrase)),
678+ }
679+}
680+
681+pub(crate) fn account_error(
682+ context: &'static str,
683+ source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
684+) -> AccountError {
685+ AccountError::Operation {
686+ context,
687+ source: Box::new(source),
688+ }
689+}
Added file: vendor/macltok/src/encryption.rs+903 additions
@@ -0,0 +1,903 @@
1+use std::sync::Arc;
2+
3+use futures_util::StreamExt;
4+use matrix_sdk::executor::{JoinHandle, spawn};
5+use matrix_sdk::{
6+ Client,
7+ encryption::{
8+ VerificationState,
9+ backups::BackupState,
10+ recovery::RecoveryState as SdkRecoveryState,
11+ verification::{
12+ SasState, SasVerification, Verification, VerificationRequest, VerificationRequestState,
13+ },
14+ },
15+ ruma::events::key::verification::VerificationMethod,
16+};
17+use ruma::{OwnedDeviceId, UserId};
18+use tokio::sync::Mutex;
19+
20+use crate::{
21+ EncryptionState, Error, ErrorKind, Event, KeyBackupState, RecoveryState, VerificationAction,
22+ VerificationState as UiState, VerificationTarget, model::VerificationFailure,
23+ transport::EventSender,
24+};
25+
26+pub(crate) fn encryption_state(
27+ device_id: OwnedDeviceId,
28+ recovery: SdkRecoveryState,
29+ verification: VerificationState,
30+ backup: BackupState,
31+) -> EncryptionState {
32+ EncryptionState {
33+ device_id,
34+ device_verified: matches!(verification, VerificationState::Verified),
35+ recovery: match recovery {
36+ SdkRecoveryState::Unknown => RecoveryState::Unknown,
37+ SdkRecoveryState::Disabled => RecoveryState::Disabled,
38+ SdkRecoveryState::Incomplete => RecoveryState::Incomplete,
39+ SdkRecoveryState::Enabled => RecoveryState::Enabled,
40+ },
41+ key_backup: match backup {
42+ BackupState::Unknown => KeyBackupState::Unknown,
43+ BackupState::Creating => KeyBackupState::Creating,
44+ BackupState::Enabling => KeyBackupState::Enabling,
45+ BackupState::Resuming => KeyBackupState::Resuming,
46+ BackupState::Enabled => KeyBackupState::Enabled,
47+ BackupState::Downloading => KeyBackupState::Downloading,
48+ BackupState::Disabling => KeyBackupState::Disabling,
49+ },
50+ }
51+}
52+
53+#[derive(Default)]
54+pub(crate) struct VerificationController {
55+ active: Arc<Mutex<VerificationSession>>,
56+ task: Option<JoinHandle<()>>,
57+}
58+
59+#[derive(Default)]
60+struct VerificationSession {
61+ request: Option<VerificationRequest>,
62+ sas: Option<SasVerification>,
63+}
64+
65+impl VerificationController {
66+ pub(crate) async fn apply(
67+ &mut self,
68+ sender: EventSender,
69+ action: VerificationAction,
70+ ) -> Result<(), Error> {
71+ match action {
72+ VerificationAction::Accept => self.accept().await,
73+ VerificationAction::Confirm => {
74+ // Retain a handle across the operation: the watcher clears
75+ // `active` when it publishes a terminal event, but this clone
76+ // still observes the shared SDK state afterwards.
77+ let sas = self.active.lock().await.sas.clone();
78+ match self.confirm().await {
79+ Ok(()) => Ok(()),
80+ Err(error) if error.kind == ErrorKind::VerificationInactive => Err(error),
81+ // The confirm advanced local SDK state before its send failed,
82+ // so the retained session could report success on retry without
83+ // network traffic. Best-effort cancel (no lock held across the
84+ // network), clear for restart, then report the terminal state.
85+ Err(error) => {
86+ // Stop the watcher first so it cannot emit a stale
87+ // Confirmed from the local transition we are unwinding.
88+ self.stop_task().await;
89+ // The peer can complete or cancel the verification
90+ // while our own Done send fails. The stopped watcher may
91+ // already have published that terminal outcome (losing the
92+ // claim below); otherwise this path owns it and must emit
93+ // it instead of regressing to Cancelled.
94+ if sas.as_ref().is_some_and(|sas| sas.is_done()) {
95+ if take_verification(&self.active).await {
96+ let _ =
97+ sender.send(Event::VerificationChanged(UiState::Done)).await;
98+ }
99+ return Err(error);
100+ }
101+ if let Some(reason) = sas
102+ .as_ref()
103+ .and_then(|sas| sas.cancel_info())
104+ .map(|info| info.reason().to_owned())
105+ {
106+ if take_verification(&self.active).await {
107+ let _ = sender
108+ .send(Event::VerificationChanged(UiState::Cancelled(reason)))
109+ .await;
110+ }
111+ return Err(error);
112+ }
113+ if let Some(sas) = sas {
114+ if sas.cancel().await.is_err() {
115+ // The best-effort unwind already advanced
116+ // local state; clearing here keeps the failed
117+ // cancellation from looking retryable. The
118+ // send is owned only if the watcher has not
119+ // already claimed the session for its own
120+ // terminal outcome.
121+ if take_verification(&self.active).await {
122+ let _ = sender
123+ .send(Event::VerificationChanged(UiState::Cancelled(
124+ error.to_string(),
125+ )))
126+ .await;
127+ }
128+ return Err(error);
129+ }
130+ // The peer can complete while the unwind cancel is in
131+ // flight and the SDK call no-ops; the Done then owns the
132+ // terminal outcome, so report it instead of attributing
133+ // it to the send failure.
134+ if sas.is_done() {
135+ if take_verification(&self.active).await {
136+ let _ = sender
137+ .send(Event::VerificationChanged(UiState::Done))
138+ .await;
139+ }
140+ return Err(error);
141+ }
142+ if let Some(info) = sas.cancel_info() {
143+ // Our own unwind cancel just marked the flow; it does
144+ // not own the outcome yet, so fall through and report
145+ // the send failure as Cancelled below. Only a peer
146+ // cancellation racing the unwind owns it instead.
147+ if !info.cancelled_by_us() {
148+ if take_verification(&self.active).await {
149+ let _ = sender
150+ .send(Event::VerificationChanged(UiState::Cancelled(
151+ info.reason().to_owned(),
152+ )))
153+ .await;
154+ }
155+ return Err(Error::new(
156+ ErrorKind::VerificationInactive,
157+ info.reason().to_owned(),
158+ ));
159+ }
160+ }
161+ }
162+ if take_verification(&self.active).await {
163+ let _ = sender
164+ .send(Event::VerificationChanged(UiState::Cancelled(
165+ error.to_string(),
166+ )))
167+ .await;
168+ }
169+ Err(error)
170+ }
171+ }
172+ }
173+ VerificationAction::Reject => self.reject().await,
174+ VerificationAction::Cancel => {
175+ // The SDK no-ops a cancel of an already-settled flow, so a
176+ // failed claim inside `cancel` already reported the true
177+ // outcome as an error and owns no event here. Only the claim
178+ // winner emits the local Cancelled.
179+ let claimed = self.cancel_claimed(Some(&sender)).await?;
180+ if claimed {
181+ sender
182+ .send(Event::VerificationChanged(UiState::Cancelled(
183+ "cancelled by this client".to_owned(),
184+ )))
185+ .await
186+ .map_err(|source| {
187+ Error::with_source(
188+ ErrorKind::Interrupted,
189+ "event receiver has closed",
190+ source,
191+ )
192+ })?;
193+ }
194+ Ok(())
195+ }
196+ }
197+ }
198+
199+ pub(crate) async fn start(
200+ &mut self,
201+ client: &Client,
202+ sender: EventSender,
203+ target: VerificationTarget,
204+ ) -> Result<(), Error> {
205+ client
206+ .encryption()
207+ .wait_for_e2ee_initialization_tasks()
208+ .await;
209+ let own_user_id = client.user_id().ok_or_else(|| {
210+ Error::new(
211+ ErrorKind::Unauthenticated,
212+ "the Matrix client is not authenticated",
213+ )
214+ })?;
215+ if self.has_active_verification().await {
216+ self.cancel().await?;
217+ } else {
218+ self.stop_task().await;
219+ }
220+ let request = match &target {
221+ VerificationTarget::OwnIdentity => {
222+ request_identity_verification(client, own_user_id).await?
223+ }
224+ VerificationTarget::User(user_id) => {
225+ request_identity_verification(client, user_id).await?
226+ }
227+ VerificationTarget::Device { user_id, device_id } => {
228+ let device = client
229+ .encryption()
230+ .get_device(user_id, device_id)
231+ .await
232+ .map_err(|source| {
233+ Error::with_source(
234+ ErrorKind::Encryption,
235+ "reading the verification device failed",
236+ source,
237+ )
238+ })?
239+ .ok_or_else(|| {
240+ Error::new(
241+ ErrorKind::VerificationUnsupported,
242+ "the verification device is unavailable",
243+ )
244+ })?;
245+ device
246+ .request_verification_with_methods(vec![VerificationMethod::SasV1])
247+ .await
248+ .map_err(|source| {
249+ Error::from_sdk("starting device verification failed", source)
250+ })?
251+ }
252+ };
253+ // subscribed before anything can wait on the event channel, or a quick peer's
254+ // answer lands while we're blocked and nobody ever hears it
255+ let changes = request.changes();
256+ self.active.lock().await.request = Some(request.clone());
257+ sender
258+ .send(Event::VerificationTargetChanged(target))
259+ .await
260+ .map_err(|source| {
261+ Error::with_source(ErrorKind::Interrupted, "event receiver has closed", source)
262+ })?;
263+ sender
264+ .send(Event::VerificationChanged(UiState::Waiting))
265+ .await
266+ .map_err(|source| {
267+ Error::with_source(ErrorKind::Interrupted, "event receiver has closed", source)
268+ })?;
269+ let active = self.active.clone();
270+ self.task = Some(spawn(async move {
271+ follow_request(request, changes, active, sender).await;
272+ }));
273+ Ok(())
274+ }
275+
276+ /// another device asked us. only taken when nothing else is in flight, and not accepted
277+ /// until the user says so with `VerificationAction::Accept`
278+ pub(crate) async fn adopt_incoming(
279+ &mut self,
280+ sender: EventSender,
281+ request: VerificationRequest,
282+ ) {
283+ if request.we_started() || request.is_done() || request.cancel_info().is_some() {
284+ return;
285+ }
286+ if self.has_active_verification().await {
287+ tracing::debug!("ignoring a verification request while another is in flight");
288+ return;
289+ }
290+ self.stop_task().await;
291+ let changes = request.changes();
292+ self.active.lock().await.request = Some(request.clone());
293+ let incoming = UiState::Incoming {
294+ user_id: request.other_user_id().to_owned(),
295+ device_id: match request.state() {
296+ VerificationRequestState::Requested {
297+ other_device_data, ..
298+ }
299+ | VerificationRequestState::Ready {
300+ other_device_data, ..
301+ } => Some(other_device_data.device_id().to_owned()),
302+ _ => None,
303+ },
304+ };
305+ let target = VerificationTarget::User(request.other_user_id().to_owned());
306+ for event in [
307+ Event::VerificationTargetChanged(target),
308+ Event::VerificationChanged(incoming),
309+ ] {
310+ if sender.send(event).await.is_err() {
311+ return;
312+ }
313+ }
314+ let active = self.active.clone();
315+ self.task = Some(spawn(async move {
316+ follow_request(request, changes, active, sender).await;
317+ }));
318+ }
319+
320+ async fn accept(&self) -> Result<(), Error> {
321+ let inactive = |message: &str| Error::new(ErrorKind::VerificationInactive, message);
322+ let (request, sas) = {
323+ let active = self.active.lock().await;
324+ (active.request.clone(), active.sas.is_some())
325+ };
326+ let request = request.ok_or_else(|| inactive("no verification request is pending"))?;
327+ if request.we_started() {
328+ return Err(inactive("nobody asked us, we asked them"));
329+ }
330+ if request.is_done() {
331+ return Err(inactive("verification is already complete"));
332+ }
333+ if let Some(info) = request.cancel_info() {
334+ return Err(inactive(info.reason()));
335+ }
336+ if sas {
337+ return Err(inactive("verification has already started"));
338+ }
339+ request
340+ .accept_with_methods(vec![VerificationMethod::SasV1])
341+ .await
342+ .map_err(|source| Error::from_sdk("accepting verification request failed", source))
343+ }
344+
345+ async fn confirm(&self) -> Result<(), Error> {
346+ let sas = self.active.lock().await.sas.clone().ok_or_else(|| {
347+ Error::new(
348+ ErrorKind::VerificationInactive,
349+ "verification has not reached comparison",
350+ )
351+ })?;
352+ // Report a settled flow truthfully instead of as "not reached
353+ // comparison": the SDK would no-op the confirm with success.
354+ if sas.is_done() {
355+ return Err(Error::new(
356+ ErrorKind::VerificationInactive,
357+ "verification is already complete",
358+ ));
359+ }
360+ if let Some(info) = sas.cancel_info() {
361+ return Err(Error::new(
362+ ErrorKind::VerificationInactive,
363+ info.reason().to_owned(),
364+ ));
365+ }
366+ // Only the short-auth-string stage is confirmable. The SDK reports the
367+ // remote-MAC-arrived state through the same presentable flag, while an
368+ // early confirm would locally no-op and falsely report success.
369+ if !sas.can_be_presented() {
370+ return Err(Error::new(
371+ ErrorKind::VerificationInactive,
372+ "verification has not reached comparison",
373+ ));
374+ }
375+ // Our own confirm can move the flow to Done, so only Done that predates
376+ // the send means the SDK no-op'd on a stale session; a cancellation
377+ // racing the send must not report success.
378+ let was_done = sas.is_done();
379+ sas.confirm()
380+ .await
381+ .map_err(|source| Error::from_sdk("confirming verification failed", source))?;
382+ if sas.is_done() {
383+ if was_done {
384+ return Err(Error::new(
385+ ErrorKind::VerificationInactive,
386+ "verification is already complete",
387+ ));
388+ }
389+ } else if sas.is_cancelled() {
390+ let reason = sas
391+ .cancel_info()
392+ .map(|info| info.reason().to_owned())
393+ .unwrap_or_else(|| "verification was cancelled".to_owned());
394+ return Err(Error::new(ErrorKind::VerificationInactive, reason));
395+ }
396+ Ok(())
397+ }
398+
399+ async fn reject(&self) -> Result<(), Error> {
400+ let sas = self.active.lock().await.sas.clone().ok_or_else(|| {
401+ Error::new(
402+ ErrorKind::VerificationInactive,
403+ "verification has not reached comparison",
404+ )
405+ })?;
406+ // Report a settled flow truthfully instead of as "not reached
407+ // comparison": the SDK would no-op the mismatch with success.
408+ if sas.is_done() {
409+ return Err(Error::new(
410+ ErrorKind::VerificationInactive,
411+ "verification is already complete",
412+ ));
413+ }
414+ if let Some(info) = sas.cancel_info() {
415+ return Err(Error::new(
416+ ErrorKind::VerificationInactive,
417+ info.reason().to_owned(),
418+ ));
419+ }
420+ // Rejecting before the comparison stage would cancel a flow the user
421+ // never compared, so it requires the same presentable state.
422+ if !sas.can_be_presented() {
423+ return Err(Error::new(
424+ ErrorKind::VerificationInactive,
425+ "verification has not reached comparison",
426+ ));
427+ }
428+ sas.mismatch()
429+ .await
430+ .map_err(|source| Error::from_sdk("rejecting verification failed", source))?;
431+ // A peer completing the flow concurrently must not be reported as
432+ // rejected. A remote cancellation racing the send turns the SDK call
433+ // into a no-op success, so report it instead of a false success.
434+ if sas.is_done() {
435+ return Err(Error::new(
436+ ErrorKind::VerificationInactive,
437+ "verification is already complete",
438+ ));
439+ }
440+ if let Some(info) = sas.cancel_info() {
441+ // Our own mismatch just marked the flow cancelled-by-us; that is the
442+ // expected reject path and stays Ok. Only a peer cancel reports an
443+ // error.
444+ if !info.cancelled_by_us() {
445+ return Err(Error::new(
446+ ErrorKind::VerificationInactive,
447+ info.reason().to_owned(),
448+ ));
449+ }
450+ }
451+ Ok(())
452+ }
453+ pub(crate) async fn cancel(&mut self) -> Result<(), Error> {
454+ self.cancel_claimed(None).await.map(|_| ())
455+ }
456+
457+ /// Return whether this action won the claim for a local cancellation.
458+ /// The optional sender reports a peer terminal outcome when it races after
459+ /// the claim; starting a replacement verification does not publish it.
460+ async fn cancel_claimed(&mut self, sender: Option<&EventSender>) -> Result<bool, Error> {
461+ let (sas, request) = {
462+ let active = self.active.lock().await;
463+ (active.sas.clone(), active.request.clone())
464+ };
465+ if sas.is_none() && request.is_none() {
466+ return Err(Error::new(
467+ ErrorKind::VerificationInactive,
468+ "no verification is active",
469+ ));
470+ }
471+ // The watcher owns terminal emission and claims the session when it
472+ // publishes. If the SDK is already terminal, the watcher either already
473+ // reported it or is about to: leave session and watcher alone so the
474+ // outcome is emitted exactly once, and report it instead of letting the
475+ // SDK no-op success regress Done to "cancelled by this client".
476+ if let Some(sas) = sas.as_ref() {
477+ if sas.is_done() {
478+ return Err(Error::new(
479+ ErrorKind::VerificationInactive,
480+ "verification is already complete",
481+ ));
482+ }
483+ if let Some(info) = sas.cancel_info() {
484+ return Err(Error::new(
485+ ErrorKind::VerificationInactive,
486+ info.reason().to_owned(),
487+ ));
488+ }
489+ } else if let Some(request) = request.as_ref() {
490+ if request.is_done() {
491+ return Err(Error::new(
492+ ErrorKind::VerificationInactive,
493+ "verification is already complete",
494+ ));
495+ }
496+ if let Some(info) = request.cancel_info() {
497+ return Err(Error::new(
498+ ErrorKind::VerificationInactive,
499+ info.reason().to_owned(),
500+ ));
501+ }
502+ }
503+ // Own the terminal outcome from here: claim the session before
504+ // stopping the watcher so a watcher that already claimed (and will
505+ // emit its own terminal event) turns this into a truthful error
506+ // instead of a duplicate local cancel; no lock is held across sends.
507+ if !take_verification(&self.active).await {
508+ // Re-check the SDK: the claim loss may be a stale session after a
509+ // terminal outcome, or a concurrent start of a new flow. Only the
510+ // terminal case reports here; a live session belongs to the new
511+ // flow and must not be disturbed.
512+ if let Some(sas) = sas.as_ref() {
513+ if sas.is_done() {
514+ return Err(Error::new(
515+ ErrorKind::VerificationInactive,
516+ "verification is already complete",
517+ ));
518+ }
519+ if let Some(info) = sas.cancel_info() {
520+ return Err(Error::new(
521+ ErrorKind::VerificationInactive,
522+ info.reason().to_owned(),
523+ ));
524+ }
525+ } else if let Some(request) = request.as_ref() {
526+ if request.is_done() {
527+ return Err(Error::new(
528+ ErrorKind::VerificationInactive,
529+ "verification is already complete",
530+ ));
531+ }
532+ if let Some(info) = request.cancel_info() {
533+ return Err(Error::new(
534+ ErrorKind::VerificationInactive,
535+ info.reason().to_owned(),
536+ ));
537+ }
538+ }
539+ return Err(Error::new(
540+ ErrorKind::VerificationInactive,
541+ "no verification is active",
542+ ));
543+ }
544+ self.stop_task().await;
545+ if let Some(sas) = sas {
546+ let result = sas.cancel().await;
547+ if sas.is_done() {
548+ report_claimed_terminal(sender, UiState::Done).await;
549+ return Err(Error::new(
550+ ErrorKind::VerificationInactive,
551+ "verification is already complete",
552+ ));
553+ }
554+ if let Some(info) = sas.cancel_info()
555+ && !info.cancelled_by_us()
556+ {
557+ let reason = info.reason().to_owned();
558+ report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;
559+ return Err(Error::new(ErrorKind::VerificationInactive, reason));
560+ }
561+ if let Err(source) = result {
562+ let error = Error::from_sdk("cancelling verification failed", source);
563+ report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;
564+ return Err(error);
565+ }
566+ Ok(true)
567+ } else if let Some(request) = request {
568+ let result = request.cancel().await;
569+ if request.is_done() {
570+ report_claimed_terminal(sender, UiState::Done).await;
571+ return Err(Error::new(
572+ ErrorKind::VerificationInactive,
573+ "verification is already complete",
574+ ));
575+ }
576+ if let Some(info) = request.cancel_info()
577+ && !info.cancelled_by_us()
578+ {
579+ let reason = info.reason().to_owned();
580+ report_claimed_terminal(sender, UiState::Cancelled(reason.clone())).await;
581+ return Err(Error::new(ErrorKind::VerificationInactive, reason));
582+ }
583+ if let Err(source) = result {
584+ let error = Error::from_sdk("cancelling verification failed", source);
585+ report_claimed_terminal(sender, UiState::Cancelled(error.to_string())).await;
586+ return Err(error);
587+ }
588+ Ok(true)
589+ } else {
590+ Ok(false)
591+ }
592+ }
593+
594+ async fn has_active_verification(&self) -> bool {
595+ let active = self.active.lock().await;
596+ active.request.is_some() || active.sas.is_some()
597+ }
598+
599+ async fn stop_task(&mut self) {
600+ if let Some(task) = self.task.take() {
601+ task.abort();
602+ let _ = task.await;
603+ }
604+ }
605+}
606+
607+/// shutdown forgets the flow without telling the sdk, a network send could hang it
608+impl Drop for VerificationController {
609+ fn drop(&mut self) {
610+ if let Some(task) = self.task.take() {
611+ task.abort();
612+ }
613+ }
614+}
615+
616+async fn request_identity_verification(
617+ client: &Client,
618+ user_id: &UserId,
619+) -> Result<VerificationRequest, Error> {
620+ let identity = client
621+ .encryption()
622+ .request_user_identity(user_id)
623+ .await
624+ .map_err(|source| Error::from_sdk("requesting the user identity failed", source))?
625+ .ok_or_else(|| {
626+ Error::new(
627+ ErrorKind::NoCrossSigningIdentity,
628+ "the user has no cross-signing identity",
629+ )
630+ })?;
631+ identity
632+ .request_verification_with_methods(vec![VerificationMethod::SasV1])
633+ .await
634+ .map_err(|source| Error::from_verification_request("starting verification failed", source))
635+}
636+
637+/// `changes` is subscribed by the caller before the request could move, and the current state
638+/// is handled first, since the stream only reports what happens after it was taken
639+async fn follow_request(
640+ request: VerificationRequest,
641+ changes: impl futures_util::Stream<Item = VerificationRequestState> + Unpin,
642+ active: Arc<Mutex<VerificationSession>>,
643+ sender: EventSender,
644+) {
645+ let mut states = futures_util::stream::iter([request.state()]).chain(changes);
646+ while let Some(state) = states.next().await {
647+ match state {
648+ // created is ours waiting on them, requested is theirs waiting on `Accept`
649+ VerificationRequestState::Created { .. }
650+ | VerificationRequestState::Requested { .. } => {}
651+ VerificationRequestState::Ready { .. } => match request.start_sas().await {
652+ Ok(Some(sas)) => {
653+ follow_sas(sas, active.clone(), sender.clone()).await;
654+ return;
655+ }
656+ Ok(None) => {}
657+ Err(error) => {
658+ tracing::warn!(error = %error, "starting SAS verification failed");
659+ // Claim the session so this terminal outcome is emitted exactly
660+ // once; a lost claim means an action path already owns it.
661+ report_watcher_terminal(
662+ &active,
663+ &sender,
664+ UiState::Failed(VerificationFailure::SasStartup(error.to_string())),
665+ "SAS startup failure",
666+ )
667+ .await;
668+ return;
669+ }
670+ },
671+ VerificationRequestState::Transitioned { verification } => {
672+ if let Verification::SasV1(sas) = verification {
673+ follow_sas(sas, active.clone(), sender.clone()).await;
674+ } else {
675+ report_watcher_terminal(
676+ &active,
677+ &sender,
678+ UiState::Cancelled(
679+ "the other device selected an unsupported method".into(),
680+ ),
681+ "unsupported verification method",
682+ )
683+ .await;
684+ }
685+ return;
686+ }
687+ VerificationRequestState::Done => {
688+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;
689+ return;
690+ }
691+ VerificationRequestState::Cancelled(info) => {
692+ report_watcher_terminal(
693+ &active,
694+ &sender,
695+ UiState::Cancelled(info.reason().to_owned()),
696+ "cancellation",
697+ )
698+ .await;
699+ return;
700+ }
701+ }
702+ }
703+ tracing::debug!("verification request watcher ended unexpectedly");
704+ // The stream may end after the SDK already settled without delivering the
705+ // terminal update; report the settled outcome instead of StreamEnded, and
706+ // stay silent if an action path already claimed the session.
707+ if request.is_done() {
708+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;
709+ } else if let Some(info) = request.cancel_info() {
710+ let reason = info.reason().to_owned();
711+ report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;
712+ } else {
713+ report_watcher_terminal(
714+ &active,
715+ &sender,
716+ UiState::Failed(VerificationFailure::RequestStreamEnded),
717+ "request stream termination",
718+ )
719+ .await;
720+ }
721+}
722+
723+async fn follow_sas(
724+ sas: SasVerification,
725+ active: Arc<Mutex<VerificationSession>>,
726+ sender: EventSender,
727+) {
728+ // the sdk may already have moved the flow along before we got here, a peer starting
729+ // sas is the usual case, so take the stream first and the current state after
730+ let changes = sas.changes();
731+ active.lock().await.sas = Some(sas.clone());
732+ let mut states = futures_util::stream::iter([sas.state()]).chain(changes);
733+ while let Some(state) = states.next().await {
734+ let update = match state {
735+ SasState::Created { .. } | SasState::Accepted { .. } => continue,
736+ SasState::Started { .. } => {
737+ if let Err(error) = sas.accept().await {
738+ tracing::warn!(error = %error, "accepting SAS verification failed");
739+ // Claim the session so this terminal outcome is emitted exactly
740+ // once; a lost claim means an action path already owns it.
741+ report_watcher_terminal(
742+ &active,
743+ &sender,
744+ UiState::Failed(VerificationFailure::SasAcceptance(error.to_string())),
745+ "SAS acceptance failure",
746+ )
747+ .await;
748+ return;
749+ }
750+ continue;
751+ }
752+ SasState::KeysExchanged { emojis, decimals } => UiState::Comparison {
753+ emojis: emojis
754+ .map(|short_auth| {
755+ short_auth
756+ .emojis
757+ .into_iter()
758+ .map(|emoji| (emoji.symbol.to_owned(), emoji.description.to_owned()))
759+ .collect()
760+ })
761+ .unwrap_or_default(),
762+ decimals,
763+ },
764+ SasState::Confirmed => UiState::Confirmed,
765+ SasState::Done { .. } => UiState::Done,
766+ SasState::Cancelled(info) => UiState::Cancelled(info.reason().to_owned()),
767+ };
768+ let terminal = matches!(update, UiState::Done | UiState::Cancelled(_));
769+ // Claim the session before the terminal send so a racing Cancel observes
770+ // no active session and the SDK no-op cannot regress Done back to
771+ // "cancelled by this client". A lost claim means an action path already
772+ // owns the terminal outcome, so stay silent instead of duplicating it.
773+ if terminal && !take_verification(&active).await {
774+ return;
775+ }
776+ if sender
777+ .send(Event::VerificationChanged(update))
778+ .await
779+ .is_err()
780+ {
781+ tracing::debug!("SAS watcher stopped because the event receiver closed");
782+ return;
783+ }
784+ if terminal {
785+ return;
786+ }
787+ }
788+ tracing::debug!("SAS watcher ended unexpectedly");
789+ // The stream may end after the SDK already settled without delivering the
790+ // terminal update; report the settled outcome instead of StreamEnded, and
791+ // stay silent if an action path already claimed the session.
792+ if sas.is_done() {
793+ report_watcher_terminal(&active, &sender, UiState::Done, "completion").await;
794+ } else if let Some(info) = sas.cancel_info() {
795+ let reason = info.reason().to_owned();
796+ report_watcher_terminal(&active, &sender, UiState::Cancelled(reason), "cancellation").await;
797+ } else {
798+ report_watcher_terminal(
799+ &active,
800+ &sender,
801+ UiState::Failed(VerificationFailure::SasStreamEnded),
802+ "SAS stream termination",
803+ )
804+ .await;
805+ }
806+}
807+
808+async fn report_watcher_terminal(
809+ active: &Arc<Mutex<VerificationSession>>,
810+ sender: &EventSender,
811+ state: UiState,
812+ context: &'static str,
813+) {
814+ if take_verification(active).await
815+ && sender
816+ .send(Event::VerificationChanged(state))
817+ .await
818+ .is_err()
819+ {
820+ tracing::debug!(
821+ context,
822+ "verification watcher could not report terminal outcome"
823+ );
824+ }
825+}
826+
827+async fn report_claimed_terminal(sender: Option<&EventSender>, state: UiState) {
828+ if let Some(sender) = sender {
829+ let _ = sender.send(Event::VerificationChanged(state)).await;
830+ }
831+}
832+
833+/// Claim the active session for terminal emission, if one is still present.
834+///
835+/// Exactly one party (watcher or action path) wins the claim and may emit the
836+/// terminal event; the loser stays silent so Done is never overwritten by a
837+/// later local Cancelled/Failed.
838+async fn take_verification(active: &Arc<Mutex<VerificationSession>>) -> bool {
839+ let mut active = active.lock().await;
840+ let had = active.sas.is_some() || active.request.is_some();
841+ active.sas.take();
842+ active.request.take();
843+ had
844+}
845+
846+#[cfg(test)]
847+mod tests {
848+ use matrix_sdk::encryption::VerificationState;
849+ use matrix_sdk::encryption::backups::BackupState;
850+ use ruma::owned_device_id;
851+
852+ use super::*;
853+
854+ #[test]
855+ fn sdk_security_state_is_normalized() {
856+ assert_eq!(
857+ encryption_state(
858+ owned_device_id!("DEVICE"),
859+ SdkRecoveryState::Incomplete,
860+ VerificationState::Unverified,
861+ BackupState::Unknown,
862+ ),
863+ EncryptionState {
864+ device_id: owned_device_id!("DEVICE"),
865+ device_verified: false,
866+ recovery: RecoveryState::Incomplete,
867+ key_backup: KeyBackupState::Unknown,
868+ }
869+ );
870+ assert_eq!(
871+ encryption_state(
872+ owned_device_id!("DEVICE"),
873+ SdkRecoveryState::Enabled,
874+ VerificationState::Verified,
875+ BackupState::Enabled,
876+ ),
877+ EncryptionState {
878+ device_id: owned_device_id!("DEVICE"),
879+ device_verified: true,
880+ recovery: RecoveryState::Enabled,
881+ key_backup: KeyBackupState::Enabled,
882+ }
883+ );
884+ }
885+
886+ #[tokio::test]
887+ async fn comparison_actions_require_an_active_sas() {
888+ let mut controller = VerificationController::default();
889+
890+ assert_eq!(
891+ controller.confirm().await.unwrap_err().to_string(),
892+ "verification has not reached comparison"
893+ );
894+ assert_eq!(
895+ controller.reject().await.unwrap_err().to_string(),
896+ "verification has not reached comparison"
897+ );
898+ assert_eq!(
899+ controller.cancel().await.unwrap_err().to_string(),
900+ "no verification is active"
901+ );
902+ }
903+}
Added file: vendor/macltok/src/engine/commands.rs+329 additions
@@ -0,0 +1,329 @@
1+use std::{future::Future, panic::AssertUnwindSafe};
2+
3+use futures_util::FutureExt;
4+use matrix_sdk::encryption::verification::VerificationRequest;
5+use matrix_sdk::{
6+ Client,
7+ notification_settings::{IsEncrypted, IsOneToOne, RoomNotificationMode as SdkMode},
8+ ruma::{
9+ OwnedDeviceId, OwnedEventId, RoomId, TransactionId,
10+ api::client::receipt::create_receipt::v3::ReceiptType, events::receipt::ReceiptThread,
11+ },
12+ send_queue::{LocalEchoContent, SendHandle},
13+};
14+use tokio::sync::mpsc;
15+
16+use crate::{
17+ EncryptionCommand, Error, ErrorKind, InteractiveAuth, InteractiveAuthChallenge,
18+ NotificationCommand, RoomNotificationMode, TimelineId,
19+ encryption::VerificationController,
20+ transport::{EventSender, Responder},
21+};
22+
23+pub(super) struct Job<T> {
24+ pub(super) command: T,
25+ pub(super) reply: Responder,
26+}
27+
28+/// a panicking command answers with an error instead of taking its worker down with it
29+pub(super) async fn guarded<T>(
30+ label: &'static str,
31+ work: impl Future<Output = Result<T, Error>>,
32+) -> Result<T, Error> {
33+ AssertUnwindSafe(work)
34+ .catch_unwind()
35+ .await
36+ .unwrap_or_else(|_| Err(Error::new(ErrorKind::Other, format!("{label} panicked"))))
37+}
38+
39+pub(super) async fn run_notifications(
40+ client: Client,
41+ mut jobs: mpsc::Receiver<Job<NotificationCommand>>,
42+) {
43+ while let Some(Job { command, reply }) = jobs.recv().await {
44+ reply.done(guarded("notification command", notification(&client, command)).await);
45+ }
46+}
47+
48+pub(super) async fn run_encryption(
49+ client: Client,
50+ events: EventSender,
51+ mut jobs: mpsc::Receiver<Job<EncryptionCommand>>,
52+ mut incoming: mpsc::UnboundedReceiver<VerificationRequest>,
53+) {
54+ let mut verification = VerificationController::default();
55+ loop {
56+ tokio::select! {
57+ job = jobs.recv() => {
58+ let Some(Job { command, reply }) = job else { return };
59+ let work = encryption(&client, &events, &mut verification, command);
60+ reply.done(guarded("encryption command", work).await);
61+ }
62+ Some(request) = incoming.recv() => {
63+ verification.adopt_incoming(events.clone(), request).await;
64+ }
65+ }
66+ }
67+}
68+
69+async fn encryption(
70+ client: &Client,
71+ events: &EventSender,
72+ verification: &mut VerificationController,
73+ command: EncryptionCommand,
74+) -> Result<(), Error> {
75+ let encryption = client.encryption();
76+ match command {
77+ EncryptionCommand::Recover(key) => encryption
78+ .recovery()
79+ .recover(&key)
80+ .await
81+ .map_err(|source| Error::from_recovery("account recovery failed", source)),
82+ EncryptionCommand::RecoverKeyBackup(key) => encryption
83+ .recovery()
84+ .recover_and_fix_backup(&key)
85+ .await
86+ .map_err(|source| Error::from_recovery("recovering key backup failed", source)),
87+ EncryptionCommand::CreateKeyBackup => encryption
88+ .backups()
89+ .create()
90+ .await
91+ .map_err(|source| Error::from_sdk("creating key backup failed", source)),
92+ EncryptionCommand::EnableKeyBackup => encryption
93+ .recovery()
94+ .enable_backup()
95+ .await
96+ .map_err(|source| Error::from_recovery("enabling key backup failed", source)),
97+ EncryptionCommand::DisableKeyBackup { delete_from_server } => {
98+ let backups = encryption.backups();
99+ if delete_from_server {
100+ backups.disable_and_delete().await
101+ } else {
102+ backups.disable().await
103+ }
104+ .map_err(|source| Error::from_sdk("disabling key backup failed", source))
105+ }
106+ EncryptionCommand::StartVerification(target) => {
107+ verification.start(client, events.clone(), target).await
108+ }
109+ EncryptionCommand::Verify(action) => verification.apply(events.clone(), action).await,
110+ }
111+}
112+
113+async fn notification(client: &Client, command: NotificationCommand) -> Result<(), Error> {
114+ let settings = client.notification_settings().await;
115+ match command {
116+ NotificationCommand::SetRoomMode { room_id, mode } => {
117+ settings
118+ .set_room_notification_mode(&room_id, sdk_mode(mode))
119+ .await
120+ }
121+ NotificationCommand::ResetRoomMode(room_id) => {
122+ settings.delete_user_defined_room_rules(&room_id).await
123+ }
124+ NotificationCommand::SetDefaultMode {
125+ encrypted,
126+ one_to_one,
127+ mode,
128+ } => {
129+ settings
130+ .set_default_room_notification_mode(
131+ IsEncrypted::from(encrypted),
132+ IsOneToOne::from(one_to_one),
133+ sdk_mode(mode),
134+ )
135+ .await
136+ }
137+ NotificationCommand::AddKeyword(keyword) => settings.add_keyword(keyword).await,
138+ NotificationCommand::RemoveKeyword(keyword) => settings.remove_keyword(&keyword).await,
139+ }
140+ .map_err(|source| {
141+ Error::with_source(
142+ ErrorKind::Server,
143+ "updating notification settings failed",
144+ source,
145+ )
146+ })
147+}
148+
149+const fn sdk_mode(mode: RoomNotificationMode) -> SdkMode {
150+ match mode {
151+ RoomNotificationMode::AllMessages => SdkMode::AllMessages,
152+ RoomNotificationMode::MentionsAndKeywordsOnly => SdkMode::MentionsAndKeywordsOnly,
153+ RoomNotificationMode::Mute => SdkMode::Mute,
154+ }
155+}
156+
157+fn room(client: &Client, room_id: &RoomId) -> Result<matrix_sdk::Room, Error> {
158+ client.get_room(room_id).ok_or_else(Error::room_unknown)
159+}
160+
161+/// the closed-room path. without a timeline nothing stops the marker going backwards
162+pub(super) async fn mark_read(
163+ client: &Client,
164+ timeline_id: &TimelineId,
165+ event_id: OwnedEventId,
166+) -> Result<(), Error> {
167+ let room = room(client, timeline_id.room_id())?;
168+ let receipt_failed = |source| Error::from_sdk("sending read receipt failed", source);
169+ let thread = match timeline_id.thread_root() {
170+ None => ReceiptThread::Main,
171+ Some(root) => ReceiptThread::Thread(root.clone()),
172+ };
173+ room.send_single_receipt(ReceiptType::Read, thread, event_id.clone())
174+ .await
175+ .map_err(receipt_failed)?;
176+ if timeline_id.thread_root().is_none() {
177+ room.send_single_receipt(ReceiptType::FullyRead, ReceiptThread::Unthreaded, event_id)
178+ .await
179+ .map_err(receipt_failed)?;
180+ }
181+ Ok(())
182+}
183+
184+pub(super) async fn set_typing(
185+ client: &Client,
186+ room_id: &RoomId,
187+ typing: bool,
188+) -> Result<(), Error> {
189+ room(client, room_id)?
190+ .typing_notice(typing)
191+ .await
192+ .map_err(|source| Error::from_sdk("typing update failed", source))
193+}
194+
195+async fn send_handle(
196+ client: &Client,
197+ room_id: &RoomId,
198+ transaction_id: &TransactionId,
199+) -> Result<SendHandle, Error> {
200+ let (echoes, _) = room(client, room_id)?
201+ .send_queue()
202+ .subscribe()
203+ .await
204+ .map_err(|source| {
205+ Error::with_source(ErrorKind::Store, "reading send queue failed", source)
206+ })?;
207+ echoes
208+ .into_iter()
209+ .find_map(|echo| match echo.content {
210+ LocalEchoContent::Event { send_handle, .. }
211+ if echo.transaction_id.as_str() == transaction_id.as_str() =>
212+ {
213+ Some(send_handle)
214+ }
215+ _ => None,
216+ })
217+ .ok_or_else(|| {
218+ Error::new(
219+ ErrorKind::Invalid,
220+ "send is no longer pending or is not a message",
221+ )
222+ })
223+}
224+
225+pub(super) async fn cancel_send(
226+ client: &Client,
227+ room_id: &RoomId,
228+ transaction_id: &TransactionId,
229+) -> Result<(), Error> {
230+ match send_handle(client, room_id, transaction_id)
231+ .await?
232+ .abort()
233+ .await
234+ {
235+ Ok(true) => Ok(()),
236+ Ok(false) => Err(Error::new(
237+ ErrorKind::Invalid,
238+ "send could not be cancelled because it has already been sent",
239+ )),
240+ Err(source) => Err(Error::with_source(
241+ ErrorKind::Store,
242+ "cancelling send failed",
243+ source,
244+ )),
245+ }
246+}
247+
248+pub(super) async fn retry_send(
249+ client: &Client,
250+ room_id: &RoomId,
251+ transaction_id: &TransactionId,
252+) -> Result<(), Error> {
253+ send_handle(client, room_id, transaction_id)
254+ .await?
255+ .unwedge()
256+ .await
257+ .map_err(|source| Error::with_source(ErrorKind::Store, "retrying send failed", source))?;
258+ room(client, room_id)?.send_queue().set_enabled(true);
259+ Ok(())
260+}
261+
262+pub(super) async fn upload_avatar(
263+ client: &Client,
264+ mimetype: &str,
265+ bytes: Vec<u8>,
266+ max_bytes: usize,
267+) -> Result<(), Error> {
268+ if bytes.len() > max_bytes {
269+ return Err(Error::new(
270+ ErrorKind::MediaTooLarge,
271+ "avatar exceeds the configured byte limit",
272+ ));
273+ }
274+ let mimetype: mime::Mime = mimetype
275+ .parse()
276+ .map_err(|_| Error::new(ErrorKind::Invalid, "avatar MIME type is invalid"))?;
277+ if mimetype.type_() != mime::IMAGE {
278+ return Err(Error::new(
279+ ErrorKind::Invalid,
280+ "an avatar has to be an image",
281+ ));
282+ }
283+ client
284+ .account()
285+ .upload_avatar(&mimetype, bytes)
286+ .await
287+ .map(drop)
288+ .map_err(|source| Error::from_sdk("uploading avatar failed", source))
289+}
290+
291+pub(super) async fn rename_device(
292+ client: &Client,
293+ device_id: &OwnedDeviceId,
294+ display_name: &str,
295+) -> Result<(), Error> {
296+ client
297+ .rename_device(device_id, display_name)
298+ .await
299+ .map(drop)
300+ .map_err(|source| Error::from_http("renaming device failed", source))
301+}
302+
303+pub(super) async fn delete_devices(
304+ client: &Client,
305+ device_ids: &[OwnedDeviceId],
306+ auth: Option<InteractiveAuth>,
307+) -> Result<(), Error> {
308+ let user_id = client
309+ .user_id()
310+ .ok_or_else(|| Error::new(ErrorKind::Unauthenticated, "the client is not logged in"))?;
311+ let auth = auth
312+ .map(|auth| auth.into_sdk(user_id))
313+ .transpose()
314+ .map_err(|message| {
315+ Error::new(
316+ ErrorKind::Invalid,
317+ format!("interactive authentication data is invalid, {message}"),
318+ )
319+ })?;
320+ match client.delete_devices(device_ids, auth).await {
321+ Ok(_) => Ok(()),
322+ Err(source) => match source.as_uiaa_response() {
323+ Some(info) => Err(Error::interactive_auth(InteractiveAuthChallenge::from(
324+ info,
325+ ))),
326+ None => Err(Error::from_http("deleting devices failed", source)),
327+ },
328+ }
329+}
Added file: vendor/macltok/src/engine/executor.rs+82 additions
@@ -0,0 +1,82 @@
1+use std::future::Future;
2+
3+use futures_util::{StreamExt, stream::FuturesUnordered};
4+use matrix_sdk::SendOutsideWasm;
5+pub(super) use matrix_sdk::executor::{AbortHandle, JoinError, JoinHandle, spawn};
6+
7+#[cfg(not(target_family = "wasm"))]
8+pub(crate) type EngineBoxFuture<'a, T> = futures_util::future::BoxFuture<'a, T>;
9+#[cfg(target_family = "wasm")]
10+pub(crate) type EngineBoxFuture<'a, T> = futures_util::future::LocalBoxFuture<'a, T>;
11+
12+#[cfg(not(target_family = "wasm"))]
13+pub(super) fn is_panic(error: &JoinError) -> bool {
14+ error.is_panic()
15+}
16+
17+/// the sdk's wasm join error has no payload, and a real panic traps before it gets here
18+#[cfg(target_family = "wasm")]
19+pub(super) fn is_panic(error: &JoinError) -> bool {
20+ !error.is_cancelled()
21+}
22+
23+#[cfg(not(target_family = "wasm"))]
24+pub(super) fn resume_unwind(error: JoinError) -> ! {
25+ std::panic::resume_unwind(error.into_panic())
26+}
27+
28+#[cfg(target_family = "wasm")]
29+pub(super) fn resume_unwind(error: JoinError) -> ! {
30+ panic!("{error}")
31+}
32+
33+/// a `JoinSet` that runs on wasm too, and like it aborts whatever's left when dropped
34+pub(super) struct TaskSet<T: 'static> {
35+ handles: FuturesUnordered<JoinHandle<T>>,
36+}
37+
38+impl<T: 'static> TaskSet<T> {
39+ pub(super) fn new() -> Self {
40+ Self {
41+ handles: FuturesUnordered::new(),
42+ }
43+ }
44+
45+ pub(super) fn spawn(
46+ &mut self,
47+ task: impl Future<Output = T> + SendOutsideWasm + 'static,
48+ ) -> AbortHandle
49+ where
50+ T: SendOutsideWasm,
51+ {
52+ let handle = spawn(task);
53+ let abort = handle.abort_handle();
54+ self.handles.push(handle);
55+ abort
56+ }
57+
58+ pub(super) fn is_empty(&self) -> bool {
59+ self.handles.is_empty()
60+ }
61+
62+ pub(super) async fn join_next(&mut self) -> Option<Result<T, JoinError>> {
63+ self.handles.next().await
64+ }
65+
66+ fn abort_all(&self) {
67+ for handle in &self.handles {
68+ handle.abort();
69+ }
70+ }
71+
72+ pub(super) async fn shutdown(&mut self) {
73+ self.abort_all();
74+ while self.handles.next().await.is_some() {}
75+ }
76+}
77+
78+impl<T: 'static> Drop for TaskSet<T> {
79+ fn drop(&mut self) {
80+ self.abort_all();
81+ }
82+}
Added file: vendor/macltok/src/engine/media.rs+736 additions
@@ -0,0 +1,736 @@
1+use std::{
2+ borrow::Cow,
3+ collections::{HashMap, VecDeque},
4+ io::Read as _,
5+ panic::AssertUnwindSafe,
6+ sync::Arc,
7+ time::Duration,
8+};
9+
10+use futures_util::{
11+ FutureExt, StreamExt,
12+ future::{Shared, WeakShared},
13+};
14+use matrix_sdk::{
15+ Client, Room, SendOutsideWasm, SyncOutsideWasm,
16+ media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings},
17+ room::reply::{EnforceThread, Reply as SdkReply},
18+ ruma::{
19+ api::{
20+ Metadata, OutgoingRequest,
21+ auth_scheme::SendAccessToken,
22+ client::{authenticated_media, media},
23+ },
24+ events::room::{
25+ EncryptedFileHash, EncryptedFileHashAlgorithm, EncryptedFileInfo, MediaSource,
26+ message::{AddMentions, ReplyWithinThread},
27+ },
28+ },
29+};
30+use matrix_sdk_crypto::{AttachmentDecryptor, MediaEncryptionInfo};
31+use ruma::MxcUri;
32+use tokio::sync::Mutex;
33+
34+use super::executor::EngineBoxFuture;
35+use crate::{
36+ AttachmentEncryption, AttachmentUpload, Error, ErrorKind, Media, MediaFile, MediaId, MediaKind,
37+ MediaLocation, MediaSize, TimelineId,
38+ normalise::{MediaRegistration, MediaScope},
39+ upload::prepare_upload,
40+};
41+
42+/// a stalled download gives up after this long without a byte
43+const DOWNLOAD_IDLE: Duration = Duration::from_secs(60);
44+/// the sdk client gives up on any request after thirty seconds, far too soon for a video
45+const DOWNLOAD_TOTAL: Duration = Duration::from_secs(60 * 60);
46+
47+type FlightFuture = EngineBoxFuture<'static, Result<Media, Error>>;
48+type Flight = Shared<FlightFuture>;
49+
50+/// owns everything needed to turn a media id or an mxc uri into bytes, so the
51+/// handle can answer media queries without a trip through the engine
52+pub(crate) struct MediaService {
53+ client: Client,
54+ registry: Mutex<MediaRegistry>,
55+ flights: Mutex<HashMap<String, WeakShared<FlightFuture>>>,
56+ max_bytes: usize,
57+}
58+
59+impl MediaService {
60+ pub(crate) fn new(client: Client, max_bytes: usize, max_sources: usize) -> Self {
61+ Self {
62+ client,
63+ registry: Mutex::new(MediaRegistry::new(max_sources)),
64+ flights: Mutex::new(HashMap::new()),
65+ max_bytes,
66+ }
67+ }
68+
69+ pub(crate) async fn register(&self, media: Vec<MediaRegistration>) {
70+ if !media.is_empty() {
71+ self.registry.lock().await.insert_all(media);
72+ }
73+ }
74+
75+ pub(crate) async fn release(&self, scope: &MediaScope) {
76+ self.registry.lock().await.release(scope);
77+ }
78+
79+ pub(crate) async fn clear(&self) {
80+ self.registry.lock().await.clear();
81+ }
82+
83+ pub(crate) async fn timeline_media(
84+ &self,
85+ media_id: &MediaId,
86+ size: MediaSize,
87+ ) -> Result<Media, Error> {
88+ let chosen = choose_source(self.registered(media_id).await?, size)?;
89+ if chosen
90+ .declared_size
91+ .is_some_and(|size| size > self.max_bytes as u64)
92+ {
93+ return Err(too_large("media exceeds the configured byte limit"));
94+ }
95+ self.fetch_or_file(chosen.request, chosen.mimetype).await
96+ }
97+
98+ pub(crate) async fn media_location(
99+ &self,
100+ media_id: &MediaId,
101+ size: MediaSize,
102+ ) -> Result<MediaLocation, Error> {
103+ let media = self.registered(media_id).await?;
104+ let chosen = choose_source(media.clone(), size)?;
105+ if is_local(&chosen.request.source) {
106+ return Ok(MediaLocation::Local);
107+ }
108+ if !matches!(chosen.request.format, MediaFormat::Thumbnail(_)) {
109+ return Ok(MediaLocation::File(
110+ media_file(
111+ &self.client,
112+ chosen.request,
113+ chosen.mimetype,
114+ chosen.declared_size,
115+ )
116+ .await?,
117+ ));
118+ }
119+ let url = media_http_request(&self.client, &chosen.request)
120+ .await?
121+ .uri()
122+ .to_string();
123+ let fallback = MediaRequestParameters {
124+ source: media.full.source,
125+ format: MediaFormat::File,
126+ };
127+ Ok(MediaLocation::ServerThumbnail {
128+ url,
129+ fallback: media_file(
130+ &self.client,
131+ fallback,
132+ media.full.mimetype,
133+ media.full.declared_size,
134+ )
135+ .await?,
136+ })
137+ }
138+
139+ async fn registered(&self, media_id: &MediaId) -> Result<MediaRegistration, Error> {
140+ self.registry
141+ .lock()
142+ .await
143+ .get(media_id)
144+ .cloned()
145+ .ok_or_else(|| {
146+ Error::new(
147+ ErrorKind::MediaUnknown,
148+ "media is not registered in a retained timeline",
149+ )
150+ })
151+ }
152+
153+ pub(crate) async fn mxc_media(&self, uri: &MxcUri, size: MediaSize) -> Result<Media, Error> {
154+ if !uri.is_valid() {
155+ return Err(Error::new(ErrorKind::Invalid, "mxc uri is malformed"));
156+ }
157+ let format = match size {
158+ MediaSize::Full => MediaFormat::File,
159+ MediaSize::Thumbnail { width, height } => thumbnail_format(width, height),
160+ };
161+ self.fetch_or_file(
162+ MediaRequestParameters {
163+ source: MediaSource::Plain(uri.to_owned()),
164+ format,
165+ },
166+ None,
167+ )
168+ .await
169+ }
170+
171+ /// plenty of servers won't thumbnail everything, and the file itself is the next best
172+ /// thing. the byte cap still holds either way
173+ async fn fetch_or_file(
174+ &self,
175+ request: MediaRequestParameters,
176+ mimetype: Option<String>,
177+ ) -> Result<Media, Error> {
178+ if !matches!(request.format, MediaFormat::Thumbnail(_)) {
179+ return self.fetch(request, mimetype).await;
180+ }
181+ match self.fetch(request.clone(), mimetype.clone()).await {
182+ Err(error) if error.kind == ErrorKind::MediaUnavailable => {
183+ let file = MediaRequestParameters {
184+ source: request.source,
185+ format: MediaFormat::File,
186+ };
187+ self.fetch(file, mimetype).await
188+ }
189+ result => result,
190+ }
191+ }
192+
193+ async fn fetch(
194+ &self,
195+ request: MediaRequestParameters,
196+ mimetype: Option<String>,
197+ ) -> Result<Media, Error> {
198+ let key = flight_key(&request);
199+ let flight = {
200+ let mut flights = self.flights.lock().await;
201+ flights.retain(|_, flight| flight.upgrade().is_some());
202+ match flights.get(&key).and_then(WeakShared::upgrade) {
203+ Some(flight) => flight,
204+ None => {
205+ let prepared = AssertUnwindSafe(fetch(
206+ self.client.clone(),
207+ request,
208+ mimetype,
209+ self.max_bytes,
210+ ))
211+ .catch_unwind()
212+ .map(|result| {
213+ result.unwrap_or_else(|_| {
214+ Err(Error::new(ErrorKind::Other, "media fetch panicked"))
215+ })
216+ });
217+ let boxed: FlightFuture = Box::pin(prepared);
218+ let flight: Flight = boxed.shared();
219+ if let Some(weak) = flight.downgrade() {
220+ flights.insert(key, weak);
221+ }
222+ flight
223+ }
224+ }
225+ };
226+ flight.await
227+ }
228+}
229+
230+struct ChosenSource {
231+ request: MediaRequestParameters,
232+ mimetype: Option<String>,
233+ declared_size: Option<u64>,
234+}
235+
236+fn choose_source(media: MediaRegistration, size: MediaSize) -> Result<ChosenSource, Error> {
237+ let (source, format, mimetype, declared_size) = match size {
238+ MediaSize::Full => (
239+ media.full.source,
240+ MediaFormat::File,
241+ media.full.mimetype,
242+ media.full.declared_size,
243+ ),
244+ MediaSize::Thumbnail { width, height } => match media.thumbnail {
245+ Some(thumbnail) => (
246+ thumbnail.source,
247+ MediaFormat::File,
248+ thumbnail.mimetype,
249+ thumbnail.declared_size,
250+ ),
251+ // the server can only resize what it can read
252+ None if matches!(media.kind, MediaKind::Image | MediaKind::Video)
253+ && matches!(media.full.source, MediaSource::Plain(_)) =>
254+ {
255+ // the server picks the thumbnail's type, so the original's would be a lie
256+ let format = if is_local(&media.full.source) {
257+ MediaFormat::File
258+ } else {
259+ thumbnail_format(width, height)
260+ };
261+ (media.full.source, format, None, None)
262+ }
263+ None => {
264+ return Err(Error::new(
265+ ErrorKind::MediaUnavailable,
266+ "the requested media size is unavailable",
267+ ));
268+ }
269+ },
270+ };
271+ Ok(ChosenSource {
272+ request: MediaRequestParameters { source, format },
273+ mimetype,
274+ declared_size,
275+ })
276+}
277+
278+async fn media_file(
279+ client: &Client,
280+ request: MediaRequestParameters,
281+ mimetype: Option<String>,
282+ declared_size: Option<u64>,
283+) -> Result<MediaFile, Error> {
284+ let url = media_http_request(client, &request)
285+ .await?
286+ .uri()
287+ .to_string();
288+ let encryption = match &request.source {
289+ MediaSource::Plain(_) => None,
290+ MediaSource::Encrypted(file) => {
291+ let (EncryptedFileInfo::V2(info), Some(EncryptedFileHash::Sha256(sha256))) = (
292+ &file.info,
293+ file.hashes.get(&EncryptedFileHashAlgorithm::Sha256),
294+ ) else {
295+ return Err(Error::new(
296+ ErrorKind::MediaUnavailable,
297+ "encrypted media is not v2 with a sha256 hash",
298+ ));
299+ };
300+ Some(AttachmentEncryption {
301+ key: info.k.encode(),
302+ iv: info.iv.encode(),
303+ sha256: sha256.encode(),
304+ })
305+ }
306+ };
307+ Ok(MediaFile {
308+ url,
309+ mimetype,
310+ declared_size,
311+ encryption,
312+ })
313+}
314+
315+fn thumbnail_format(width: u32, height: u32) -> MediaFormat {
316+ MediaFormat::Thumbnail(MediaThumbnailSettings::new(width.into(), height.into()))
317+}
318+
319+fn is_local(source: &MediaSource) -> bool {
320+ let uri = match source {
321+ MediaSource::Plain(uri) => uri,
322+ MediaSource::Encrypted(file) => &file.url,
323+ };
324+ uri.server_name()
325+ .is_ok_and(|server| server.as_str() == "send-queue.localhost")
326+}
327+
328+/// plain and encrypted sources share a cache key in the sdk, but must never share a flight
329+fn flight_key(request: &MediaRequestParameters) -> String {
330+ let uri = match &request.source {
331+ MediaSource::Plain(uri) => format!("plain|{uri}"),
332+ MediaSource::Encrypted(file) => format!("encrypted|{}", file.url),
333+ };
334+ match &request.format {
335+ MediaFormat::File => uri.to_owned(),
336+ MediaFormat::Thumbnail(settings) => format!(
337+ "{uri}|{}|{}x{}|{}",
338+ settings.method, settings.width, settings.height, settings.animated
339+ ),
340+ }
341+}
342+
343+fn too_large(message: &'static str) -> Error {
344+ Error::new(ErrorKind::MediaTooLarge, message)
345+}
346+
347+async fn fetch(
348+ client: Client,
349+ request: MediaRequestParameters,
350+ mimetype: Option<String>,
351+ max_bytes: usize,
352+) -> Result<Media, Error> {
353+ if let Some(cached) = cached_media(&client, &request).await? {
354+ if cached.len() > max_bytes {
355+ if let Ok(store) = client.media_store().lock().await {
356+ let _ = store.remove_media_content(&request).await;
357+ }
358+ return Err(too_large("cached media exceeds the configured byte limit"));
359+ }
360+ // plain and encrypted share a cache key, so a plain fetch of an encrypted file's uri
361+ // leaves ciphertext here. its hash gives it away
362+ if is_ciphertext(&cached, &request.source) {
363+ let bytes = maybe_decrypt(cached, &request, max_bytes)?;
364+ cache(&client, &request, &bytes).await;
365+ return Ok(media(bytes, mimetype));
366+ }
367+ return Ok(media(cached, mimetype));
368+ }
369+ let (bytes, served_mimetype) = download_bounded(&client, &request, max_bytes).await?;
370+ // the sdk caches plaintext under the encrypted key, so we do too or nobody can read anything
371+ let bytes = maybe_decrypt(bytes, &request, max_bytes)?;
372+ cache(&client, &request, &bytes).await;
373+ Ok(media(bytes, mimetype.or(served_mimetype)))
374+}
375+
376+async fn cache(client: &Client, request: &MediaRequestParameters, bytes: &[u8]) {
377+ if let Ok(store) = client.media_store().lock().await {
378+ let _ = store
379+ .add_media_content(
380+ request,
381+ bytes.to_vec(),
382+ matrix_sdk::media::store::IgnoreMediaRetentionPolicy::No,
383+ )
384+ .await;
385+ }
386+}
387+
388+fn is_ciphertext(bytes: &[u8], source: &MediaSource) -> bool {
389+ use sha2::{Digest, Sha256};
390+ let MediaSource::Encrypted(file) = source else {
391+ return false;
392+ };
393+ file.hashes
394+ .get(&ruma::events::room::EncryptedFileHashAlgorithm::Sha256)
395+ .is_some_and(|hash| hash.as_bytes() == Sha256::digest(bytes).as_slice())
396+}
397+
398+fn media(bytes: Vec<u8>, mimetype: Option<String>) -> Media {
399+ let mimetype = mimetype
400+ .or_else(|| sniff_image(&bytes).map(str::to_owned))
401+ .unwrap_or_else(|| "application/octet-stream".to_owned());
402+ Media {
403+ bytes: Arc::from(bytes),
404+ mimetype,
405+ }
406+}
407+
408+/// the cache forgets content types, and avatars never declare one. images are the common case
409+fn sniff_image(bytes: &[u8]) -> Option<&'static str> {
410+ match bytes {
411+ [0x89, b'P', b'N', b'G', ..] => Some("image/png"),
412+ [0xff, 0xd8, 0xff, ..] => Some("image/jpeg"),
413+ [b'G', b'I', b'F', b'8', ..] => Some("image/gif"),
414+ [
415+ b'R',
416+ b'I',
417+ b'F',
418+ b'F',
419+ _,
420+ _,
421+ _,
422+ _,
423+ b'W',
424+ b'E',
425+ b'B',
426+ b'P',
427+ ..,
428+ ] => Some("image/webp"),
429+ _ => None,
430+ }
431+}
432+
433+async fn cached_media(
434+ client: &Client,
435+ request: &MediaRequestParameters,
436+) -> Result<Option<Vec<u8>>, Error> {
437+ fn cache_failed(
438+ source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
439+ ) -> Error {
440+ Error::with_source(ErrorKind::Store, "reading media cache failed", source)
441+ }
442+ client
443+ .media_store()
444+ .lock()
445+ .await
446+ .map_err(cache_failed)?
447+ .get_media_content(request)
448+ .await
449+ .map_err(cache_failed)
450+}
451+
452+fn maybe_decrypt(
453+ bytes: Vec<u8>,
454+ request: &MediaRequestParameters,
455+ max_bytes: usize,
456+) -> Result<Vec<u8>, Error> {
457+ let MediaSource::Encrypted(file) = &request.source else {
458+ return Ok(bytes);
459+ };
460+ fn decrypt_failed(
461+ source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
462+ ) -> Error {
463+ Error::with_source(
464+ ErrorKind::MediaUnavailable,
465+ "media decryption failed",
466+ source,
467+ )
468+ }
469+ let info: MediaEncryptionInfo = (**file).clone().into();
470+ let mut cursor = std::io::Cursor::new(bytes);
471+ let mut decryptor = AttachmentDecryptor::new(&mut cursor, info).map_err(decrypt_failed)?;
472+ let mut decrypted = Vec::new();
473+ let mut chunk = [0_u8; 8192];
474+ loop {
475+ let read = decryptor.read(&mut chunk).map_err(decrypt_failed)?;
476+ if read == 0 {
477+ return Ok(decrypted);
478+ }
479+ if read > max_bytes.saturating_sub(decrypted.len()) {
480+ return Err(too_large(
481+ "decrypted media exceeds the configured byte limit",
482+ ));
483+ }
484+ decrypted.extend_from_slice(&chunk[..read]);
485+ }
486+}
487+
488+/// the sdk's own download has no size cap, so we do it by hand
489+async fn download_bounded(
490+ client: &Client,
491+ request: &MediaRequestParameters,
492+ max_bytes: usize,
493+) -> Result<(Vec<u8>, Option<String>), Error> {
494+ if is_local(&request.source) {
495+ return Err(Error::new(
496+ ErrorKind::MediaUnavailable,
497+ "local media is not in the media cache",
498+ ));
499+ }
500+ let network_failed =
501+ |source| Error::with_source(ErrorKind::Network, "media download failed", source);
502+ let mut refreshed = false;
503+ let response = loop {
504+ let http_request = media_http_request(client, request).await?;
505+ let (parts, _) = http_request.into_parts();
506+ let mut builder = client
507+ .http_client()
508+ .get(parts.uri.to_string())
509+ .timeout(DOWNLOAD_TOTAL);
510+ for (name, value) in &parts.headers {
511+ builder = builder.header(name.as_str(), value.as_bytes());
512+ }
513+ let response = builder.send().await.map_err(network_failed)?;
514+ if response.status() != matrix_sdk::reqwest::StatusCode::UNAUTHORIZED {
515+ break response;
516+ }
517+ if refreshed
518+ || client
519+ .session_tokens()
520+ .is_none_or(|tokens| tokens.refresh_token.is_none())
521+ {
522+ return Err(Error::new(
523+ ErrorKind::Unauthenticated,
524+ "media download was not authorized",
525+ ));
526+ }
527+ client.refresh_access_token().await.map_err(|source| {
528+ Error::from_refresh_token("refreshing media access token failed", source)
529+ })?;
530+ refreshed = true;
531+ };
532+ let status = response.status();
533+ if !status.is_success() {
534+ return Err(Error::new(
535+ ErrorKind::MediaUnavailable,
536+ format!("media download failed with status {status}"),
537+ ));
538+ }
539+ if response
540+ .content_length()
541+ .is_some_and(|length| length > max_bytes as u64)
542+ {
543+ return Err(too_large("media exceeds the configured byte limit"));
544+ }
545+ let mimetype = response
546+ .headers()
547+ .get(http::header::CONTENT_TYPE)
548+ .and_then(|value| value.to_str().ok())
549+ .map(str::to_owned);
550+ let mut stream = response.bytes_stream();
551+ let mut bytes = Vec::new();
552+ loop {
553+ let chunk = matrix_sdk::timeout::timeout(stream.next(), DOWNLOAD_IDLE)
554+ .await
555+ .map_err(|_| Error::new(ErrorKind::Network, "media download stalled"))?;
556+ let Some(chunk) = chunk else { break };
557+ let chunk = chunk.map_err(network_failed)?;
558+ if chunk.len() > max_bytes.saturating_sub(bytes.len()) {
559+ return Err(too_large(
560+ "downloaded media exceeds the configured byte limit",
561+ ));
562+ }
563+ bytes.extend_from_slice(&chunk);
564+ }
565+ Ok((bytes, mimetype))
566+}
567+
568+async fn media_http_request(
569+ client: &Client,
570+ request: &MediaRequestParameters,
571+) -> Result<http::Request<Vec<u8>>, Error> {
572+ let versions = client.supported_versions().await.map_err(|source| {
573+ Error::with_source(
574+ ErrorKind::Network,
575+ "reading supported server versions failed",
576+ source,
577+ )
578+ })?;
579+ let authenticated =
580+ authenticated_media::get_content::v1::Request::PATH_BUILDER.is_supported(&versions);
581+ let base = client.homeserver().to_string();
582+ let token = client.access_token();
583+ let invalid_uri =
584+ |source| Error::with_source(ErrorKind::Invalid, "media URI is invalid", source);
585+ let (uri, thumbnail) = match (&request.source, &request.format) {
586+ (MediaSource::Encrypted(file), _) => (&file.url, None),
587+ (MediaSource::Plain(uri), MediaFormat::File) => (uri, None),
588+ (MediaSource::Plain(uri), MediaFormat::Thumbnail(settings)) => (uri, Some(settings)),
589+ };
590+ // each endpoint has its own auth input type, so no generic helper can take them all
591+ macro_rules! into_http {
592+ ($endpoint:expr, $token:expr) => {
593+ $endpoint
594+ .try_into_http_request::<Vec<u8>>(&base, $token, Cow::Borrowed(&versions))
595+ .map_err(|source| {
596+ Error::with_source(ErrorKind::Other, "media request is invalid", source)
597+ })?
598+ };
599+ }
600+ let authed = SendAccessToken::IfRequired(token.as_deref().unwrap_or(""));
601+ #[allow(deprecated)]
602+ let mut http_request = match (thumbnail, authenticated) {
603+ (None, true) => into_http!(
604+ authenticated_media::get_content::v1::Request::from_uri(uri).map_err(invalid_uri)?,
605+ authed
606+ ),
607+ (None, false) => into_http!(
608+ media::get_content::v3::Request::from_url(uri).map_err(invalid_uri)?,
609+ SendAccessToken::None
610+ ),
611+ (Some(settings), true) => {
612+ let mut endpoint = authenticated_media::get_content_thumbnail::v1::Request::from_uri(
613+ uri,
614+ settings.width,
615+ settings.height,
616+ )
617+ .map_err(invalid_uri)?;
618+ endpoint.method = Some(settings.method.clone());
619+ endpoint.animated = Some(settings.animated);
620+ into_http!(endpoint, authed)
621+ }
622+ (Some(settings), false) => {
623+ let mut endpoint = media::get_content_thumbnail::v3::Request::from_url(
624+ uri,
625+ settings.width,
626+ settings.height,
627+ )
628+ .map_err(invalid_uri)?;
629+ endpoint.method = Some(settings.method.clone());
630+ endpoint.animated = Some(settings.animated);
631+ into_http!(endpoint, SendAccessToken::None)
632+ }
633+ };
634+ // legacy endpoints don't ask for a token but plenty of servers want one anyway
635+ if let Some(token) = token.as_deref() {
636+ let value = format!("Bearer {token}").parse().map_err(|_| {
637+ Error::new(ErrorKind::Unauthenticated, "stored access token is invalid")
638+ })?;
639+ http_request
640+ .headers_mut()
641+ .insert(http::header::AUTHORIZATION, value);
642+ }
643+ Ok(http_request)
644+}
645+
646+pub(crate) async fn queue_upload(
647+ room: Room,
648+ upload: AttachmentUpload,
649+ max_bytes: usize,
650+) -> Result<(), Error> {
651+ let (upload, mime, mut config) = prepare_upload(upload, max_bytes)?;
652+ if let TimelineId::Thread { root_event_id, .. } = &upload.timeline_id {
653+ config.reply = Some(SdkReply {
654+ event_id: root_event_id.clone(),
655+ enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),
656+ add_mentions: AddMentions::No,
657+ });
658+ }
659+ room.send_queue()
660+ .send_attachment(upload.filename, mime, upload.bytes, config)
661+ .await
662+ .map(drop)
663+ .map_err(|source| {
664+ use matrix_sdk::send_queue::RoomSendQueueError;
665+ let kind = match &source {
666+ RoomSendQueueError::RoomNotJoined | RoomSendQueueError::RoomDisappeared => {
667+ ErrorKind::RoomUnknown
668+ }
669+ RoomSendQueueError::StorageError(_) => ErrorKind::Store,
670+ _ => ErrorKind::MediaUnavailable,
671+ };
672+ Error::with_source(kind, "media upload could not be queued", source)
673+ })
674+}
675+
676+/// least recently seen goes first. stale order slots are skipped by stamp rather than hunted down
677+struct MediaRegistry {
678+ entries: HashMap<MediaId, (MediaRegistration, u64)>,
679+ order: VecDeque<(MediaId, u64)>,
680+ next_stamp: u64,
681+ limit: usize,
682+}
683+
684+impl MediaRegistry {
685+ fn new(limit: usize) -> Self {
686+ Self {
687+ entries: HashMap::new(),
688+ order: VecDeque::new(),
689+ next_stamp: 0,
690+ limit,
691+ }
692+ }
693+
694+ fn get(&self, id: &MediaId) -> Option<&MediaRegistration> {
695+ self.entries.get(id).map(|(media, _)| media)
696+ }
697+
698+ fn insert_all(&mut self, media: Vec<MediaRegistration>) {
699+ for media in media {
700+ let stamp = self.next_stamp;
701+ self.next_stamp += 1;
702+ self.order.push_back((media.id.clone(), stamp));
703+ self.entries.insert(media.id.clone(), (media, stamp));
704+ }
705+ while self.entries.len() > self.limit {
706+ let Some((id, stamp)) = self.order.pop_front() else {
707+ break;
708+ };
709+ if self.is_current(&id, stamp) {
710+ self.entries.remove(&id);
711+ }
712+ }
713+ if self.order.len() > self.limit.saturating_mul(4) {
714+ let entries = &self.entries;
715+ self.order
716+ .retain(|(id, stamp)| entries.get(id).is_some_and(|(_, current)| current == stamp));
717+ }
718+ }
719+
720+ fn is_current(&self, id: &MediaId, stamp: u64) -> bool {
721+ self.entries
722+ .get(id)
723+ .is_some_and(|(_, current)| *current == stamp)
724+ }
725+
726+ fn release(&mut self, scope: &MediaScope) {
727+ self.entries.retain(|id, _| !scope.owns(id));
728+ let entries = &self.entries;
729+ self.order.retain(|(id, _)| entries.contains_key(id));
730+ }
731+
732+ fn clear(&mut self) {
733+ self.entries.clear();
734+ self.order.clear();
735+ }
736+}
Added file: vendor/macltok/src/engine/mod.rs+1001 additions
@@ -0,0 +1,1001 @@
1+mod commands;
2+pub(crate) mod executor;
3+mod media;
4+mod mutations;
5+mod prepare;
6+mod room;
7+mod session;
8+mod timeline;
9+mod watchers;
10+
11+use std::{
12+ collections::{HashMap, VecDeque},
13+ future::Future,
14+ panic::AssertUnwindSafe,
15+ sync::Arc,
16+};
17+
18+use futures_util::FutureExt;
19+use matrix_sdk::{Client, SendOutsideWasm, SessionChange};
20+use matrix_sdk_ui::sync_service::SyncService;
21+use ruma::{OwnedDeviceId, OwnedRoomId};
22+use thiserror::Error as ThisError;
23+use tokio::sync::{broadcast, mpsc, watch};
24+
25+pub(crate) use media::MediaService;
26+pub(crate) use session::Persistence;
27+
28+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
29+pub(crate) use crate::store_lock::StoreLock;
30+#[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]
31+pub(crate) enum StoreLock {}
32+
33+use self::{
34+ commands::Job,
35+ executor::{EngineBoxFuture, TaskSet},
36+ mutations::{Mutation, MutationJob},
37+ room::{ActorNote, ActorRequest, RoomActor},
38+ timeline::{SessionContext, Supervisor},
39+};
40+use crate::{
41+ AccountCommand, ClientHandle, Command, ConnectionState, EncryptionCommand, EngineErrorKind,
42+ EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand, MediaCommand,
43+ NotificationCommand, RoomCommand, Termination, TimelineCommand,
44+ transport::{DriverPort, Request, Responder, transport_pair},
45+};
46+
47+#[derive(Clone, Debug, Eq, PartialEq)]
48+#[non_exhaustive]
49+pub struct EngineConfig {
50+ pub command_capacity: usize,
51+ pub event_capacity: usize,
52+ pub initial_history_limit: u16,
53+ pub max_media_bytes: usize,
54+ pub max_media_sources: usize,
55+ /// warm a room up for sending when it's opened or someone starts typing in it
56+ pub prepare_to_send: bool,
57+}
58+
59+impl EngineConfig {
60+ #[must_use]
61+ pub const fn with_command_capacity(mut self, capacity: usize) -> Self {
62+ self.command_capacity = capacity;
63+ self
64+ }
65+
66+ #[must_use]
67+ pub const fn with_event_capacity(mut self, capacity: usize) -> Self {
68+ self.event_capacity = capacity;
69+ self
70+ }
71+
72+ #[must_use]
73+ pub const fn with_initial_history_limit(mut self, limit: u16) -> Self {
74+ self.initial_history_limit = limit;
75+ self
76+ }
77+
78+ #[must_use]
79+ pub const fn with_max_media_bytes(mut self, bytes: usize) -> Self {
80+ self.max_media_bytes = bytes;
81+ self
82+ }
83+
84+ #[must_use]
85+ pub const fn with_max_media_sources(mut self, sources: usize) -> Self {
86+ self.max_media_sources = sources;
87+ self
88+ }
89+
90+ #[must_use]
91+ pub const fn with_prepare_to_send(mut self, prepare: bool) -> Self {
92+ self.prepare_to_send = prepare;
93+ self
94+ }
95+}
96+
97+impl Default for EngineConfig {
98+ fn default() -> Self {
99+ Self {
100+ command_capacity: 64,
101+ event_capacity: 128,
102+ initial_history_limit: 30,
103+ max_media_bytes: 20 * 1024 * 1024,
104+ max_media_sources: 2_048,
105+ prepare_to_send: true,
106+ }
107+ }
108+}
109+
110+#[derive(Debug, ThisError)]
111+pub enum EngineError {
112+ #[error("the Matrix client is not authenticated")]
113+ Unauthenticated,
114+ #[error("command, event, and media source capacities must be nonzero")]
115+ ZeroCapacity,
116+ #[error("could not build the sync service")]
117+ SyncService(#[source] matrix_sdk_ui::sync_service::Error),
118+ #[error("the event receiver has closed")]
119+ EventReceiverClosed,
120+ #[error("engine task {task} panicked")]
121+ TaskPanicked { task: String },
122+}
123+
124+impl EngineError {
125+ fn failure(&self) -> EngineFailure {
126+ EngineFailure {
127+ kind: match self {
128+ Self::EventReceiverClosed => EngineErrorKind::EventReceiverClosed,
129+ Self::TaskPanicked { .. } => EngineErrorKind::Watcher,
130+ Self::Unauthenticated | Self::ZeroCapacity | Self::SyncService(_) => {
131+ EngineErrorKind::Other
132+ }
133+ },
134+ message: self.to_string(),
135+ }
136+ }
137+}
138+
139+#[derive(Clone)]
140+enum TaskContext {
141+ /// a panic here costs the caller an `Interrupted` answer and nothing else
142+ Command(&'static str),
143+ /// a panic here takes the engine with it
144+ Watcher(&'static str),
145+ RoomActor {
146+ room_id: OwnedRoomId,
147+ generation: u64,
148+ },
149+}
150+
151+impl TaskContext {
152+ fn label(&self) -> String {
153+ match self {
154+ Self::Command(name) | Self::Watcher(name) => (*name).to_owned(),
155+ Self::RoomActor {
156+ room_id,
157+ generation,
158+ } => format!("room actor {room_id} generation {generation}"),
159+ }
160+ }
161+}
162+
163+/// tasks catch their own panics so each comes back with its context, since joining
164+/// through the sdk's executor gives no task id to look one up by. wasm has nothing to catch
165+fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
166+ payload
167+ .downcast_ref::<&str>()
168+ .map(ToString::to_string)
169+ .or_else(|| payload.downcast_ref::<String>().cloned())
170+ .unwrap_or_else(|| "task panicked with a non-string payload".to_owned())
171+}
172+
173+struct TaskRegistry {
174+ tasks: TaskSet<(TaskContext, Option<String>)>,
175+}
176+
177+impl TaskRegistry {
178+ fn spawn(
179+ &mut self,
180+ context: TaskContext,
181+ task: impl Future<Output = ()> + SendOutsideWasm + 'static,
182+ ) {
183+ self.tasks.spawn(async move {
184+ let panic = AssertUnwindSafe(task)
185+ .catch_unwind()
186+ .await
187+ .err()
188+ .map(panic_message);
189+ (context, panic)
190+ });
191+ }
192+
193+ fn is_empty(&self) -> bool {
194+ self.tasks.is_empty()
195+ }
196+
197+ async fn join_next(&mut self) -> Option<(TaskContext, Option<String>)> {
198+ loop {
199+ if let Ok(done) = self.tasks.join_next().await? {
200+ return Some(done);
201+ }
202+ }
203+ }
204+
205+ async fn shutdown(&mut self) {
206+ self.tasks.shutdown().await;
207+ }
208+}
209+
210+enum RoomSlot {
211+ Active {
212+ generation: u64,
213+ sender: mpsc::Sender<ActorRequest>,
214+ },
215+ /// opens and closes that arrive while the actor winds down, replayed in order once it's gone
216+ Closing {
217+ generation: u64,
218+ pending: VecDeque<Deferred>,
219+ },
220+}
221+
222+impl RoomSlot {
223+ const fn generation(&self) -> u64 {
224+ match self {
225+ Self::Active { generation, .. } | Self::Closing { generation, .. } => *generation,
226+ }
227+ }
228+}
229+
230+enum Deferred {
231+ Open(Responder),
232+ Close(Responder),
233+}
234+
235+/// logouts in flight, and a token rejection held back because one of them caused it
236+#[derive(Default)]
237+pub(super) struct Logouts {
238+ pub(super) in_flight: usize,
239+ pub(super) rejected: Option<Option<OwnedDeviceId>>,
240+}
241+
242+struct Queues {
243+ mutations: mpsc::Receiver<MutationJob>,
244+ notifications: mpsc::Receiver<Job<NotificationCommand>>,
245+ encryption: mpsc::Receiver<Job<EncryptionCommand>>,
246+ session_changes: broadcast::Receiver<SessionChange>,
247+ persistence: Option<Persistence>,
248+ upload_reports: mpsc::Receiver<watchers::RoomUpload>,
249+}
250+
251+/// requests other devices send us, handed from the sdk's event handler to the encryption worker
252+type IncomingVerifications =
253+ mpsc::UnboundedSender<matrix_sdk::encryption::verification::VerificationRequest>;
254+
255+pub struct Engine {
256+ client: Client,
257+ sync: Arc<SyncService>,
258+ port: DriverPort,
259+ config: EngineConfig,
260+ media: Arc<MediaService>,
261+ tasks: TaskRegistry,
262+ rooms: HashMap<OwnedRoomId, RoomSlot>,
263+ next_generation: u64,
264+ notes: mpsc::UnboundedReceiver<ActorNote>,
265+ note_sender: mpsc::UnboundedSender<ActorNote>,
266+ /// the rooms sliding sync should follow, applied off the run loop since it touches the store
267+ subscriptions: watch::Sender<Vec<OwnedRoomId>>,
268+ mutations: mpsc::Sender<MutationJob>,
269+ notifications: mpsc::Sender<Job<NotificationCommand>>,
270+ encryption: mpsc::Sender<Job<EncryptionCommand>>,
271+ queues: Option<Queues>,
272+ /// one progress forwarder per room that has uploaded, and whether it's subscribed yet
273+ uploads: HashMap<OwnedRoomId, watch::Receiver<bool>>,
274+ upload_reports: mpsc::Sender<watchers::RoomUpload>,
275+ /// the client can outlive us, so the handler has to come off it when we stop
276+ verification_handler: Option<matrix_sdk::event_handler::EventHandlerHandle>,
277+ stop: mpsc::UnboundedSender<Termination>,
278+ stops: mpsc::UnboundedReceiver<Termination>,
279+ logouts: Arc<watch::Sender<Logouts>>,
280+ preparations: prepare::Preparations,
281+ /// shared with every `ClientHandle`, so the store stays locked while the client is reachable
282+ _store_lock: Option<Arc<StoreLock>>,
283+}
284+
285+impl Engine {
286+ /// the escape hatch for a client you built yourself. `Session` is the usual way in
287+ pub async fn from_client(
288+ client: Client,
289+ config: EngineConfig,
290+ ) -> Result<(Self, ClientHandle, EventStream), EngineError> {
291+ let session_changes = client.subscribe_to_session_changes();
292+ Self::with_persistence(client, config, session_changes, None, None).await
293+ }
294+
295+ /// `session_changes` is taken when the client is built, since the sdk can refresh
296+ /// before an engine exists
297+ pub(crate) async fn with_persistence(
298+ client: Client,
299+ config: EngineConfig,
300+ session_changes: broadcast::Receiver<SessionChange>,
301+ persistence: Option<Persistence>,
302+ store_lock: Option<StoreLock>,
303+ ) -> Result<(Self, ClientHandle, EventStream), EngineError> {
304+ if client.user_id().is_none() {
305+ return Err(EngineError::Unauthenticated);
306+ }
307+ if config.command_capacity == 0
308+ || config.event_capacity == 0
309+ || config.max_media_sources == 0
310+ {
311+ return Err(EngineError::ZeroCapacity);
312+ }
313+ let sync = Arc::new(
314+ SyncService::builder(client.clone())
315+ .with_offline_mode()
316+ .build()
317+ .await
318+ .map_err(EngineError::SyncService)?,
319+ );
320+ let store_lock = store_lock.map(Arc::new);
321+ let media = Arc::new(MediaService::new(
322+ client.clone(),
323+ config.max_media_bytes,
324+ config.max_media_sources,
325+ ));
326+ let (handle, port, events) = transport_pair(
327+ client.clone(),
328+ sync.clone(),
329+ media.clone(),
330+ store_lock.clone(),
331+ config.command_capacity,
332+ config.event_capacity,
333+ );
334+ client.send_queue().enable_upload_progress(true);
335+ let (note_sender, notes) = mpsc::unbounded_channel();
336+ let (stop, stops) = mpsc::unbounded_channel();
337+ let (upload_reports, upload_report_queue) = mpsc::channel(config.event_capacity);
338+ let (mutations, mutation_queue) = mpsc::channel(config.command_capacity);
339+ let (notifications, notification_queue) = mpsc::channel(config.command_capacity);
340+ let (encryption, encryption_queue) = mpsc::channel(config.command_capacity);
341+ let engine = Self {
342+ client,
343+ sync,
344+ port,
345+ config,
346+ media,
347+ tasks: TaskRegistry {
348+ tasks: TaskSet::new(),
349+ },
350+ rooms: HashMap::new(),
351+ next_generation: 0,
352+ notes,
353+ note_sender,
354+ subscriptions: watch::Sender::new(Vec::new()),
355+ mutations,
356+ notifications,
357+ encryption,
358+ queues: Some(Queues {
359+ mutations: mutation_queue,
360+ notifications: notification_queue,
361+ encryption: encryption_queue,
362+ session_changes,
363+ persistence,
364+ upload_reports: upload_report_queue,
365+ }),
366+ upload_reports,
367+ uploads: HashMap::new(),
368+ verification_handler: None,
369+ preparations: prepare::Preparations::default(),
370+ stop,
371+ stops,
372+ logouts: Arc::new(watch::Sender::new(Logouts::default())),
373+ _store_lock: store_lock,
374+ };
375+ Ok((engine, handle, events))
376+ }
377+
378+ /// boxed because the engine's future is deep enough to blow the compiler's query depth
379+ /// in every crate that spawns it, which isn't something a client should have to fix
380+ pub fn run(self) -> EngineBoxFuture<'static, Result<Termination, EngineError>> {
381+ Box::pin(self.run_inner())
382+ }
383+
384+ async fn run_inner(mut self) -> Result<Termination, EngineError> {
385+ let events = self.port.event_sender();
386+ let user_id = self
387+ .client
388+ .user_id()
389+ .expect("authentication was checked during construction")
390+ .to_owned();
391+ self.port
392+ .connection_sender()
393+ .send_replace(ConnectionState::Connecting);
394+ for event in [
395+ Event::Authenticated { user_id },
396+ Event::ConnectionChanged(ConnectionState::Connecting),
397+ ] {
398+ if events.send(event).await.is_err() {
399+ self.shutdown().await;
400+ return Err(EngineError::EventReceiverClosed);
401+ }
402+ }
403+ self.start_tasks();
404+ self.resume_upload_forwarders().await;
405+ self.sync.start().await;
406+
407+ let mut shutdown_reply = None;
408+ let mut handles_dropped = false;
409+ let mut logouts = self.logouts.subscribe();
410+ let ending = loop {
411+ tokio::select! {
412+ biased;
413+ Some(termination) = self.stops.recv() => break Ok(termination),
414+ () = events.closed() => break Err(EngineError::EventReceiverClosed),
415+ () = settled(&mut logouts), if handles_dropped => {
416+ break Ok(Termination::HandlesDropped);
417+ }
418+ Some((context, panic)) = self.tasks.join_next(), if !self.tasks.is_empty() => {
419+ let Some(message) = panic else { continue };
420+ if let TaskContext::Command(label) = context {
421+ tracing::error!(label, panic = %message, "command task panicked");
422+ continue;
423+ }
424+ break Err(EngineError::TaskPanicked { task: context.label() });
425+ }
426+ Some(note) = self.notes.recv() => self.actor_note(note).await,
427+ request = self.port.recv(), if !handles_dropped => {
428+ // nobody's left to ask, but a logout they asked for still deserves to land
429+ let Some(request) = request else {
430+ handles_dropped = true;
431+ continue;
432+ };
433+ if let Some(reply) = self.dispatch(request).await {
434+ shutdown_reply = Some(reply);
435+ break Ok(Termination::ShutDown);
436+ }
437+ }
438+ }
439+ };
440+
441+ if let Err(error) = &ending {
442+ let _ = events.send(Event::Failed(error.failure())).await;
443+ }
444+ self.shutdown().await;
445+ let _ = events.send(Event::Stopped).await;
446+ if let Some(reply) = shutdown_reply {
447+ reply.done(Ok(()));
448+ }
449+ // everything else still queued answers `Interrupted` as it drops, but a shutdown did happen
450+ while let Some(Request { command, reply }) = self.port.try_recv() {
451+ if matches!(command, Command::Lifecycle(LifecycleCommand::Shutdown)) {
452+ reply.done(Ok(()));
453+ }
454+ }
455+ ending
456+ }
457+
458+ fn start_tasks(&mut self) {
459+ let queues = self.queues.take().expect("engine tasks start once");
460+ let (incoming, incoming_verifications) = mpsc::unbounded_channel();
461+ self.verification_handler = Some(self.verification_handler(incoming));
462+ let client = &self.client;
463+ let events = || self.port.event_sender();
464+ let (notices, notice_queue) = mpsc::unbounded_channel();
465+ let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 12] = [
466+ (
467+ "upload tracker",
468+ Box::pin(watchers::uploads(
469+ client.clone(),
470+ client.send_queue().subscribe(),
471+ queues.upload_reports,
472+ events(),
473+ )),
474+ ),
475+ (
476+ "room subscription watcher",
477+ Box::pin(watchers::room_subscriptions(
478+ self.sync.clone(),
479+ self.subscriptions.subscribe(),
480+ )),
481+ ),
482+ (
483+ "session change watcher",
484+ Box::pin(session::session(
485+ client.clone(),
486+ queues.session_changes,
487+ queues.persistence,
488+ notices,
489+ client
490+ .device_id()
491+ .expect("authentication was checked during construction")
492+ .to_owned(),
493+ self.logouts.clone(),
494+ )),
495+ ),
496+ (
497+ "session notice watcher",
498+ Box::pin(session::session_notices(
499+ notice_queue,
500+ events(),
501+ self.stop.clone(),
502+ )),
503+ ),
504+ (
505+ "connection state watcher",
506+ Box::pin(watchers::connection(
507+ self.sync.state(),
508+ self.sync.clone(),
509+ client.clone(),
510+ events(),
511+ self.port.connection_sender(),
512+ )),
513+ ),
514+ (
515+ "send queue watcher",
516+ Box::pin(watchers::send_queue_errors(
517+ client.clone(),
518+ client.send_queue().subscribe_errors(),
519+ self.port.connection_sender().subscribe(),
520+ )),
521+ ),
522+ (
523+ "encryption state watcher",
524+ Box::pin(watchers::encryption(client.clone(), events())),
525+ ),
526+ (
527+ "room list watcher",
528+ Box::pin(watchers::rooms(client.clone(), events())),
529+ ),
530+ (
531+ "notification settings watcher",
532+ Box::pin(watchers::notification_settings(client.clone(), events())),
533+ ),
534+ (
535+ "room command worker",
536+ Box::pin(mutations::run_scheduler(
537+ client.clone(),
538+ queues.mutations,
539+ self.config.command_capacity,
540+ )),
541+ ),
542+ (
543+ "notification command worker",
544+ Box::pin(commands::run_notifications(
545+ client.clone(),
546+ queues.notifications,
547+ )),
548+ ),
549+ (
550+ "encryption command worker",
551+ Box::pin(commands::run_encryption(
552+ client.clone(),
553+ events(),
554+ queues.encryption,
555+ incoming_verifications,
556+ )),
557+ ),
558+ ];
559+ for (label, task) in watchers {
560+ self.tasks.spawn(TaskContext::Watcher(label), task);
561+ }
562+ }
563+
564+ fn verification_handler(
565+ &self,
566+ incoming: IncomingVerifications,
567+ ) -> matrix_sdk::event_handler::EventHandlerHandle {
568+ use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent;
569+ self.client.add_event_handler(
570+ move |event: ToDeviceKeyVerificationRequestEvent, client: Client| {
571+ let incoming = incoming.clone();
572+ async move {
573+ let request = client
574+ .encryption()
575+ .get_verification_request(
576+ &event.sender,
577+ event.content.transaction_id.as_str(),
578+ )
579+ .await;
580+ if let Some(request) = request {
581+ let _ = incoming.send(request);
582+ }
583+ }
584+ },
585+ )
586+ }
587+
588+ /// hands back the shutdown request's responder, which is answered once we've stopped
589+ async fn dispatch(&mut self, Request { command, reply }: Request) -> Option<Responder> {
590+ let client = self.client.clone();
591+ match command {
592+ Command::Lifecycle(LifecycleCommand::Shutdown) => return Some(reply),
593+ Command::Account(AccountCommand::Logout) => {
594+ let events = self.port.event_sender();
595+ let stop = self.stop.clone();
596+ let logouts = self.logouts.clone();
597+ logouts.send_modify(|logouts| logouts.in_flight += 1);
598+ self.spawn_command("logout", reply, async move {
599+ let result = client.logout().await;
600+ if result.is_ok() {
601+ let _ = events.send(Event::LoggedOut).await;
602+ let _ = stop.send(Termination::LoggedOut);
603+ }
604+ let mut rejected = None;
605+ logouts.send_if_modified(|logouts| {
606+ if result.is_err() && logouts.in_flight == 1 {
607+ rejected = logouts.rejected.take();
608+ }
609+ false
610+ });
611+ if let Some(soft_logout) = rejected {
612+ session::reject_session(&events, &stop, soft_logout).await;
613+ }
614+ logouts.send_modify(|logouts| logouts.in_flight -= 1);
615+ result.map_err(|source| Error::from_sdk("logout failed", source))
616+ });
617+ }
618+ Command::Account(AccountCommand::SetAccountData {
619+ event_type,
620+ content,
621+ }) => enqueue(
622+ &self.mutations,
623+ Job {
624+ command: Mutation::GlobalAccountData {
625+ event_type,
626+ content,
627+ },
628+ reply,
629+ },
630+ ),
631+ Command::Account(AccountCommand::SetDisplayName(name)) => {
632+ self.spawn_command("set display name", reply, async move {
633+ client
634+ .account()
635+ .set_display_name(name.as_deref())
636+ .await
637+ .map_err(|source| Error::from_sdk("setting display name failed", source))
638+ });
639+ }
640+ Command::Account(AccountCommand::SetAvatar(avatar)) => {
641+ self.spawn_command("set avatar", reply, async move {
642+ client
643+ .account()
644+ .set_avatar_url(avatar.as_deref())
645+ .await
646+ .map_err(|source| Error::from_sdk("setting avatar failed", source))
647+ });
648+ }
649+ Command::Account(AccountCommand::UploadAvatar { mimetype, bytes }) => {
650+ let max_bytes = self.config.max_media_bytes;
651+ self.spawn_command("upload avatar", reply, async move {
652+ commands::upload_avatar(&client, &mimetype, bytes, max_bytes).await
653+ });
654+ }
655+ Command::Account(AccountCommand::RenameDevice {
656+ device_id,
657+ display_name,
658+ }) => self.spawn_command("rename device", reply, async move {
659+ commands::rename_device(&client, &device_id, &display_name).await
660+ }),
661+ Command::Account(AccountCommand::DeleteDevices { device_ids, auth }) => {
662+ self.spawn_command("delete devices", reply, async move {
663+ commands::delete_devices(&client, &device_ids, auth).await
664+ });
665+ }
666+ Command::Room(RoomCommand::Open(room_id)) => {
667+ if self.config.prepare_to_send {
668+ self.prepare_quietly(room_id.clone());
669+ }
670+ self.open_room(room_id, reply).await;
671+ }
672+ Command::Room(RoomCommand::Close(room_id)) => self.close_room(room_id, reply).await,
673+ Command::Room(RoomCommand::PrepareToSend(room_id)) => {
674+ let flight = self.preparations.join(&client, room_id);
675+ self.spawn_command("prepare to send", reply, flight);
676+ }
677+ Command::Room(RoomCommand::SetTyping { room_id, typing }) => {
678+ if typing && self.config.prepare_to_send {
679+ self.prepare_quietly(room_id.clone());
680+ }
681+ self.spawn_command("typing", reply, async move {
682+ commands::set_typing(&client, &room_id, typing).await
683+ });
684+ }
685+ Command::Room(RoomCommand::CancelSend {
686+ room_id,
687+ transaction_id,
688+ }) => self.spawn_command("cancel send", reply, async move {
689+ commands::cancel_send(&client, &room_id, &transaction_id).await
690+ }),
691+ Command::Room(RoomCommand::RetrySend {
692+ room_id,
693+ transaction_id,
694+ }) => self.spawn_command("retry send", reply, async move {
695+ commands::retry_send(&client, &room_id, &transaction_id).await
696+ }),
697+ Command::Room(command) => enqueue(
698+ &self.mutations,
699+ Job {
700+ command: Mutation::Room(command),
701+ reply,
702+ },
703+ ),
704+ Command::Timeline(command) => self.route_timeline(command, reply),
705+ Command::Media(MediaCommand::Upload(upload)) => self.upload(upload, reply),
706+ Command::Encryption(command) => enqueue(&self.encryption, Job { command, reply }),
707+ Command::Notification(command) => {
708+ enqueue(&self.notifications, Job { command, reply });
709+ }
710+ }
711+ None
712+ }
713+
714+ fn spawn_command(
715+ &mut self,
716+ label: &'static str,
717+ reply: Responder,
718+ work: impl Future<Output = Result<(), Error>> + SendOutsideWasm + 'static,
719+ ) {
720+ self.tasks.spawn(TaskContext::Command(label), async move {
721+ reply.done(work.await);
722+ });
723+ }
724+
725+ fn prepare_quietly(&mut self, room_id: OwnedRoomId) {
726+ let flight = self.preparations.join(&self.client, room_id);
727+ self.tasks
728+ .spawn(TaskContext::Command("prepare to send"), async move {
729+ let _ = flight.await;
730+ });
731+ }
732+
733+ fn upload(&mut self, upload: crate::AttachmentUpload, reply: Responder) {
734+ let Some(room) = self.client.get_room(upload.timeline_id.room_id()) else {
735+ return reply.done(Err(Error::room_unknown()));
736+ };
737+ let mut ready = self.upload_forwarder(&room);
738+ let max_bytes = self.config.max_media_bytes;
739+ // queueing before the forwarder listens would lose the first progress ticks
740+ self.spawn_command("upload", reply, async move {
741+ ready
742+ .wait_for(|subscribed| *subscribed)
743+ .await
744+ .map_err(|_| Error::new(ErrorKind::Store, "reading the media send queue failed"))?;
745+ media::queue_upload(room, upload, max_bytes).await
746+ });
747+ }
748+
749+ fn upload_forwarder(&mut self, room: &matrix_sdk::Room) -> watch::Receiver<bool> {
750+ self.uploads.retain(|_, ready| ready.has_changed().is_ok());
751+ if let Some(ready) = self.uploads.get(room.room_id()) {
752+ return ready.clone();
753+ }
754+ let (subscribed, ready) = watch::channel(false);
755+ self.tasks.spawn(
756+ TaskContext::Command("upload watcher"),
757+ watchers::room_uploads(room.clone(), self.upload_reports.clone(), subscribed),
758+ );
759+ self.uploads
760+ .insert(room.room_id().to_owned(), ready.clone());
761+ ready
762+ }
763+
764+ /// the sdk resumes uploads left over from last time, and they deserve progress too
765+ async fn resume_upload_forwarders(&mut self) {
766+ let Ok(echoes) = self.client.send_queue().local_echoes().await else {
767+ return;
768+ };
769+ for room_id in echoes.keys() {
770+ if let Some(room) = self.client.get_room(room_id) {
771+ self.upload_forwarder(&room);
772+ }
773+ }
774+ }
775+
776+ fn route_timeline(&mut self, command: TimelineCommand, reply: Responder) {
777+ let sender = match self.rooms.get(command.room_id()) {
778+ Some(RoomSlot::Active { sender, .. }) => Some(sender.clone()),
779+ _ => None,
780+ };
781+ match (sender, command) {
782+ (Some(sender), command) => send_actor(&sender, ActorRequest::Execute(command, reply)),
783+ (
784+ None,
785+ TimelineCommand::MarkRead {
786+ timeline_id,
787+ event_id,
788+ },
789+ ) => {
790+ let client = self.client.clone();
791+ self.spawn_command("mark read", reply, async move {
792+ commands::mark_read(&client, &timeline_id, event_id).await
793+ });
794+ }
795+ (None, _) => reply.done(Err(Error::new(ErrorKind::RoomNotOpen, "room is not open"))),
796+ }
797+ }
798+
799+ async fn open_room(&mut self, room_id: OwnedRoomId, reply: Responder) {
800+ match self.rooms.get_mut(&room_id) {
801+ Some(RoomSlot::Active { sender, .. }) => send_actor(sender, ActorRequest::Open(reply)),
802+ Some(RoomSlot::Closing { pending, .. }) => {
803+ defer(pending, Deferred::Open(reply), self.config.command_capacity);
804+ }
805+ None => self.start_actor(room_id, reply).await,
806+ }
807+ }
808+
809+ async fn close_room(&mut self, room_id: OwnedRoomId, reply: Responder) {
810+ let (generation, sender) = match self.rooms.get_mut(&room_id) {
811+ None => return reply.done(Ok(())),
812+ Some(RoomSlot::Closing { pending, .. }) => {
813+ return defer(
814+ pending,
815+ Deferred::Close(reply),
816+ self.config.command_capacity,
817+ );
818+ }
819+ Some(RoomSlot::Active { generation, sender }) => (*generation, sender.clone()),
820+ };
821+ match sender.try_send(ActorRequest::Close(reply)) {
822+ Ok(()) => {}
823+ // the actor already left on its own, which is closed enough
824+ Err(mpsc::error::TrySendError::Closed(request)) => request.into_reply().done(Ok(())),
825+ Err(mpsc::error::TrySendError::Full(request)) => {
826+ return request.into_reply().done(Err(Error::new(
827+ ErrorKind::RoomBusy,
828+ "room command queue is full",
829+ )));
830+ }
831+ }
832+ self.rooms.insert(
833+ room_id,
834+ RoomSlot::Closing {
835+ generation,
836+ pending: VecDeque::new(),
837+ },
838+ );
839+ self.update_room_subscriptions();
840+ }
841+
842+ async fn start_actor(&mut self, room_id: OwnedRoomId, reply: Responder) {
843+ let Some(room) = self.client.get_room(&room_id) else {
844+ return reply.done(Err(Error::room_unknown()));
845+ };
846+ self.next_generation = self.next_generation.wrapping_add(1);
847+ let generation = self.next_generation;
848+ let (sender, requests) = mpsc::channel(self.config.command_capacity);
849+ let (supervisor, panics) = Supervisor::new();
850+ let actor = RoomActor {
851+ room_id: room_id.clone(),
852+ generation,
853+ room,
854+ requests,
855+ notes: self.note_sender.clone(),
856+ context: SessionContext {
857+ events: self.port.event_sender(),
858+ media: self.media.clone(),
859+ current_user_id: self.client.user_id().map(ToOwned::to_owned),
860+ initial_history_limit: self.config.initial_history_limit,
861+ supervisor,
862+ },
863+ panics,
864+ opening: vec![reply],
865+ };
866+ self.rooms
867+ .insert(room_id.clone(), RoomSlot::Active { generation, sender });
868+ self.tasks.spawn(
869+ TaskContext::RoomActor {
870+ room_id,
871+ generation,
872+ },
873+ actor.run(),
874+ );
875+ self.update_room_subscriptions();
876+ }
877+
878+ async fn actor_note(&mut self, note: ActorNote) {
879+ let (room_id, generation) = match &note {
880+ ActorNote::Leaving {
881+ room_id,
882+ generation,
883+ }
884+ | ActorNote::Exited {
885+ room_id,
886+ generation,
887+ ..
888+ } => (room_id.clone(), *generation),
889+ };
890+ if self
891+ .rooms
892+ .get(&room_id)
893+ .is_none_or(|slot| slot.generation() != generation)
894+ {
895+ return;
896+ }
897+ let ActorNote::Exited { leftovers, .. } = note else {
898+ if let Some(slot @ RoomSlot::Active { .. }) = self.rooms.get_mut(&room_id) {
899+ *slot = RoomSlot::Closing {
900+ generation,
901+ pending: VecDeque::new(),
902+ };
903+ self.update_room_subscriptions();
904+ }
905+ return;
906+ };
907+ let pending = match self.rooms.remove(&room_id) {
908+ Some(RoomSlot::Closing { pending, .. }) => pending,
909+ _ => VecDeque::new(),
910+ };
911+ // leftovers were sent before anything deferred, so they go first
912+ for request in leftovers {
913+ match request {
914+ ActorRequest::Open(reply) => self.open_room(room_id.clone(), reply).await,
915+ ActorRequest::Close(reply) => reply.done(Ok(())),
916+ ActorRequest::Execute(command, reply) => self.route_timeline(command, reply),
917+ }
918+ }
919+ for deferred in pending {
920+ match deferred {
921+ Deferred::Open(reply) => self.open_room(room_id.clone(), reply).await,
922+ Deferred::Close(reply) => self.close_room(room_id.clone(), reply).await,
923+ }
924+ }
925+ self.update_room_subscriptions();
926+ }
927+
928+ fn update_room_subscriptions(&self) {
929+ let room_ids = self
930+ .rooms
931+ .iter()
932+ .filter(|(_, slot)| matches!(slot, RoomSlot::Active { .. }))
933+ .map(|(room_id, _)| room_id.clone())
934+ .collect();
935+ self.subscriptions.send_replace(room_ids);
936+ }
937+
938+ async fn shutdown(&mut self) {
939+ if let Some(handler) = self.verification_handler.take() {
940+ self.client.remove_event_handler(handler);
941+ }
942+ self.port.close();
943+ self.tasks.shutdown().await;
944+ self.rooms.clear();
945+ self.uploads.clear();
946+ self.media.clear().await;
947+ self.sync.stop().await;
948+ self.port
949+ .connection_sender()
950+ .send_replace(ConnectionState::Stopped);
951+ let _ = self
952+ .port
953+ .event_sender()
954+ .send(Event::ConnectionChanged(ConnectionState::Stopped))
955+ .await;
956+ }
957+}
958+
959+fn send_actor(sender: &mpsc::Sender<ActorRequest>, request: ActorRequest) {
960+ if let Err(error) = sender.try_send(request) {
961+ let (request, error) = match error {
962+ mpsc::error::TrySendError::Full(request) => (
963+ request,
964+ Error::new(ErrorKind::RoomBusy, "room command queue is full"),
965+ ),
966+ mpsc::error::TrySendError::Closed(request) => (
967+ request,
968+ Error::new(ErrorKind::RoomNotOpen, "room is not open"),
969+ ),
970+ };
971+ request.into_reply().done(Err(error));
972+ }
973+}
974+
975+fn defer(pending: &mut VecDeque<Deferred>, deferred: Deferred, capacity: usize) {
976+ if pending.len() < capacity {
977+ return pending.push_back(deferred);
978+ }
979+ let (Deferred::Open(reply) | Deferred::Close(reply)) = deferred;
980+ reply.done(Err(Error::new(
981+ ErrorKind::RoomBusy,
982+ "room lifecycle queue is full",
983+ )));
984+}
985+
986+async fn settled(logouts: &mut watch::Receiver<Logouts>) {
987+ let _ = logouts.wait_for(|logouts| logouts.in_flight == 0).await;
988+}
989+
990+fn enqueue<T>(sender: &mpsc::Sender<Job<T>>, job: Job<T>) {
991+ if let Err(error) = sender.try_send(job) {
992+ let (job, error) = match error {
993+ mpsc::error::TrySendError::Full(job) => (
994+ job,
995+ Error::new(ErrorKind::QueueFull, "command queue is full"),
996+ ),
997+ mpsc::error::TrySendError::Closed(job) => (job, Error::interrupted()),
998+ };
999+ job.reply.done(Err(error));
1000+ }
1001+}
Added file: vendor/macltok/src/engine/mutations.rs+379 additions
@@ -0,0 +1,379 @@
1+use std::{
2+ collections::{HashMap, HashSet, VecDeque},
3+ sync::Arc,
4+};
5+
6+use matrix_sdk::{
7+ Client, Room,
8+ ruma::{
9+ Int,
10+ api::client::room::{
11+ Visibility as SdkVisibility,
12+ create_room::v3::{Request as CreateRoomRequest, RoomPreset as SdkPreset},
13+ },
14+ events::{
15+ AnyGlobalAccountDataEventContent, GlobalAccountDataEventType, MessageLikeEventType,
16+ StateEventType, room::power_levels::RoomPowerLevelsEventContent,
17+ },
18+ serde::Raw,
19+ },
20+};
21+use ruma::{OwnedRoomId, OwnedUserId};
22+use tokio::sync::mpsc;
23+
24+use super::commands::{Job, guarded};
25+use super::executor::TaskSet;
26+use crate::{
27+ CommandOutput, CreateRoomOptions, Error, ErrorKind, PowerLevelChanges, RoomCommand, RoomPreset,
28+ RoomVisibility,
29+};
30+
31+/// state changes that must not race each other. same room, same alias, same dm
32+/// partner or same account data type run in order, everything else runs at once
33+pub(super) enum Mutation {
34+ Room(RoomCommand),
35+ GlobalAccountData {
36+ event_type: GlobalAccountDataEventType,
37+ content: Raw<AnyGlobalAccountDataEventContent>,
38+ },
39+}
40+
41+pub(super) type MutationJob = Job<Mutation>;
42+
43+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
44+enum Key {
45+ Room(OwnedRoomId),
46+ Alias(String),
47+ Direct(OwnedUserId),
48+ AccountData(String),
49+ Independent(u64),
50+}
51+
52+pub(super) async fn run_scheduler(
53+ client: Client,
54+ mut jobs: mpsc::Receiver<MutationJob>,
55+ queue_limit: usize,
56+) {
57+ let fresh_dms = FreshDms::default();
58+ let mut waiting = HashMap::<Key, VecDeque<MutationJob>>::new();
59+ let mut running = HashSet::<Key>::new();
60+ let mut tasks = TaskSet::<Key>::new();
61+ let mut queued = 0_usize;
62+ let mut independent = 0_u64;
63+ let mut receiving = true;
64+ while receiving || !tasks.is_empty() {
65+ tokio::select! {
66+ biased;
67+ Some(finished) = tasks.join_next(), if !tasks.is_empty() => {
68+ // a job's own panic is caught inside it, so this is only ever a key
69+ let Ok(key) = finished else { continue };
70+ let next = waiting.get_mut(&key).and_then(VecDeque::pop_front);
71+ if waiting.get(&key).is_some_and(VecDeque::is_empty) {
72+ waiting.remove(&key);
73+ }
74+ match next {
75+ Some(job) => {
76+ queued -= 1;
77+ spawn(&mut tasks, &client, &fresh_dms, key, job);
78+ }
79+ None => {
80+ running.remove(&key);
81+ }
82+ }
83+ }
84+ job = jobs.recv(), if receiving => {
85+ let Some(job) = job else {
86+ receiving = false;
87+ continue;
88+ };
89+ let key = key(&job.command, client.user_id().map(ruma::UserId::server_name)).unwrap_or_else(|| {
90+ independent += 1;
91+ Key::Independent(independent)
92+ });
93+ if running.insert(key.clone()) {
94+ spawn(&mut tasks, &client, &fresh_dms, key, job);
95+ } else if queued < queue_limit {
96+ waiting.entry(key).or_default().push_back(job);
97+ queued += 1;
98+ } else {
99+ job.reply.done(Err(Error::new(
100+ ErrorKind::QueueFull,
101+ "room command queue is full",
102+ )));
103+ }
104+ }
105+ }
106+ }
107+}
108+
109+/// `server` is ours, since a new alias lives there
110+fn key(mutation: &Mutation, server: Option<&ruma::ServerName>) -> Option<Key> {
111+ let room_id = match mutation {
112+ Mutation::GlobalAccountData { event_type, .. } => {
113+ return Some(Key::AccountData(event_type.to_string()));
114+ }
115+ Mutation::Room(RoomCommand::Join { room, .. }) => {
116+ return Some(match OwnedRoomId::try_from(room.clone()) {
117+ Ok(room_id) => Key::Room(room_id),
118+ Err(alias) => Key::Alias(alias.to_string()),
119+ });
120+ }
121+ Mutation::Room(RoomCommand::CreateDirect(user_id)) => {
122+ return Some(Key::Direct(user_id.clone()));
123+ }
124+ Mutation::Room(RoomCommand::Create(options)) => {
125+ return match (&options.alias_localpart, options.invite.as_slice()) {
126+ (Some(localpart), _) => Some(Key::Alias(format!("#{localpart}:{}", server?))),
127+ (None, [user_id]) if options.direct => Some(Key::Direct(user_id.clone())),
128+ _ => None,
129+ };
130+ }
131+ Mutation::Room(
132+ RoomCommand::Leave(room_id)
133+ | RoomCommand::Forget(room_id)
134+ | RoomCommand::AcceptInvite(room_id)
135+ | RoomCommand::RejectInvite(room_id)
136+ | RoomCommand::InviteUser { room_id, .. }
137+ | RoomCommand::KickUser { room_id, .. }
138+ | RoomCommand::BanUser { room_id, .. }
139+ | RoomCommand::UnbanUser { room_id, .. }
140+ | RoomCommand::SetPowerLevels { room_id, .. }
141+ | RoomCommand::SetUserPowerLevel { room_id, .. }
142+ | RoomCommand::SetAccountData { room_id, .. },
143+ ) => room_id,
144+ Mutation::Room(_) => return None,
145+ };
146+ Some(Key::Room(room_id.clone()))
147+}
148+
149+/// dms made here that the next sync hasn't told the sdk about yet, since `get_dm_room`
150+/// only knows `m.direct` once it comes back round
151+type FreshDms = Arc<std::sync::Mutex<HashMap<OwnedUserId, OwnedRoomId>>>;
152+
153+fn spawn(
154+ tasks: &mut TaskSet<Key>,
155+ client: &Client,
156+ fresh_dms: &FreshDms,
157+ key: Key,
158+ job: MutationJob,
159+) {
160+ let client = client.clone();
161+ let fresh_dms = fresh_dms.clone();
162+ tasks.spawn(async move {
163+ let Job { command, reply } = job;
164+ reply.send(guarded("room command", execute(&client, &fresh_dms, command)).await);
165+ key
166+ });
167+}
168+
169+async fn execute(
170+ client: &Client,
171+ fresh_dms: &FreshDms,
172+ mutation: Mutation,
173+) -> Result<CommandOutput, Error> {
174+ let command = match mutation {
175+ Mutation::GlobalAccountData {
176+ event_type,
177+ content,
178+ } => {
179+ client
180+ .account()
181+ .set_account_data_raw(event_type, content)
182+ .await
183+ .map_err(|source| Error::from_sdk("writing account data failed", source))?;
184+ return Ok(CommandOutput::Done);
185+ }
186+ Mutation::Room(command) => command,
187+ };
188+ let room = |room_id: &OwnedRoomId| client.get_room(room_id).ok_or_else(Error::room_unknown);
189+ let joined = |room: Room| CommandOutput::Room(room.room_id().to_owned());
190+ let sdk = |message: &'static str| move |source| Error::from_sdk(message, source);
191+ match command {
192+ RoomCommand::Join { room, via } => client
193+ .join_room_by_id_or_alias(&room, &via)
194+ .await
195+ .map(joined)
196+ .map_err(sdk("joining room failed")),
197+ RoomCommand::Leave(room_id) => room(&room_id)?
198+ .leave()
199+ .await
200+ .map(done)
201+ .map_err(sdk("leaving room failed")),
202+ RoomCommand::Forget(room_id) => room(&room_id)?
203+ .forget()
204+ .await
205+ .map(done)
206+ .map_err(sdk("forgetting room failed")),
207+ RoomCommand::AcceptInvite(room_id) => room(&room_id)?
208+ .join()
209+ .await
210+ .map(done)
211+ .map_err(sdk("accepting invite failed")),
212+ RoomCommand::RejectInvite(room_id) => room(&room_id)?
213+ .leave()
214+ .await
215+ .map(done)
216+ .map_err(sdk("rejecting invite failed")),
217+ RoomCommand::InviteUser { room_id, user_id } => room(&room_id)?
218+ .invite_user_by_id(&user_id)
219+ .await
220+ .map(done)
221+ .map_err(sdk("inviting user failed")),
222+ RoomCommand::KickUser {
223+ room_id,
224+ user_id,
225+ reason,
226+ } => room(&room_id)?
227+ .kick_user(&user_id, reason.as_deref())
228+ .await
229+ .map(done)
230+ .map_err(sdk("kicking user failed")),
231+ RoomCommand::BanUser {
232+ room_id,
233+ user_id,
234+ reason,
235+ } => room(&room_id)?
236+ .ban_user(&user_id, reason.as_deref())
237+ .await
238+ .map(done)
239+ .map_err(sdk("banning user failed")),
240+ RoomCommand::UnbanUser {
241+ room_id,
242+ user_id,
243+ reason,
244+ } => room(&room_id)?
245+ .unban_user(&user_id, reason.as_deref())
246+ .await
247+ .map(done)
248+ .map_err(sdk("unbanning user failed")),
249+ RoomCommand::SetPowerLevels { room_id, changes } => {
250+ apply_power_level_changes(&room(&room_id)?, changes)
251+ .await
252+ .map(done)
253+ }
254+ RoomCommand::SetUserPowerLevel {
255+ room_id,
256+ user_id,
257+ level,
258+ } => room(&room_id)?
259+ .update_power_levels(vec![(&user_id, power_level(level)?)])
260+ .await
261+ .map(done)
262+ .map_err(sdk("updating user power level failed")),
263+ RoomCommand::SetAccountData {
264+ room_id,
265+ event_type,
266+ content,
267+ } => room(&room_id)?
268+ .set_account_data_raw(event_type, content)
269+ .await
270+ .map(done)
271+ .map_err(sdk("writing room account data failed")),
272+ RoomCommand::Create(options) => client
273+ .create_room(create_room_request(options))
274+ .await
275+ .map(joined)
276+ .map_err(sdk("creating room failed")),
277+ RoomCommand::CreateDirect(user_id) => {
278+ if let Some(existing) = client.get_dm_room(&user_id) {
279+ return Ok(joined(existing));
280+ }
281+ let fresh = fresh_dms
282+ .lock()
283+ .expect("nothing panics holding this")
284+ .get(&user_id)
285+ .cloned();
286+ if let Some(room_id) = fresh.filter(|room_id| client.get_room(room_id).is_some()) {
287+ return Ok(CommandOutput::Room(room_id));
288+ }
289+ let room = client
290+ .create_dm(&user_id)
291+ .await
292+ .map_err(sdk("creating direct room failed"))?;
293+ fresh_dms
294+ .lock()
295+ .expect("nothing panics holding this")
296+ .insert(user_id, room.room_id().to_owned());
297+ Ok(joined(room))
298+ }
299+ _ => Err(Error::new(ErrorKind::Other, "room command was misrouted")),
300+ }
301+}
302+
303+/// one state event carrying every requested change, since the sdk's own patcher is private
304+async fn apply_power_level_changes(room: &Room, changes: PowerLevelChanges) -> Result<(), Error> {
305+ let mut levels = room
306+ .power_levels()
307+ .await
308+ .map_err(|source| Error::from_sdk("reading power levels failed", source.into()))?;
309+ for (target, value) in [
310+ (&mut levels.ban, changes.ban),
311+ (&mut levels.invite, changes.invite),
312+ (&mut levels.kick, changes.kick),
313+ (&mut levels.redact, changes.redact),
314+ (&mut levels.events_default, changes.events_default),
315+ (&mut levels.state_default, changes.state_default),
316+ (&mut levels.users_default, changes.users_default),
317+ (&mut levels.notifications.room, changes.room_notification),
318+ ] {
319+ if let Some(value) = value {
320+ *target = power_level(value)?;
321+ }
322+ }
323+ for (event_type, value) in [
324+ (StateEventType::RoomName.into(), changes.room_name),
325+ (StateEventType::RoomAvatar.into(), changes.room_avatar),
326+ (StateEventType::RoomTopic.into(), changes.room_topic),
327+ (StateEventType::SpaceChild.into(), changes.space_child),
328+ (MessageLikeEventType::Beacon.into(), changes.beacon),
329+ (StateEventType::BeaconInfo.into(), changes.beacon_info),
330+ ] {
331+ if let Some(value) = value {
332+ levels.events.insert(event_type, power_level(value)?);
333+ }
334+ }
335+ let content = RoomPowerLevelsEventContent::try_from(levels).map_err(|source| {
336+ Error::with_source(
337+ ErrorKind::Invalid,
338+ "normalising power levels failed",
339+ source,
340+ )
341+ })?;
342+ room.send_state_event(content)
343+ .await
344+ .map(drop)
345+ .map_err(|source| Error::from_sdk("updating power levels failed", source))
346+}
347+
348+fn done<T>(_: T) -> CommandOutput {
349+ CommandOutput::Done
350+}
351+
352+fn power_level(value: i64) -> Result<Int, Error> {
353+ value.try_into().map_err(|source| {
354+ Error::with_source(
355+ ErrorKind::Invalid,
356+ "power level is outside the Matrix integer range",
357+ source,
358+ )
359+ })
360+}
361+
362+fn create_room_request(options: CreateRoomOptions) -> CreateRoomRequest {
363+ let mut request = CreateRoomRequest::new();
364+ request.name = options.name;
365+ request.topic = options.topic;
366+ request.room_alias_name = options.alias_localpart;
367+ request.invite = options.invite;
368+ request.is_direct = options.direct;
369+ request.visibility = match options.visibility {
370+ RoomVisibility::Private => SdkVisibility::Private,
371+ RoomVisibility::Public => SdkVisibility::Public,
372+ };
373+ request.preset = options.preset.map(|preset| match preset {
374+ RoomPreset::PrivateChat => SdkPreset::PrivateChat,
375+ RoomPreset::TrustedPrivateChat => SdkPreset::TrustedPrivateChat,
376+ RoomPreset::PublicChat => SdkPreset::PublicChat,
377+ });
378+ request
379+}
Added file: vendor/macltok/src/engine/prepare.rs+47 additions
@@ -0,0 +1,47 @@
1+use std::collections::HashMap;
2+
3+use futures_util::{
4+ FutureExt,
5+ future::{Shared, WeakShared},
6+};
7+use matrix_sdk::Client;
8+use ruma::OwnedRoomId;
9+
10+use super::executor::EngineBoxFuture;
11+use crate::Error;
12+
13+type Flight = Shared<EngineBoxFuture<'static, Result<(), Error>>>;
14+
15+/// one warm-up per room at a time, later asks join the one in flight
16+#[derive(Default)]
17+pub(super) struct Preparations(
18+ HashMap<OwnedRoomId, WeakShared<EngineBoxFuture<'static, Result<(), Error>>>>,
19+);
20+
21+impl Preparations {
22+ pub(super) fn join(&mut self, client: &Client, room_id: OwnedRoomId) -> Flight {
23+ self.0.retain(|_, flight| flight.upgrade().is_some());
24+ if let Some(flight) = self.0.get(&room_id).and_then(WeakShared::upgrade) {
25+ return flight;
26+ }
27+ let work: EngineBoxFuture<'static, _> = Box::pin(prepare(client.clone(), room_id.clone()));
28+ let flight = work.shared();
29+ if let Some(weak) = flight.downgrade() {
30+ self.0.insert(room_id, weak);
31+ }
32+ flight
33+ }
34+}
35+
36+/// the part of the sdk's pre-send encryption work it lets us reach. syncing the members
37+/// has the sdk track them all, and its own background `/keys/query` then catches up
38+/// their devices. the one time key claim and the room key share stay on the send path
39+async fn prepare(client: Client, room_id: OwnedRoomId) -> Result<(), Error> {
40+ let room = client.get_room(&room_id).ok_or_else(Error::room_unknown)?;
41+ if room.encryption_state().is_encrypted() && !room.are_members_synced() {
42+ room.sync_members()
43+ .await
44+ .map_err(|source| Error::from_sdk("syncing room members failed", source))?;
45+ }
46+ Ok(())
47+}
Added file: vendor/macltok/src/engine/room.rs+459 additions
@@ -0,0 +1,459 @@
1+use std::collections::{HashMap, VecDeque};
2+
3+use matrix_sdk::{Room, RoomState};
4+use matrix_sdk_ui::timeline::{RoomExt, Timeline, TimelineFocus, TimelineReadReceiptTracking};
5+use ruma::{OwnedEventId, OwnedRoomId};
6+use tokio::sync::mpsc;
7+
8+use super::executor::{AbortHandle, JoinError, TaskSet, is_panic, resume_unwind};
9+use super::timeline::{SessionContext, TaskGuard, TimelineSession};
10+use crate::{
11+ Error, ErrorKind, Event, TimelineCommand, TimelineId, TypingState, transport::Responder,
12+};
13+
14+pub(super) enum ActorRequest {
15+ /// opening an open room answers with a fresh reset
16+ Open(Responder),
17+ Execute(TimelineCommand, Responder),
18+ Close(Responder),
19+}
20+
21+impl ActorRequest {
22+ pub(super) fn into_reply(self) -> Responder {
23+ match self {
24+ Self::Open(reply) | Self::Execute(_, reply) | Self::Close(reply) => reply,
25+ }
26+ }
27+}
28+
29+pub(super) enum ActorNote {
30+ /// stopping on its own, after a leave or a kick. anything sent its way now comes back in `Exited`
31+ Leaving {
32+ room_id: OwnedRoomId,
33+ generation: u64,
34+ },
35+ Exited {
36+ room_id: OwnedRoomId,
37+ generation: u64,
38+ /// requests that arrived too late to be served, for the engine to answer or replay
39+ leftovers: Vec<ActorRequest>,
40+ },
41+}
42+
43+pub(super) struct RoomActor {
44+ pub(super) room_id: OwnedRoomId,
45+ pub(super) generation: u64,
46+ pub(super) room: Room,
47+ pub(super) requests: mpsc::Receiver<ActorRequest>,
48+ pub(super) notes: mpsc::UnboundedSender<ActorNote>,
49+ pub(super) context: SessionContext,
50+ pub(super) panics: mpsc::UnboundedReceiver<&'static str>,
51+ pub(super) opening: Vec<Responder>,
52+}
53+
54+impl RoomActor {
55+ pub(super) async fn run(mut self) {
56+ self.serve().await;
57+ self.requests.close();
58+ let mut leftovers = Vec::new();
59+ while let Ok(request) = self.requests.try_recv() {
60+ leftovers.push(request);
61+ }
62+ let _ = self.notes.send(ActorNote::Exited {
63+ room_id: self.room_id.clone(),
64+ generation: self.generation,
65+ leftovers,
66+ });
67+ }
68+
69+ async fn serve(&mut self) {
70+ let timeline = match build_timeline(
71+ &self.room,
72+ TimelineFocus::Live {
73+ hide_threaded_events: true,
74+ },
75+ )
76+ .await
77+ {
78+ Ok(timeline) => timeline,
79+ Err(error) => return self.refuse_everything(error).await,
80+ };
81+ let Ok(room_timeline) = TimelineSession::start(
82+ TimelineId::Room(self.room_id.clone()),
83+ timeline,
84+ self.context.clone(),
85+ )
86+ .await
87+ else {
88+ return;
89+ };
90+ for reply in self.opening.drain(..) {
91+ reply.done(Ok(()));
92+ }
93+ let (typing_guard, typing) = self.room.subscribe_to_typing_notifications();
94+ let mut typing_watcher = TaskGuard(self.context.supervisor.spawn(
95+ "typing watcher",
96+ watch_typing(self.room_id.clone(), typing, self.context.events.clone()),
97+ ));
98+ let mut timelines = Timelines {
99+ room: room_timeline,
100+ threads: Threads::new(self.requests.max_capacity()),
101+ work: TaskSet::new(),
102+ };
103+ let close = self.serve_open(&mut timelines).await;
104+ if close.is_none() {
105+ self.requests.close();
106+ let _ = self.notes.send(ActorNote::Leaving {
107+ room_id: self.room_id.clone(),
108+ generation: self.generation,
109+ });
110+ }
111+
112+ // let redactions and receipts that already left finish rather than lie about them
113+ while timelines.work.join_next().await.is_some() {}
114+ drop(typing_guard);
115+ typing_watcher.0.abort();
116+ let _ = (&mut typing_watcher.0).await;
117+ let _ = self
118+ .context
119+ .events
120+ .send(Event::TypingChanged(TypingState {
121+ room_id: self.room_id.clone(),
122+ users: Vec::new(),
123+ }))
124+ .await;
125+ timelines.threads.close().await;
126+ timelines.room.close().await;
127+ if let Some(reply) = close {
128+ reply.done(Ok(()));
129+ }
130+ }
131+
132+ /// returns the close request's responder, if a close is why we stopped
133+ async fn serve_open(&mut self, timelines: &mut Timelines) -> Option<Responder> {
134+ let mut room_info = self.room.subscribe_info();
135+ // a leave that landed while the timeline was building won't come round again
136+ if matches!(self.room.state(), RoomState::Left | RoomState::Banned) {
137+ return None;
138+ }
139+ loop {
140+ tokio::select! {
141+ Some(info) = room_info.next() => {
142+ if matches!(info.state(), RoomState::Left | RoomState::Banned) {
143+ tracing::debug!(room_id = %self.room_id, "room actor stopped because we left the room");
144+ return None;
145+ }
146+ }
147+ request = self.requests.recv() => match request {
148+ None => return None,
149+ Some(ActorRequest::Close(reply)) => return Some(reply),
150+ Some(ActorRequest::Open(reply)) => reply.done(timelines.room.resync().await),
151+ Some(ActorRequest::Execute(command, reply)) => {
152+ timelines.execute(command, reply, &self.room).await;
153+ }
154+ },
155+ Some(built) = timelines.threads.builds.join_next(), if !timelines.threads.builds.is_empty() => {
156+ timelines.thread_built(built, &self.room_id, &self.context).await;
157+ }
158+ Some(label) = self.panics.recv() => panic!("{label} panicked in room {}", self.room_id),
159+ Some(done) = timelines.work.join_next(), if !timelines.work.is_empty() => {
160+ if let Err(error) = done && is_panic(&error) {
161+ tracing::warn!(room_id = %self.room_id, %error, "timeline operation panicked");
162+ }
163+ }
164+ }
165+ }
166+ }
167+
168+ async fn refuse_everything(&mut self, error: matrix_sdk_ui::timeline::Error) {
169+ tracing::warn!(%error, room_id = %self.room_id, "room actor could not build its timeline");
170+ let error = Error::with_source(ErrorKind::Timeline, "room timeline build failed", error);
171+ for reply in self.opening.drain(..) {
172+ reply.done(Err(error.clone()));
173+ }
174+ self.requests.close();
175+ while let Some(request) = self.requests.recv().await {
176+ request.into_reply().done(Err(error.clone()));
177+ }
178+ }
179+}
180+
181+async fn build_timeline(
182+ room: &Room,
183+ focus: TimelineFocus,
184+) -> Result<Timeline, matrix_sdk_ui::timeline::Error> {
185+ room.timeline_builder()
186+ .with_focus(focus)
187+ .track_read_marker_and_receipts(TimelineReadReceiptTracking::AllEvents)
188+ .build()
189+ .await
190+}
191+
192+async fn watch_typing(
193+ room_id: OwnedRoomId,
194+ mut typing: tokio::sync::broadcast::Receiver<Vec<ruma::OwnedUserId>>,
195+ events: crate::transport::EventSender,
196+) {
197+ loop {
198+ match typing.recv().await {
199+ Ok(users) => {
200+ let state = TypingState {
201+ room_id: room_id.clone(),
202+ users,
203+ };
204+ if events.send(Event::TypingChanged(state)).await.is_err() {
205+ return;
206+ }
207+ }
208+ Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
209+ tracing::warn!(%room_id, skipped, "typing watcher lagged");
210+ }
211+ Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
212+ }
213+ }
214+}
215+
216+struct Timelines {
217+ room: TimelineSession,
218+ threads: Threads,
219+ work: TaskSet<()>,
220+}
221+
222+impl Timelines {
223+ async fn execute(&mut self, command: TimelineCommand, reply: Responder, room: &Room) {
224+ match command {
225+ TimelineCommand::OpenThread { root_event_id, .. } => {
226+ self.threads.open(root_event_id, reply, room);
227+ }
228+ TimelineCommand::CloseThread { root_event_id, .. } => {
229+ reply.done(self.threads.close_one(&root_event_id).await);
230+ }
231+ TimelineCommand::Resync {
232+ timeline_id: TimelineId::Room(_),
233+ } => reply.done(self.room.resync().await),
234+ command => {
235+ let root = command
236+ .timeline_id()
237+ .and_then(TimelineId::thread_root)
238+ .cloned();
239+ let Some(root) = root else {
240+ return self.room.execute(command, reply, &mut self.work).await;
241+ };
242+ if let TimelineCommand::MarkRead {
243+ timeline_id,
244+ event_id,
245+ } = &command
246+ && !self.threads.open.contains_key(&root)
247+ {
248+ let client = room.client();
249+ let (timeline_id, event_id) = (timeline_id.clone(), event_id.clone());
250+ self.work.spawn(async move {
251+ reply.done(
252+ super::commands::mark_read(&client, &timeline_id, event_id).await,
253+ );
254+ });
255+ return;
256+ }
257+ match self.threads.route(root, command, reply) {
258+ Routed::Open(session, TimelineCommand::Resync { .. }, reply) => {
259+ reply.done(session.resync().await);
260+ }
261+ Routed::Open(session, command, reply) => {
262+ session.execute(command, reply, &mut self.work).await;
263+ }
264+ Routed::Queued => {}
265+ }
266+ }
267+ }
268+ }
269+
270+ async fn thread_built(
271+ &mut self,
272+ built: Result<Built, JoinError>,
273+ room_id: &OwnedRoomId,
274+ context: &SessionContext,
275+ ) {
276+ let Some((root, queued, timeline)) = self.threads.built(built) else {
277+ return;
278+ };
279+ let timeline = match timeline {
280+ Ok(timeline) => timeline,
281+ Err(error) => {
282+ tracing::warn!(%error, %room_id, %root, "thread timeline build failed");
283+ let error =
284+ Error::with_source(ErrorKind::Timeline, "thread timeline build failed", error);
285+ for (_, reply) in queued {
286+ reply.done(Err(error.clone()));
287+ }
288+ return;
289+ }
290+ };
291+ let id = TimelineId::thread(room_id.clone(), root.clone());
292+ let Ok(session) = TimelineSession::start(id, timeline, context.clone()).await else {
293+ return;
294+ };
295+ let session = self
296+ .threads
297+ .open
298+ .entry(root)
299+ .insert_entry(session)
300+ .into_mut();
301+ for (command, reply) in queued {
302+ match command {
303+ None => reply.done(Ok(())),
304+ Some(TimelineCommand::Resync { .. }) => reply.done(session.resync().await),
305+ Some(command) => session.execute(command, reply, &mut self.work).await,
306+ }
307+ }
308+ }
309+}
310+
311+type Built = (
312+ OwnedEventId,
313+ u64,
314+ Result<Timeline, matrix_sdk_ui::timeline::Error>,
315+);
316+
317+/// `None` is the open itself, everything else waits behind it
318+type Queued = VecDeque<(Option<TimelineCommand>, Responder)>;
319+
320+enum Routed<'a> {
321+ Open(&'a mut TimelineSession, TimelineCommand, Responder),
322+ Queued,
323+}
324+
325+struct OpeningThread {
326+ generation: u64,
327+ build: AbortHandle,
328+ queued: Queued,
329+}
330+
331+struct Threads {
332+ open: HashMap<OwnedEventId, TimelineSession>,
333+ opening: HashMap<OwnedEventId, OpeningThread>,
334+ builds: TaskSet<Built>,
335+ next_generation: u64,
336+ queued: usize,
337+ capacity: usize,
338+}
339+
340+impl Threads {
341+ fn new(capacity: usize) -> Self {
342+ Self {
343+ open: HashMap::new(),
344+ opening: HashMap::new(),
345+ builds: TaskSet::new(),
346+ next_generation: 0,
347+ queued: 0,
348+ capacity,
349+ }
350+ }
351+
352+ fn enqueue(&mut self, root: &OwnedEventId, command: Option<TimelineCommand>, reply: Responder) {
353+ if self.queued >= self.capacity {
354+ return reply.done(Err(Error::new(
355+ ErrorKind::QueueFull,
356+ "thread command queue is full",
357+ )));
358+ }
359+ let Some(opening) = self.opening.get_mut(root) else {
360+ return reply.done(Err(Error::new(
361+ ErrorKind::TimelineNotOpen,
362+ "thread timeline is not open",
363+ )));
364+ };
365+ opening.queued.push_back((command, reply));
366+ self.queued += 1;
367+ }
368+
369+ fn open(&mut self, root: OwnedEventId, reply: Responder, room: &Room) {
370+ if self.open.contains_key(&root) {
371+ return reply.done(Ok(()));
372+ }
373+ if !self.opening.contains_key(&root) {
374+ let generation = self.next_generation;
375+ self.next_generation = self.next_generation.wrapping_add(1);
376+ let room = room.clone();
377+ let build_root = root.clone();
378+ let build = self.builds.spawn(async move {
379+ let focus = TimelineFocus::Thread {
380+ root_event_id: build_root.clone(),
381+ };
382+ (build_root, generation, build_timeline(&room, focus).await)
383+ });
384+ self.opening.insert(
385+ root.clone(),
386+ OpeningThread {
387+ generation,
388+ build,
389+ queued: VecDeque::new(),
390+ },
391+ );
392+ }
393+ self.enqueue(&root, None, reply);
394+ }
395+
396+ fn route(
397+ &mut self,
398+ root: OwnedEventId,
399+ command: TimelineCommand,
400+ reply: Responder,
401+ ) -> Routed<'_> {
402+ if !self.open.contains_key(&root) {
403+ self.enqueue(&root, Some(command), reply);
404+ return Routed::Queued;
405+ }
406+ let session = self.open.get_mut(&root).expect("thread was just checked");
407+ Routed::Open(session, command, reply)
408+ }
409+
410+ /// `None` when the build belongs to a thread that was closed while it ran
411+ fn built(
412+ &mut self,
413+ built: Result<Built, JoinError>,
414+ ) -> Option<(
415+ OwnedEventId,
416+ Queued,
417+ Result<Timeline, matrix_sdk_ui::timeline::Error>,
418+ )> {
419+ let (root, generation, timeline) = match built {
420+ Ok(built) => built,
421+ Err(error) if error.is_cancelled() => return None,
422+ Err(error) => resume_unwind(error),
423+ };
424+ if self
425+ .opening
426+ .get(&root)
427+ .is_none_or(|opening| opening.generation != generation)
428+ {
429+ return None;
430+ }
431+ let opening = self.opening.remove(&root)?;
432+ self.queued -= opening.queued.len();
433+ Some((root, opening.queued, timeline))
434+ }
435+
436+ async fn close_one(&mut self, root: &OwnedEventId) -> Result<(), Error> {
437+ if let Some(session) = self.open.remove(root) {
438+ session.close().await;
439+ return Ok(());
440+ }
441+ let Some(opening) = self.opening.remove(root) else {
442+ return Err(Error::new(
443+ ErrorKind::TimelineNotOpen,
444+ "thread timeline is not open",
445+ ));
446+ };
447+ opening.build.abort();
448+ self.queued -= opening.queued.len();
449+ Ok(())
450+ }
451+
452+ async fn close(&mut self) {
453+ self.builds.shutdown().await;
454+ self.opening.clear();
455+ for (_, session) in self.open.drain() {
456+ session.close().await;
457+ }
458+ }
459+}
Added file: vendor/macltok/src/engine/session.rs+132 additions
@@ -0,0 +1,132 @@
1+use std::sync::Arc;
2+
3+use matrix_sdk::{BoxFuture, Client, SessionChange};
4+use ruma::OwnedDeviceId;
5+use tokio::sync::{
6+ broadcast::{self, error::RecvError},
7+ mpsc, watch,
8+};
9+
10+use super::Logouts;
11+use crate::{Error, Event, Termination, transport::EventSender};
12+
13+#[cfg(not(target_family = "wasm"))]
14+pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>> + Send + Sync;
15+#[cfg(target_family = "wasm")]
16+pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>>;
17+
18+/// how the engine keeps the saved record current, handed over by `Session::build`
19+pub(crate) struct Persistence {
20+ pub(crate) save: Box<SaveRecord>,
21+ /// what the sdk's own save callback made of each refresh, when the store could write
22+ /// there and then
23+ pub(crate) inline: mpsc::UnboundedReceiver<Result<(), Error>>,
24+}
25+
26+impl Persistence {
27+ async fn persist(&mut self, client: &Client) -> Result<(), Error> {
28+ let mut inline = None;
29+ while let Ok(result) = self.inline.try_recv() {
30+ inline = Some(result);
31+ }
32+ if let Some(Ok(())) = inline {
33+ return Ok(());
34+ }
35+ // an inline save for a newer refresh can land while this one writes, so go again
36+ // until what's on disk is what the client holds
37+ loop {
38+ let tokens = client.session_tokens();
39+ (self.save)(client.clone()).await?;
40+ if client.session_tokens() == tokens {
41+ return Ok(());
42+ }
43+ }
44+ }
45+}
46+
47+pub(super) enum Notice {
48+ Event(Event),
49+ Rejected(Option<OwnedDeviceId>),
50+}
51+
52+/// saves and notices are split, so a full event stream never holds a save back
53+pub(super) async fn session(
54+ client: Client,
55+ mut changes: broadcast::Receiver<SessionChange>,
56+ mut persistence: Option<Persistence>,
57+ notices: mpsc::UnboundedSender<Notice>,
58+ device_id: OwnedDeviceId,
59+ logouts: Arc<watch::Sender<Logouts>>,
60+) {
61+ loop {
62+ let event = match changes.recv().await {
63+ // a missed refresh costs one needless save, a missed rejection comes round again
64+ Ok(SessionChange::TokensRefreshed) | Err(RecvError::Lagged(_)) => {
65+ match persistence.as_mut() {
66+ None => Event::SessionTokensChanged,
67+ Some(persistence) => match persistence.persist(&client).await {
68+ Ok(()) => Event::SessionTokensChanged,
69+ Err(error) => Event::SessionSaveFailed {
70+ message: error.message,
71+ },
72+ },
73+ }
74+ }
75+ Ok(SessionChange::UnknownToken(data)) => {
76+ let soft_logout = data.soft_logout.then_some(device_id);
77+ let mut held = false;
78+ // our own logout revokes the token, so a rejection meanwhile is its doing
79+ logouts.send_if_modified(|logouts| {
80+ held = logouts.in_flight > 0;
81+ if held {
82+ logouts.rejected = Some(soft_logout.clone());
83+ }
84+ held
85+ });
86+ if !held {
87+ let _ = notices.send(Notice::Rejected(soft_logout));
88+ }
89+ return;
90+ }
91+ Err(RecvError::Closed) => return,
92+ };
93+ if notices.send(Notice::Event(event)).is_err() {
94+ return;
95+ }
96+ }
97+}
98+
99+pub(super) async fn session_notices(
100+ mut notices: mpsc::UnboundedReceiver<Notice>,
101+ events: EventSender,
102+ stop: mpsc::UnboundedSender<Termination>,
103+) {
104+ while let Some(notice) = notices.recv().await {
105+ match notice {
106+ Notice::Event(event) => {
107+ if events.send(event).await.is_err() {
108+ return;
109+ }
110+ }
111+ Notice::Rejected(soft_logout) => {
112+ return reject_session(&events, &stop, soft_logout).await;
113+ }
114+ }
115+ }
116+}
117+
118+pub(super) async fn reject_session(
119+ events: &EventSender,
120+ stop: &mpsc::UnboundedSender<Termination>,
121+ soft_logout: Option<OwnedDeviceId>,
122+) {
123+ let _ = events
124+ .send(Event::SessionAccessRejected {
125+ soft_logout: soft_logout.clone(),
126+ })
127+ .await;
128+ let _ = stop.send(match soft_logout {
129+ Some(device_id) => Termination::SoftLoggedOut { device_id },
130+ None => Termination::AccessRevoked,
131+ });
132+}
Added file: vendor/macltok/src/engine/timeline.rs+463 additions
@@ -0,0 +1,463 @@
1+use std::{collections::HashSet, future::Future, panic::AssertUnwindSafe, sync::Arc};
2+
3+use futures_util::{FutureExt, StreamExt};
4+use matrix_sdk::SendOutsideWasm;
5+use matrix_sdk::{
6+ room::edit::EditedContent,
7+ room::reply::{EnforceThread, Reply as SdkReply},
8+ ruma::{
9+ api::client::receipt::create_receipt::v3::ReceiptType,
10+ events::{
11+ Mentions,
12+ room::message::{
13+ AddMentions, Relation, ReplyWithinThread, RoomMessageEventContentWithoutRelation,
14+ },
15+ },
16+ },
17+};
18+use matrix_sdk_ui::timeline::{Timeline, TimelineEventItemId};
19+use ruma::{OwnedEventId, OwnedUserId};
20+use tokio::sync::mpsc;
21+
22+use super::executor::{EngineBoxFuture, JoinHandle, TaskSet, spawn};
23+use crate::{
24+ Error, ErrorKind, Event, EventRef, HistoryState, MessageContent, MessageEditContent,
25+ MessageMentions, RichText, TimelineCommand, TimelineId,
26+ engine::MediaService,
27+ normalise::{MediaScope, normalise_timeline, normalise_timeline_diffs},
28+ transport::{EventSender, Responder, SendEventError},
29+};
30+
31+const PAGINATION_QUEUE: usize = 32;
32+
33+/// long-lived tasks inside a room actor report panics here so the actor can die loudly
34+#[derive(Clone)]
35+pub(super) struct Supervisor(mpsc::UnboundedSender<&'static str>);
36+
37+impl Supervisor {
38+ pub(super) fn new() -> (Self, mpsc::UnboundedReceiver<&'static str>) {
39+ let (sender, receiver) = mpsc::unbounded_channel();
40+ (Self(sender), receiver)
41+ }
42+
43+ pub(super) fn spawn(
44+ &self,
45+ label: &'static str,
46+ task: impl Future<Output = ()> + SendOutsideWasm + 'static,
47+ ) -> JoinHandle<()> {
48+ let panics = self.0.clone();
49+ spawn(async move {
50+ if AssertUnwindSafe(task).catch_unwind().await.is_err() {
51+ let _ = panics.send(label);
52+ }
53+ })
54+ }
55+}
56+
57+/// aborts its task when dropped, so an actor that dies mid-flight takes its helpers with it
58+pub(super) struct TaskGuard(pub(super) JoinHandle<()>);
59+
60+impl Drop for TaskGuard {
61+ fn drop(&mut self) {
62+ self.0.abort();
63+ }
64+}
65+
66+/// what a timeline session needs from the room that owns it
67+#[derive(Clone)]
68+pub(super) struct SessionContext {
69+ pub(super) events: EventSender,
70+ pub(super) media: Arc<MediaService>,
71+ pub(super) current_user_id: Option<OwnedUserId>,
72+ pub(super) initial_history_limit: u16,
73+ pub(super) supervisor: Supervisor,
74+}
75+
76+pub(super) struct TimelineSession {
77+ pub(super) id: TimelineId,
78+ timeline: Arc<Timeline>,
79+ context: SessionContext,
80+ scope: MediaScope,
81+ watcher: JoinHandle<()>,
82+ pagination: mpsc::Sender<PaginationRequest>,
83+ pagination_worker: JoinHandle<()>,
84+}
85+
86+struct PaginationRequest {
87+ limit: u16,
88+ reply: Option<Responder>,
89+}
90+
91+impl TimelineSession {
92+ pub(super) async fn start(
93+ id: TimelineId,
94+ timeline: Timeline,
95+ context: SessionContext,
96+ ) -> Result<Self, SendEventError> {
97+ let timeline = Arc::new(timeline);
98+ let scope = MediaScope::new();
99+ let watcher = start_watcher(&id, &timeline, &context, &scope).await?;
100+ let (pagination, requests) = mpsc::channel(PAGINATION_QUEUE);
101+ let pagination_worker = context.supervisor.spawn(
102+ "pagination worker",
103+ run_pagination(
104+ requests,
105+ timeline.clone(),
106+ context.events.clone(),
107+ id.clone(),
108+ ),
109+ );
110+ let session = Self {
111+ id,
112+ timeline,
113+ scope,
114+ watcher,
115+ pagination,
116+ pagination_worker,
117+ context,
118+ };
119+ if session.context.initial_history_limit > 0 {
120+ session.paginate(session.context.initial_history_limit, None);
121+ }
122+ Ok(session)
123+ }
124+
125+ /// swaps in a fresh watcher. the old one is dead before the reset goes out,
126+ /// so no stale diff can land after it
127+ pub(super) async fn resync(&mut self) -> Result<(), Error> {
128+ self.watcher.abort();
129+ let _ = (&mut self.watcher).await;
130+ self.watcher = start_watcher(&self.id, &self.timeline, &self.context, &self.scope)
131+ .await
132+ .map_err(|_| Error::new(ErrorKind::Timeline, "timeline reset could not be sent"))?;
133+ Ok(())
134+ }
135+
136+ pub(super) fn paginate(&self, limit: u16, reply: Option<Responder>) {
137+ if let Err(error) = self.pagination.try_send(PaginationRequest { limit, reply }) {
138+ let (request, error) = match error {
139+ mpsc::error::TrySendError::Full(request) => (
140+ request,
141+ Error::new(ErrorKind::QueueFull, "pagination queue is full"),
142+ ),
143+ mpsc::error::TrySendError::Closed(request) => (
144+ request,
145+ Error::new(ErrorKind::TimelineNotOpen, "timeline is closing"),
146+ ),
147+ };
148+ if let Some(reply) = request.reply {
149+ reply.done(Err(error));
150+ }
151+ }
152+ }
153+
154+ /// pending paginations answer `Interrupted` as their responders drop
155+ pub(super) async fn close(mut self) {
156+ self.pagination_worker.abort();
157+ let _ = (&mut self.pagination_worker).await;
158+ self.watcher.abort();
159+ let _ = (&mut self.watcher).await;
160+ // after the watcher, or a last diff could register media for a timeline that's gone
161+ self.context.media.release(&self.scope).await;
162+ let _ = self
163+ .context
164+ .events
165+ .send(Event::TimelineClosed {
166+ timeline_id: self.id.clone(),
167+ })
168+ .await;
169+ }
170+
171+ pub(super) async fn execute(
172+ &self,
173+ command: TimelineCommand,
174+ reply: Responder,
175+ work: &mut TaskSet<()>,
176+ ) {
177+ let timeline = self.timeline.clone();
178+ let thread_root = self.id.thread_root().cloned();
179+ let background: EngineBoxFuture<'static, Result<(), Error>> = match command {
180+ TimelineCommand::Paginate { limit, .. } => return self.paginate(limit, Some(reply)),
181+ // network-bound, so it runs beside the actor instead of queueing the room behind it
182+ TimelineCommand::Redact { target, reason, .. } => Box::pin(async move {
183+ timeline
184+ .redact(&item_id(target), reason.as_deref())
185+ .await
186+ .map_err(timeline_error)
187+ }),
188+ TimelineCommand::ToggleReaction { target, key, .. } => Box::pin(async move {
189+ timeline
190+ .toggle_reaction(&item_id(target), &key)
191+ .await
192+ .map(drop)
193+ .map_err(timeline_error)
194+ }),
195+ TimelineCommand::MarkRead { event_id, .. } => Box::pin(async move {
196+ let receipt_failed =
197+ |source| Error::from_sdk("sending read receipt failed", source);
198+ timeline
199+ .send_single_receipt(ReceiptType::Read, event_id.clone())
200+ .await
201+ .map_err(receipt_failed)?;
202+ if thread_root.is_none() {
203+ timeline
204+ .send_single_receipt(ReceiptType::FullyRead, event_id)
205+ .await
206+ .map_err(receipt_failed)?;
207+ }
208+ Ok(())
209+ }),
210+ command => return reply.done(self.execute_serial(command).await),
211+ };
212+ work.spawn(async move { reply.done(background.await) });
213+ }
214+
215+ /// sends stay on the actor so they hit the send queue in the order they were asked for
216+ async fn execute_serial(&self, command: TimelineCommand) -> Result<(), Error> {
217+ let timeline = &self.timeline;
218+ match command {
219+ TimelineCommand::Send { content, .. } => {
220+ let body = match &content {
221+ MessageContent::Text { content, .. }
222+ | MessageContent::Emote { content, .. }
223+ | MessageContent::Reply { content, .. } => &content.body,
224+ };
225+ if body.trim().is_empty() {
226+ return Err(Error::new(ErrorKind::EmptyBody, "message body is empty"));
227+ }
228+ let content = match content {
229+ MessageContent::Text { content, mentions } => {
230+ message_content(content, mentions, false).with_relation(None)
231+ }
232+ MessageContent::Emote { content, mentions } => {
233+ message_content(content, mentions, true).with_relation(None)
234+ }
235+ MessageContent::Reply {
236+ event_id,
237+ content,
238+ mentions,
239+ notify_replied_to,
240+ } => {
241+ self.reply_content(event_id, content, mentions, notify_replied_to)
242+ .await?
243+ }
244+ };
245+ timeline
246+ .send(content.into())
247+ .await
248+ .map(drop)
249+ .map_err(timeline_error)
250+ }
251+ TimelineCommand::Edit {
252+ target, content, ..
253+ } => {
254+ let (content, mentions, emote) = match content {
255+ MessageEditContent::Text { content, mentions } => (content, mentions, false),
256+ MessageEditContent::Emote { content, mentions } => (content, mentions, true),
257+ };
258+ if content.body.trim().is_empty() {
259+ return Err(Error::new(ErrorKind::EmptyBody, "message body is empty"));
260+ }
261+ timeline
262+ .edit(
263+ &item_id(target),
264+ EditedContent::RoomMessage(message_content(content, mentions, emote)),
265+ )
266+ .await
267+ .map_err(timeline_error)
268+ }
269+ _ => Err(Error::new(
270+ ErrorKind::Other,
271+ "timeline command was misrouted",
272+ )),
273+ }
274+ }
275+
276+ async fn reply_content(
277+ &self,
278+ event_id: OwnedEventId,
279+ content: RichText,
280+ mentions: MessageMentions,
281+ notify_replied_to: bool,
282+ ) -> Result<matrix_sdk::ruma::events::room::message::RoomMessageEventContent, Error> {
283+ let thread_root = self.id.thread_root();
284+ let content = self
285+ .timeline
286+ .room()
287+ .make_reply_event(
288+ message_content(content, mentions, false),
289+ SdkReply {
290+ event_id,
291+ enforce_thread: if thread_root.is_some() {
292+ EnforceThread::Threaded(ReplyWithinThread::Yes)
293+ } else {
294+ EnforceThread::MaybeThreaded
295+ },
296+ add_mentions: if notify_replied_to {
297+ AddMentions::Yes
298+ } else {
299+ AddMentions::No
300+ },
301+ },
302+ )
303+ .await
304+ .map_err(|source| {
305+ Error::with_source(ErrorKind::Timeline, "reply operation failed", source)
306+ })?;
307+ if let Some(thread_root) = thread_root
308+ && !matches!(
309+ content.relates_to.as_ref(),
310+ Some(Relation::Thread(thread)) if &thread.event_id == thread_root
311+ )
312+ {
313+ return Err(Error::new(
314+ ErrorKind::Invalid,
315+ "reply target does not belong to the selected thread",
316+ ));
317+ }
318+ Ok(content)
319+ }
320+}
321+
322+impl Drop for TimelineSession {
323+ fn drop(&mut self) {
324+ self.watcher.abort();
325+ self.pagination_worker.abort();
326+ }
327+}
328+
329+fn item_id(target: EventRef) -> TimelineEventItemId {
330+ match target {
331+ EventRef::Remote(event_id) => TimelineEventItemId::EventId(event_id),
332+ EventRef::Local(transaction_id) => TimelineEventItemId::TransactionId(transaction_id),
333+ }
334+}
335+
336+/// the sdk leaves a reply's target unloaded until someone asks, so the watcher asks, once per event
337+struct ReplyFetcher {
338+ timeline: Arc<Timeline>,
339+ asked: HashSet<OwnedEventId>,
340+ fetches: TaskSet<()>,
341+}
342+
343+impl ReplyFetcher {
344+ fn fetch(&mut self, missing: Vec<OwnedEventId>) {
345+ for event_id in missing {
346+ if self.asked.insert(event_id.clone()) {
347+ let timeline = self.timeline.clone();
348+ self.fetches.spawn(async move {
349+ if let Err(error) = timeline.fetch_details_for_event(&event_id).await {
350+ tracing::debug!(%event_id, %error, "fetching reply details failed");
351+ }
352+ });
353+ }
354+ }
355+ }
356+}
357+
358+async fn start_watcher(
359+ id: &TimelineId,
360+ timeline: &Arc<Timeline>,
361+ context: &SessionContext,
362+ scope: &MediaScope,
363+) -> Result<JoinHandle<()>, SendEventError> {
364+ let (items, mut stream) = timeline.subscribe().await;
365+ let current_user_id = context.current_user_id.clone();
366+ let normalised = normalise_timeline(items.iter(), current_user_id.as_deref(), scope);
367+ context.media.register(normalised.media).await;
368+ context
369+ .events
370+ .send(Event::TimelineReset {
371+ timeline_id: id.clone(),
372+ items: normalised.items,
373+ })
374+ .await?;
375+ let mut replies = ReplyFetcher {
376+ timeline: timeline.clone(),
377+ asked: HashSet::new(),
378+ fetches: TaskSet::new(),
379+ };
380+ replies.fetch(normalised.missing_replies);
381+ let id = id.clone();
382+ let events = context.events.clone();
383+ let media = context.media.clone();
384+ let scope = scope.clone();
385+ Ok(context.supervisor.spawn("timeline watcher", async move {
386+ loop {
387+ let diffs = tokio::select! {
388+ diffs = stream.next() => match diffs {
389+ Some(diffs) => diffs,
390+ None => return,
391+ },
392+ Some(_) = replies.fetches.join_next(), if !replies.fetches.is_empty() => continue,
393+ };
394+ let normalised = normalise_timeline_diffs(diffs, current_user_id.as_deref(), &scope);
395+ media.register(normalised.media).await;
396+ replies.fetch(normalised.missing_replies);
397+ let event = Event::TimelineChanged {
398+ timeline_id: id.clone(),
399+ diffs: normalised.diffs,
400+ };
401+ if events.send(event).await.is_err() {
402+ return;
403+ }
404+ }
405+ }))
406+}
407+
408+async fn run_pagination(
409+ mut requests: mpsc::Receiver<PaginationRequest>,
410+ timeline: Arc<Timeline>,
411+ events: EventSender,
412+ timeline_id: TimelineId,
413+) {
414+ while let Some(PaginationRequest { limit, reply }) = requests.recv().await {
415+ let history = |state| Event::HistoryChanged {
416+ timeline_id: timeline_id.clone(),
417+ state,
418+ };
419+ if events.send(history(HistoryState::Loading)).await.is_err() {
420+ return;
421+ }
422+ let result = timeline
423+ .paginate_backwards(limit.max(1))
424+ .await
425+ .map_err(timeline_error);
426+ let state = match &result {
427+ Ok(true) => HistoryState::StartReached,
428+ Ok(false) => HistoryState::Idle,
429+ Err(error) => HistoryState::Failed(error.to_string()),
430+ };
431+ let _ = events.send(history(state)).await;
432+ if let Some(reply) = reply {
433+ reply.done(result.map(drop));
434+ }
435+ }
436+}
437+
438+fn message_content(
439+ content: RichText,
440+ mentions: MessageMentions,
441+ emote: bool,
442+) -> RoomMessageEventContentWithoutRelation {
443+ let content = match content.formatted_body {
444+ Some(formatted_body) if emote => {
445+ RoomMessageEventContentWithoutRelation::emote_html(content.body, formatted_body)
446+ }
447+ Some(formatted_body) => {
448+ RoomMessageEventContentWithoutRelation::text_html(content.body, formatted_body)
449+ }
450+ None if emote => RoomMessageEventContentWithoutRelation::emote_plain(content.body),
451+ None => RoomMessageEventContentWithoutRelation::text_plain(content.body),
452+ };
453+ let content = content.add_mentions(Mentions::with_user_ids(mentions.user_ids));
454+ if mentions.room {
455+ content.add_mentions(Mentions::with_room_mention())
456+ } else {
457+ content
458+ }
459+}
460+
461+pub(super) fn timeline_error(source: matrix_sdk_ui::timeline::Error) -> Error {
462+ Error::with_source(ErrorKind::Timeline, "timeline operation failed", source)
463+}
Added file: vendor/macltok/src/engine/watchers.rs+531 additions
@@ -0,0 +1,531 @@
1+use std::{collections::HashSet, sync::Arc, time::Duration};
2+
3+use futures_util::StreamExt;
4+use matrix_sdk::{
5+ Client, Room,
6+ ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},
7+ send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},
8+};
9+use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};
10+use ruma::{OwnedRoomId, OwnedTransactionId, time::Instant};
11+use tokio::sync::{broadcast::error::RecvError, mpsc, watch};
12+
13+use super::executor::TaskSet;
14+use crate::{
15+ ConnectionState, Event, UploadOutcome, encryption::encryption_state, error::classify_sdk_error,
16+ transport::EventSender,
17+};
18+
19+const SYNC_RESTART_MIN: Duration = Duration::from_secs(1);
20+const SYNC_RESTART_MAX: Duration = Duration::from_secs(60);
21+const SEND_QUEUE_RETRY: Duration = Duration::from_secs(5);
22+const SYNC_STABLE: Duration = Duration::from_secs(30);
23+
24+pub(super) async fn room_subscriptions(
25+ sync: Arc<SyncService>,
26+ mut rooms: watch::Receiver<Vec<ruma::OwnedRoomId>>,
27+) {
28+ while rooms.changed().await.is_ok() {
29+ let room_ids = rooms.borrow_and_update().clone();
30+ let room_ids = room_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>();
31+ sync.room_list_service().subscribe_to_rooms(&room_ids).await;
32+ }
33+}
34+
35+/// `states` must be taken before sync starts, a late subscriber never hears the transition it missed
36+pub(super) async fn connection(
37+ mut states: impl futures_util::Stream<Item = SyncState> + Unpin,
38+ sync: Arc<SyncService>,
39+ client: Client,
40+ events: EventSender,
41+ connection: watch::Sender<ConnectionState>,
42+) {
43+ let mut backoff = SYNC_RESTART_MIN;
44+ let mut online_since: Option<Instant> = None;
45+ while let Some(state) = states.next().await {
46+ // only a run that lasted counts as recovered, or a server that answers /versions
47+ // but refuses sync would have us bounce between offline and online forever
48+ let stable = online_since
49+ .take()
50+ .map(|since| since.elapsed() >= SYNC_STABLE);
51+ if stable == Some(true) {
52+ backoff = SYNC_RESTART_MIN;
53+ }
54+ let (state, restart) = match state {
55+ // only ever us stopping it for a restart, nothing a client needs to hear
56+ SyncState::Idle => continue,
57+ SyncState::Running => {
58+ online_since = Some(Instant::now());
59+ // the sdk parks a room queue on any send error and never unparks it
60+ client.send_queue().set_enabled(true).await;
61+ (ConnectionState::Online, false)
62+ }
63+ SyncState::Offline => (ConnectionState::Offline, stable == Some(false)),
64+ SyncState::Terminated => (ConnectionState::Offline, true),
65+ SyncState::Error(error) => {
66+ tracing::warn!(%error, "sync service reported an error");
67+ (ConnectionState::Offline, true)
68+ }
69+ };
70+ if *connection.borrow() != state {
71+ connection.send_replace(state);
72+ if events.send(Event::ConnectionChanged(state)).await.is_err() {
73+ return;
74+ }
75+ }
76+ if restart {
77+ sync.stop().await;
78+ matrix_sdk::sleep::sleep(backoff).await;
79+ backoff = (backoff * 2).min(SYNC_RESTART_MAX);
80+ sync.start().await;
81+ }
82+ }
83+}
84+
85+/// a recoverable failure while we're online is a hiccup, so give the room another go
86+pub(super) async fn send_queue_errors(
87+ client: Client,
88+ mut errors: tokio::sync::broadcast::Receiver<matrix_sdk::send_queue::SendQueueRoomError>,
89+ online: watch::Receiver<ConnectionState>,
90+) {
91+ let mut retries = TaskSet::new();
92+ loop {
93+ let error = tokio::select! {
94+ error = errors.recv() => error,
95+ Some(_) = retries.join_next(), if !retries.is_empty() => continue,
96+ };
97+ match error {
98+ Ok(error) if error.is_recoverable => {
99+ let client = client.clone();
100+ let online = online.clone();
101+ retries.spawn(async move {
102+ matrix_sdk::sleep::sleep(SEND_QUEUE_RETRY).await;
103+ if *online.borrow() == ConnectionState::Online
104+ && let Some(room) = client.get_room(&error.room_id)
105+ {
106+ room.send_queue().set_enabled(true);
107+ }
108+ });
109+ }
110+ Ok(_) | Err(RecvError::Lagged(_)) => {}
111+ Err(RecvError::Closed) => return,
112+ }
113+ }
114+}
115+
116+/// upload progress only goes out on the room's own channel, never the client-wide one.
117+/// the room's sends and cancels come along behind it, so an upload's end can't
118+/// overtake its own progress
119+pub(super) async fn room_uploads(
120+ room: Room,
121+ tracker: mpsc::Sender<RoomUpload>,
122+ ready: watch::Sender<bool>,
123+) {
124+ let room_id = room.room_id().to_owned();
125+ let mut updates = match room.send_queue().subscribe().await {
126+ Ok((_, updates)) => updates,
127+ Err(error) => {
128+ tracing::warn!(%room_id, %error, "reading the media send queue failed");
129+ return;
130+ }
131+ };
132+ ready.send_replace(true);
133+ loop {
134+ let report = match updates.recv().await {
135+ Ok(RoomSendQueueUpdate::MediaUpload {
136+ related_to,
137+ file,
138+ progress,
139+ ..
140+ }) => RoomUpload::Progress {
141+ upload: (room_id.clone(), related_to),
142+ uploaded_bytes: progress.current as u64,
143+ total_bytes: progress.total as u64,
144+ complete: file.is_some() && progress.current == progress.total,
145+ },
146+ Ok(RoomSendQueueUpdate::SentEvent {
147+ transaction_id,
148+ event_id,
149+ }) => RoomUpload::Ended(
150+ (room_id.clone(), transaction_id),
151+ UploadOutcome::Sent { event_id },
152+ ),
153+ Ok(RoomSendQueueUpdate::CancelledLocalEvent { transaction_id }) => {
154+ RoomUpload::Ended((room_id.clone(), transaction_id), UploadOutcome::Cancelled)
155+ }
156+ Ok(_) => continue,
157+ Err(RecvError::Lagged(_)) => RoomUpload::Lagged,
158+ Err(RecvError::Closed) => return,
159+ };
160+ if tracker.send(report).await.is_err() {
161+ return;
162+ }
163+ }
164+}
165+
166+pub(super) enum RoomUpload {
167+ Progress {
168+ upload: Upload,
169+ uploaded_bytes: u64,
170+ total_bytes: u64,
171+ complete: bool,
172+ },
173+ Ended(Upload, UploadOutcome),
174+ /// an end may have been lost with the ticks
175+ Lagged,
176+}
177+
178+/// the one place upload events leave from, so an upload is never reported after it
179+/// ended. progress comes in from the room forwarders, the rest from the client-wide
180+/// channel, which exists before any upload does
181+pub(super) async fn uploads(
182+ client: Client,
183+ mut updates: tokio::sync::broadcast::Receiver<SendQueueUpdate>,
184+ mut rooms_reports: mpsc::Receiver<RoomUpload>,
185+ events: EventSender,
186+) {
187+ let (_, mut rooms) = client.rooms_stream();
188+ let mut tracker = UploadTracker {
189+ live: attachment_echoes(&client).await,
190+ ended: HashSet::new(),
191+ };
192+ loop {
193+ let reports = tokio::select! {
194+ update = updates.recv() => match update {
195+ Ok(update) => tracker.update(update).into_iter().collect(),
196+ Err(RecvError::Lagged(skipped)) => {
197+ tracing::warn!(skipped, "upload tracker lagged");
198+ tracker.resnapshot(attachment_echoes(&client).await)
199+ }
200+ Err(RecvError::Closed) => return,
201+ },
202+ Some(report) = rooms_reports.recv() => match report {
203+ RoomUpload::Lagged => tracker.resnapshot(attachment_echoes(&client).await),
204+ report => tracker.room(report).into_iter().collect(),
205+ },
206+ update = rooms.next() => {
207+ if update.is_none() {
208+ return;
209+ }
210+ tracker.finish_where(
211+ |(room_id, _)| client.get_room(room_id).is_none(),
212+ &UploadOutcome::RoomForgotten,
213+ )
214+ }
215+ };
216+ for event in reports {
217+ if events.send(event).await.is_err() {
218+ return;
219+ }
220+ }
221+ }
222+}
223+
224+type Upload = (OwnedRoomId, OwnedTransactionId);
225+
226+/// everything reported gets exactly one `MediaUploadFinished`. an upload ended from
227+/// anywhere but its room's forwarder goes in `ended` until the forwarder's own end for
228+/// it arrives, since until then the forwarder can still be holding progress for it
229+struct UploadTracker {
230+ live: HashSet<Upload>,
231+ ended: HashSet<Upload>,
232+}
233+
234+impl UploadTracker {
235+ fn update(&mut self, update: SendQueueUpdate) -> Option<Event> {
236+ let room_id = update.room_id;
237+ let (transaction_id, outcome) = match update.update {
238+ RoomSendQueueUpdate::NewLocalEvent(echo) => {
239+ let upload = (room_id, echo.transaction_id);
240+ if is_attachment_echo(&echo.content) && !self.ended.contains(&upload) {
241+ self.live.insert(upload);
242+ }
243+ return None;
244+ }
245+ RoomSendQueueUpdate::SendError {
246+ transaction_id,
247+ error,
248+ is_recoverable,
249+ } => {
250+ let upload = (room_id, transaction_id);
251+ if !self.live.contains(&upload) {
252+ return None;
253+ }
254+ let (room_id, transaction_id) = upload;
255+ return Some(Event::MediaUploadFailed {
256+ room_id,
257+ transaction_id,
258+ kind: classify_sdk_error(&error),
259+ will_retry: is_recoverable,
260+ });
261+ }
262+ RoomSendQueueUpdate::SentEvent {
263+ transaction_id,
264+ event_id,
265+ } => (transaction_id, UploadOutcome::Sent { event_id }),
266+ RoomSendQueueUpdate::CancelledLocalEvent { transaction_id } => {
267+ (transaction_id, UploadOutcome::Cancelled)
268+ }
269+ _ => return None,
270+ };
271+ self.finish((room_id, transaction_id), outcome, true)
272+ }
273+
274+ fn room(&mut self, report: RoomUpload) -> Option<Event> {
275+ match report {
276+ RoomUpload::Progress {
277+ upload,
278+ uploaded_bytes,
279+ total_bytes,
280+ complete,
281+ } => {
282+ if self.ended.contains(&upload) {
283+ return None;
284+ }
285+ self.live.insert(upload.clone());
286+ let (room_id, transaction_id) = upload;
287+ Some(Event::MediaUploadProgress {
288+ room_id,
289+ transaction_id,
290+ uploaded_bytes,
291+ total_bytes,
292+ complete,
293+ })
294+ }
295+ RoomUpload::Ended(upload, outcome) => {
296+ self.ended.remove(&upload);
297+ self.finish(upload, outcome, false)
298+ }
299+ RoomUpload::Lagged => None,
300+ }
301+ }
302+
303+ fn resnapshot(&mut self, current: HashSet<Upload>) -> Vec<Event> {
304+ let reports = self.finish_where(|upload| !current.contains(upload), &UploadOutcome::Missed);
305+ let ended = &self.ended;
306+ self.live
307+ .extend(current.into_iter().filter(|upload| !ended.contains(upload)));
308+ reports
309+ }
310+
311+ fn finish_where(
312+ &mut self,
313+ ends: impl Fn(&Upload) -> bool,
314+ outcome: &UploadOutcome,
315+ ) -> Vec<Event> {
316+ let ending = self
317+ .live
318+ .iter()
319+ .filter(|upload| ends(upload))
320+ .cloned()
321+ .collect::<Vec<_>>();
322+ ending
323+ .into_iter()
324+ .filter_map(|upload| self.finish(upload, outcome.clone(), true))
325+ .collect()
326+ }
327+
328+ fn finish(&mut self, upload: Upload, outcome: UploadOutcome, remember: bool) -> Option<Event> {
329+ if !self.live.remove(&upload) {
330+ return None;
331+ }
332+ if remember {
333+ self.ended.insert(upload.clone());
334+ }
335+ let (room_id, transaction_id) = upload;
336+ Some(Event::MediaUploadFinished {
337+ room_id,
338+ transaction_id,
339+ outcome,
340+ })
341+ }
342+}
343+
344+async fn attachment_echoes(client: &Client) -> HashSet<Upload> {
345+ let Ok(echoes) = client.send_queue().local_echoes().await else {
346+ return HashSet::new();
347+ };
348+ echoes
349+ .into_iter()
350+ .flat_map(|(room_id, echoes)| {
351+ echoes
352+ .into_iter()
353+ .filter(|echo| is_attachment_echo(&echo.content))
354+ .map(move |echo| (room_id.clone(), echo.transaction_id))
355+ })
356+ .collect()
357+}
358+
359+fn is_attachment_echo(content: &LocalEchoContent) -> bool {
360+ let LocalEchoContent::Event {
361+ serialized_event, ..
362+ } = content
363+ else {
364+ return false;
365+ };
366+ matches!(
367+ serialized_event.deserialize(),
368+ Ok(AnyMessageLikeEventContent::RoomMessage(content))
369+ if matches!(
370+ content.msgtype,
371+ MessageType::Audio(_) | MessageType::File(_) | MessageType::Image(_) | MessageType::Video(_)
372+ )
373+ )
374+}
375+
376+pub(super) async fn encryption(client: Client, events: EventSender) {
377+ let Some(device_id) = client.device_id().map(ToOwned::to_owned) else {
378+ tracing::warn!("encryption watcher stopped because the client has no device id");
379+ return;
380+ };
381+ let recovery = client.encryption().recovery();
382+ let backups = client.encryption().backups();
383+ let mut recovery_stream = recovery.state_stream();
384+ let mut backup_stream = backups.state_stream();
385+ let mut verification_stream = client.encryption().verification_state();
386+ let mut recovery_state = recovery.state();
387+ let mut backup_state = backups.state();
388+ let mut verification_state = verification_stream.get();
389+ loop {
390+ let state = encryption_state(
391+ device_id.clone(),
392+ recovery_state,
393+ verification_state,
394+ backup_state,
395+ );
396+ if events.send(Event::EncryptionChanged(state)).await.is_err() {
397+ return;
398+ }
399+ tokio::select! {
400+ Some(update) = recovery_stream.next() => recovery_state = update,
401+ Some(update) = verification_stream.next() => verification_state = update,
402+ Some(update) = backup_stream.next() => {
403+ backup_state = update.unwrap_or_else(|_| backups.state());
404+ }
405+ else => return,
406+ }
407+ }
408+}
409+
410+pub(super) async fn rooms(client: Client, events: EventSender) {
411+ let (_, mut rooms) = client.rooms_stream();
412+ let mut info = client.room_info_notable_update_receiver();
413+ loop {
414+ if events.send(Event::RoomsChanged).await.is_err() {
415+ return;
416+ }
417+ tokio::select! {
418+ update = rooms.next() => if update.is_none() { return },
419+ update = info.recv() => if matches!(update, Err(RecvError::Closed)) { return },
420+ }
421+ // a sync touching fifty rooms is one invalidation, not fifty
422+ while let Ok(_) | Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) =
423+ info.try_recv()
424+ {}
425+ }
426+}
427+
428+pub(super) async fn notification_settings(client: Client, events: EventSender) {
429+ let settings = client.notification_settings().await;
430+ let mut changes = settings.subscribe_to_changes();
431+ while let Ok(()) | Err(RecvError::Lagged(_)) = changes.recv().await {
432+ // room summaries carry the effective mode, and push rule changes never touch room info
433+ for event in [Event::NotificationSettingsChanged, Event::RoomsChanged] {
434+ if events.send(event).await.is_err() {
435+ return;
436+ }
437+ }
438+ }
439+}
440+
441+#[cfg(test)]
442+mod tests {
443+ use matrix_sdk::send_queue::{RoomSendQueueUpdate, SendQueueUpdate};
444+ use ruma::{OwnedTransactionId, event_id, owned_room_id};
445+
446+ use super::{HashSet, RoomUpload, Upload, UploadTracker};
447+ use crate::{Event, UploadOutcome};
448+
449+ fn upload() -> Upload {
450+ (
451+ owned_room_id!("!room:localhost"),
452+ OwnedTransactionId::from("upload"),
453+ )
454+ }
455+
456+ fn tick() -> RoomUpload {
457+ RoomUpload::Progress {
458+ upload: upload(),
459+ uploaded_bytes: 1,
460+ total_bytes: 2,
461+ complete: false,
462+ }
463+ }
464+
465+ fn sent() -> UploadOutcome {
466+ UploadOutcome::Sent {
467+ event_id: event_id!("$sent:localhost").to_owned(),
468+ }
469+ }
470+
471+ fn tracker() -> UploadTracker {
472+ UploadTracker {
473+ live: HashSet::new(),
474+ ended: HashSet::new(),
475+ }
476+ }
477+
478+ fn finished(event: Option<Event>) -> Option<UploadOutcome> {
479+ match event? {
480+ Event::MediaUploadFinished { outcome, .. } => Some(outcome),
481+ _ => None,
482+ }
483+ }
484+
485+ #[test]
486+ fn a_stale_tick_after_a_missed_end_changes_nothing() {
487+ let mut tracker = tracker();
488+ assert!(tracker.room(tick()).is_some());
489+ let missed = tracker.resnapshot(HashSet::new());
490+ assert!(matches!(
491+ missed.as_slice(),
492+ [Event::MediaUploadFinished {
493+ outcome: UploadOutcome::Missed,
494+ ..
495+ }]
496+ ));
497+
498+ assert!(tracker.room(tick()).is_none());
499+ let (room_id, transaction_id) = upload();
500+ let global = SendQueueUpdate {
501+ room_id,
502+ update: RoomSendQueueUpdate::SentEvent {
503+ transaction_id,
504+ event_id: event_id!("$sent:localhost").to_owned(),
505+ },
506+ };
507+ assert!(tracker.update(global).is_none());
508+ assert!(tracker.room(RoomUpload::Ended(upload(), sent())).is_none());
509+ assert!(tracker.live.is_empty() && tracker.ended.is_empty());
510+ }
511+
512+ #[test]
513+ fn an_end_the_tracker_never_knew_about_still_follows_its_progress() {
514+ let mut tracker = tracker();
515+ let (room_id, transaction_id) = upload();
516+ let global = SendQueueUpdate {
517+ room_id,
518+ update: RoomSendQueueUpdate::SentEvent {
519+ transaction_id,
520+ event_id: event_id!("$sent:localhost").to_owned(),
521+ },
522+ };
523+ assert!(tracker.update(global).is_none());
524+ assert!(tracker.room(tick()).is_some());
525+ assert_eq!(
526+ finished(tracker.room(RoomUpload::Ended(upload(), sent()))),
527+ Some(sent())
528+ );
529+ assert!(tracker.live.is_empty() && tracker.ended.is_empty());
530+ }
531+}
Added file: vendor/macltok/src/error.rs+245 additions
@@ -0,0 +1,245 @@
1+use std::sync::Arc;
2+
3+use matrix_sdk::{
4+ HttpError, RefreshTokenError, SendOutsideWasm, SyncOutsideWasm,
5+ authentication::oauth::error::{
6+ OAuthAuthorizationCodeError, OAuthDiscoveryError, OAuthError, OAuthTokenRevocationError,
7+ RequestTokenError,
8+ },
9+ encryption::{
10+ identities::{ManualVerifyError, RequestVerificationError},
11+ recovery::RecoveryError,
12+ secret_storage::{ImportError, SecretStorageError},
13+ },
14+};
15+#[cfg(feature = "serde")]
16+use serde::{Deserialize, Serialize};
17+
18+use crate::InteractiveAuthChallenge;
19+
20+/// the sdk's `SendOutsideWasm` isn't an auto trait, so it can't go in a trait object
21+#[cfg(not(target_family = "wasm"))]
22+pub(crate) type SourceError = dyn std::error::Error + Send + Sync;
23+#[cfg(target_family = "wasm")]
24+pub(crate) type SourceError = dyn std::error::Error;
25+
26+/// the one thing that goes wrong, for commands and queries alike. match on `kind`, show `message`
27+#[derive(Clone, Debug, thiserror::Error)]
28+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
29+#[error("{message}")]
30+#[non_exhaustive]
31+pub struct Error {
32+ pub kind: ErrorKind,
33+ pub message: String,
34+ /// set when the server wants interactive auth before it'll do the thing
35+ pub challenge: Option<Box<InteractiveAuthChallenge>>,
36+ #[source]
37+ #[cfg_attr(feature = "serde", serde(skip))]
38+ source: Option<Arc<SourceError>>,
39+}
40+
41+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
43+#[non_exhaustive]
44+pub enum ErrorKind {
45+ /// the engine stopped, or the task doing the work died, before answering
46+ Interrupted,
47+ Unauthenticated,
48+ InteractiveAuthRequired,
49+ Invalid,
50+ RoomUnknown,
51+ RoomNotOpen,
52+ TimelineNotOpen,
53+ RoomBusy,
54+ QueueFull,
55+ EmptyBody,
56+ MediaUnknown,
57+ MediaTooLarge,
58+ MediaUnavailable,
59+ VerificationInactive,
60+ VerificationUnsupported,
61+ NoCrossSigningIdentity,
62+ RecoveryRejected,
63+ Network,
64+ Server,
65+ Store,
66+ Timeline,
67+ Encryption,
68+ Other,
69+}
70+
71+impl Error {
72+ pub(crate) fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
73+ Self {
74+ kind,
75+ message: message.into(),
76+ challenge: None,
77+ source: None,
78+ }
79+ }
80+
81+ pub(crate) fn with_source(
82+ kind: ErrorKind,
83+ message: impl Into<String>,
84+ source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,
85+ ) -> Self {
86+ Self {
87+ source: Some(Arc::new(source)),
88+ ..Self::new(kind, message)
89+ }
90+ }
91+
92+ pub(crate) fn interrupted() -> Self {
93+ Self::new(
94+ ErrorKind::Interrupted,
95+ "the engine stopped before the operation finished",
96+ )
97+ }
98+
99+ pub(crate) fn room_unknown() -> Self {
100+ Self::new(
101+ ErrorKind::RoomUnknown,
102+ "room is not available in the local store",
103+ )
104+ }
105+
106+ pub(crate) fn interactive_auth(challenge: InteractiveAuthChallenge) -> Self {
107+ Self {
108+ challenge: Some(Box::new(challenge)),
109+ ..Self::new(
110+ ErrorKind::InteractiveAuthRequired,
111+ "interactive authentication is required",
112+ )
113+ }
114+ }
115+
116+ pub(crate) fn from_sdk(message: impl Into<String>, source: matrix_sdk::Error) -> Self {
117+ Self::with_source(classify_sdk_error(&source), message, source)
118+ }
119+
120+ pub(crate) fn from_http(message: impl Into<String>, source: HttpError) -> Self {
121+ Self::with_source(classify_http_error(&source), message, source)
122+ }
123+
124+ pub(crate) fn from_refresh_token(
125+ message: impl Into<String>,
126+ source: RefreshTokenError,
127+ ) -> Self {
128+ Self::with_source(classify_refresh_token_error(&source), message, source)
129+ }
130+
131+ pub(crate) fn from_recovery(message: impl Into<String>, source: RecoveryError) -> Self {
132+ let kind = match &source {
133+ RecoveryError::Sdk(source) => classify_sdk_error(source),
134+ RecoveryError::SecretStorage(SecretStorageError::Sdk(source)) => {
135+ classify_sdk_error(source)
136+ }
137+ RecoveryError::SecretStorage(SecretStorageError::ImportError { error, .. }) => {
138+ match error {
139+ ImportError::Sdk(source) => classify_sdk_error(source),
140+ _ => ErrorKind::RecoveryRejected,
141+ }
142+ }
143+ RecoveryError::SecretStorage(SecretStorageError::Verification(source)) => {
144+ match source {
145+ ManualVerifyError::Http(source) => classify_http_error(source),
146+ _ => ErrorKind::RecoveryRejected,
147+ }
148+ }
149+ RecoveryError::SecretStorage(SecretStorageError::SecretStorageKey(_)) => {
150+ ErrorKind::RecoveryRejected
151+ }
152+ RecoveryError::SecretStorage(SecretStorageError::Storage(_)) => ErrorKind::Store,
153+ RecoveryError::BackupExistsOnServer | RecoveryError::SecretStorage(_) => {
154+ ErrorKind::RecoveryRejected
155+ }
156+ };
157+ Self::with_source(kind, message, source)
158+ }
159+
160+ pub(crate) fn from_verification_request(
161+ message: impl Into<String>,
162+ source: RequestVerificationError,
163+ ) -> Self {
164+ let kind = match &source {
165+ RequestVerificationError::Sdk(source) => classify_sdk_error(source),
166+ RequestVerificationError::RoomCreation(_) => ErrorKind::Encryption,
167+ };
168+ Self::with_source(kind, message, source)
169+ }
170+}
171+
172+impl PartialEq for Error {
173+ fn eq(&self, other: &Self) -> bool {
174+ self.kind == other.kind
175+ && self.message == other.message
176+ && self.challenge == other.challenge
177+ }
178+}
179+
180+pub(crate) fn classify_sdk_error(source: &matrix_sdk::Error) -> ErrorKind {
181+ match source {
182+ matrix_sdk::Error::Http(source) => classify_http_error(source),
183+ matrix_sdk::Error::OAuth(source) => classify_oauth_error(source),
184+ matrix_sdk::Error::AuthenticationRequired => ErrorKind::Unauthenticated,
185+ matrix_sdk::Error::BadCryptoStoreState
186+ | matrix_sdk::Error::CryptoStoreError(_)
187+ | matrix_sdk::Error::CrossProcessLockError(_)
188+ | matrix_sdk::Error::StateStore(_)
189+ | matrix_sdk::Error::EventCacheStore(_)
190+ | matrix_sdk::Error::MediaStore(_) => ErrorKind::Store,
191+ matrix_sdk::Error::NoOlmMachine
192+ | matrix_sdk::Error::OlmError(_)
193+ | matrix_sdk::Error::MegolmError(_)
194+ | matrix_sdk::Error::DecryptorError(_) => ErrorKind::Encryption,
195+ matrix_sdk::Error::Media(_) => ErrorKind::MediaUnavailable,
196+ matrix_sdk::Error::Timeout => ErrorKind::Network,
197+ _ => ErrorKind::Other,
198+ }
199+}
200+
201+fn classify_http_error(source: &HttpError) -> ErrorKind {
202+ match source {
203+ HttpError::Reqwest(_) => ErrorKind::Network,
204+ HttpError::Api(_) => ErrorKind::Server,
205+ HttpError::RefreshToken(source) => classify_refresh_token_error(source),
206+ HttpError::Cached(source) => classify_http_error(source),
207+ _ => ErrorKind::Other,
208+ }
209+}
210+
211+fn classify_refresh_token_error(source: &RefreshTokenError) -> ErrorKind {
212+ match source {
213+ RefreshTokenError::RefreshTokenRequired => ErrorKind::Unauthenticated,
214+ RefreshTokenError::MatrixAuth(source) => classify_http_error(source),
215+ RefreshTokenError::OAuth(source) => classify_oauth_error(source),
216+ }
217+}
218+
219+fn classify_oauth_error(source: &OAuthError) -> ErrorKind {
220+ let classify_token_request_error = |source: &_| match source {
221+ RequestTokenError::Request(_) => ErrorKind::Network,
222+ RequestTokenError::ServerResponse(_) => ErrorKind::Unauthenticated,
223+ _ => ErrorKind::Other,
224+ };
225+ match source {
226+ OAuthError::Discovery(source) => match source {
227+ OAuthDiscoveryError::Http(source) => classify_http_error(source),
228+ OAuthDiscoveryError::Oidc(source) => classify_token_request_error(source),
229+ _ => ErrorKind::Unauthenticated,
230+ },
231+ OAuthError::RefreshToken(source) => classify_token_request_error(source),
232+ OAuthError::AuthorizationCode(OAuthAuthorizationCodeError::RequestToken(source)) => {
233+ classify_token_request_error(source)
234+ }
235+ OAuthError::AuthorizationCode(_) => ErrorKind::Unauthenticated,
236+ OAuthError::Logout(source) => match source {
237+ OAuthTokenRevocationError::Revoke(RequestTokenError::Request(_)) => ErrorKind::Network,
238+ OAuthTokenRevocationError::Revoke(RequestTokenError::ServerResponse(_)) => {
239+ ErrorKind::Unauthenticated
240+ }
241+ _ => ErrorKind::Other,
242+ },
243+ _ => ErrorKind::Unauthenticated,
244+ }
245+}
Added file: vendor/macltok/src/lib.rs+67 additions
@@ -0,0 +1,67 @@
1+//! A compact application backend contract on top of the Matrix Rust SDK.
2+
3+mod encryption;
4+mod engine;
5+mod error;
6+#[cfg(feature = "mirror")]
7+mod mirror;
8+mod model;
9+mod normalise;
10+mod transport;
11+mod uiaa;
12+mod upload;
13+#[cfg(target_family = "wasm")]
14+mod wasm;
15+
16+#[cfg(all(feature = "sqlite", target_family = "wasm"))]
17+compile_error!("sqlite has no filesystem to live on in wasm, use indexeddb");
18+#[cfg(all(feature = "indexeddb", not(target_family = "wasm")))]
19+compile_error!("indexeddb only exists in a browser, use sqlite");
20+
21+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
22+mod account;
23+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
24+mod session_store;
25+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
26+mod store_lock;
27+
28+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
29+pub use account::{
30+ AccountError, OAuthApplicationType, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore,
31+ Session, SessionRecord, SoftLogout, StoreConfig, UnsavedSession,
32+};
33+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
34+pub use session_store::{EncryptedSessionStore, SessionStore};
35+
36+pub use engine::{Engine, EngineConfig, EngineError};
37+pub use error::{Error, ErrorKind};
38+pub use imbl;
39+#[cfg(feature = "mirror")]
40+pub use mirror::{
41+ Mirror, MirrorHandle, SessionStatus, State, StateChange, Subscription, TimelineChange,
42+ TimelineState, Update, UploadFailure, UploadState,
43+};
44+pub use model::{
45+ AccountCommand, AttachmentEncryption, AttachmentUpload, Change, Command, CommandOutput,
46+ ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind,
47+ EngineFailure, Event, EventRef, HistoryState, KeyBackupState, LatestEvent, LifecycleCommand,
48+ Media, MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,
49+ MemberFilter, MemberMembership, MemberSummary, MembershipChange, Message, MessageContent,
50+ MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice,
51+ PowerLevelChanges, Profile, PushNotification, PushNotificationEvent, PushNotificationRoom,
52+ PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState, ReplyDetails,
53+ ReplyReference, Retain, RichText, RoomCategory, RoomCommand, RoomHero, RoomListFilter,
54+ RoomMatcher, RoomMembership, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels,
55+ RoomPreset, RoomSummary, RoomVisibility, SendState, Sender, SpaceChild, Termination,
56+ ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,
57+ TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,
58+ UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,
59+ VerificationState, VerificationTarget,
60+};
61+pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};
62+pub use uiaa::{
63+ InteractiveAuth, InteractiveAuthChallenge, InteractiveAuthFailure, InteractiveAuthFlow,
64+ InteractiveAuthOutcome,
65+};
66+#[cfg(target_family = "wasm")]
67+pub use wasm::on_panic;
Added file: vendor/macltok/src/mirror.rs+639 additions
@@ -0,0 +1,639 @@
1+use std::{
2+ collections::HashMap,
3+ fmt,
4+ pin::Pin,
5+ sync::{Arc, Mutex, MutexGuard, PoisonError},
6+ task::{Context, Poll},
7+ time::Duration,
8+};
9+
10+use futures_util::{Stream, StreamExt};
11+use imbl::Vector;
12+use matrix_sdk::{
13+ SendOutsideWasm,
14+ executor::{AbortHandle, spawn},
15+};
16+use ruma::{OwnedDeviceId, OwnedRoomId, OwnedTransactionId, OwnedUserId};
17+#[cfg(feature = "serde")]
18+use serde::{Deserialize, Serialize};
19+use tokio::sync::broadcast;
20+
21+use crate::{
22+ ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,
23+ HistoryState, RoomSummary, TimelineCommand, TimelineDiff, TimelineId, TimelineItem,
24+ TypingState, UploadOutcome, VerificationState, VerificationTarget,
25+ engine::executor::EngineBoxFuture,
26+};
27+
28+#[cfg(not(target_family = "wasm"))]
29+type EngineBoxStream<T> = futures_util::stream::BoxStream<'static, T>;
30+#[cfg(target_family = "wasm")]
31+type EngineBoxStream<T> = futures_util::stream::LocalBoxStream<'static, T>;
32+
33+const DEFAULT_CAPACITY: usize = 256;
34+const RESYNC_BACKOFF: Duration = Duration::from_millis(100);
35+const RESYNC_BACKOFF_CAP: Duration = Duration::from_secs(10);
36+
37+/// everything the engine has said so far, folded together
38+#[derive(Clone, Debug, PartialEq)]
39+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
40+#[non_exhaustive]
41+pub struct State {
42+ pub user_id: Option<OwnedUserId>,
43+ pub session: SessionStatus,
44+ pub connection: ConnectionState,
45+ pub encryption: Option<EncryptionState>,
46+ pub verification: Option<VerificationState>,
47+ pub verification_target: Option<VerificationTarget>,
48+ pub rooms: Vector<RoomSummary>,
49+ pub timelines: imbl::HashMap<TimelineId, TimelineState>,
50+ pub typing: imbl::HashMap<OwnedRoomId, Vec<OwnedUserId>>,
51+ pub uploads: imbl::HashMap<OwnedTransactionId, UploadState>,
52+ /// what stopped the engine, if something went wrong
53+ pub engine_failure: Option<EngineFailure>,
54+ /// the last save of the session record that failed, until one succeeds
55+ pub session_save_failure: Option<String>,
56+}
57+
58+#[derive(Clone, Debug, PartialEq)]
59+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
60+#[non_exhaustive]
61+pub struct TimelineState {
62+ pub items: Vector<TimelineItem>,
63+ pub history: HistoryState,
64+ /// a batch didn't fit, so `items` is the last good copy until the next reset
65+ pub diverged: bool,
66+}
67+
68+#[derive(Clone, Debug, Eq, PartialEq)]
69+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
70+#[non_exhaustive]
71+pub struct UploadState {
72+ pub room_id: OwnedRoomId,
73+ pub uploaded_bytes: u64,
74+ pub total_bytes: u64,
75+ pub complete: bool,
76+ pub failure: Option<UploadFailure>,
77+}
78+
79+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
81+#[non_exhaustive]
82+pub struct UploadFailure {
83+ pub kind: ErrorKind,
84+ pub will_retry: bool,
85+}
86+
87+/// the `Termination` it'll end in, as far as the events tell
88+#[derive(Clone, Debug, Eq, PartialEq)]
89+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
90+#[non_exhaustive]
91+pub enum SessionStatus {
92+ Active,
93+ SoftLoggedOut {
94+ device_id: OwnedDeviceId,
95+ },
96+ AccessRevoked,
97+ LoggedOut,
98+ /// stopped with the session still good
99+ Stopped,
100+}
101+
102+#[derive(Clone, Debug)]
103+#[non_exhaustive]
104+pub struct Update {
105+ /// one more than the update before it, and a `State` carries the one it's current as of
106+ pub sequence: u64,
107+ pub change: StateChange,
108+}
109+
110+#[derive(Clone, Debug)]
111+#[non_exhaustive]
112+pub enum StateChange {
113+ /// start over from this. a subscription opens with one and gets another after lagging
114+ State(Arc<State>),
115+ Authenticated(OwnedUserId),
116+ Session(SessionStatus),
117+ /// the record on disk may now hold a spent refresh token
118+ SessionSaveFailed(String),
119+ /// saved again after `SessionSaveFailed`
120+ SessionSaved,
121+ Connection(ConnectionState),
122+ Encryption(EncryptionState),
123+ Verification(VerificationState),
124+ VerificationTarget(VerificationTarget),
125+ /// room modes arrive with the `Rooms` that follows
126+ NotificationSettings,
127+ Rooms(Vector<RoomSummary>),
128+ Timeline {
129+ timeline_id: TimelineId,
130+ change: TimelineChange,
131+ },
132+ History {
133+ timeline_id: TimelineId,
134+ state: HistoryState,
135+ },
136+ Typing(TypingState),
137+ Upload {
138+ transaction_id: OwnedTransactionId,
139+ upload: UploadState,
140+ },
141+ UploadFinished {
142+ room_id: OwnedRoomId,
143+ transaction_id: OwnedTransactionId,
144+ outcome: UploadOutcome,
145+ },
146+ Failed(EngineFailure),
147+}
148+
149+#[derive(Clone, Debug)]
150+#[non_exhaustive]
151+pub enum TimelineChange {
152+ Reset {
153+ items: Vector<TimelineItem>,
154+ },
155+ /// `items` is what applying `diffs` left
156+ Changed {
157+ diffs: Arc<[TimelineDiff]>,
158+ items: Vector<TimelineItem>,
159+ },
160+ /// a batch didn't fit. a resync is on its way and nothing changes until its reset
161+ Diverged,
162+ Closed,
163+}
164+
165+/// drains the event stream into a `State` that any number of `MirrorHandle`s watch
166+pub struct Mirror {
167+ handle: ClientHandle,
168+ events: EngineBoxStream<Event>,
169+ shared: Arc<Shared>,
170+ resyncs: HashMap<TimelineId, AbortHandle>,
171+ rooms_fetch: Option<AbortHandle>,
172+}
173+
174+impl fmt::Debug for Mirror {
175+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176+ formatter.debug_struct("Mirror").finish_non_exhaustive()
177+ }
178+}
179+
180+impl Mirror {
181+ /// takes any stream of the engine's events, so a host can still look at them on the way past
182+ pub fn new(
183+ handle: ClientHandle,
184+ events: impl Stream<Item = Event> + SendOutsideWasm + 'static,
185+ ) -> (Self, MirrorHandle) {
186+ Self::with_capacity(handle, events, DEFAULT_CAPACITY)
187+ }
188+
189+ /// `capacity` updates can queue for a subscriber before it lags and starts over. a zero
190+ /// capacity counts as one
191+ pub fn with_capacity(
192+ handle: ClientHandle,
193+ events: impl Stream<Item = Event> + SendOutsideWasm + 'static,
194+ capacity: usize,
195+ ) -> (Self, MirrorHandle) {
196+ let state = State {
197+ user_id: None,
198+ session: SessionStatus::Active,
199+ connection: *handle.connection_state().borrow(),
200+ encryption: handle.encryption_state(),
201+ verification: None,
202+ verification_target: None,
203+ rooms: Vector::new(),
204+ timelines: imbl::HashMap::new(),
205+ typing: imbl::HashMap::new(),
206+ uploads: imbl::HashMap::new(),
207+ engine_failure: None,
208+ session_save_failure: None,
209+ };
210+ let shared = Arc::new(Shared {
211+ published: Mutex::new(Published {
212+ state: Arc::new(state),
213+ sequence: 0,
214+ updates: Some(broadcast::channel(capacity.max(1)).0),
215+ rooms_fetch: RoomsFetch::Idle,
216+ }),
217+ });
218+ #[cfg(not(target_family = "wasm"))]
219+ let events = events.boxed();
220+ #[cfg(target_family = "wasm")]
221+ let events = events.boxed_local();
222+ let mirror = Self {
223+ handle,
224+ events,
225+ shared: shared.clone(),
226+ resyncs: HashMap::new(),
227+ rooms_fetch: None,
228+ };
229+ (mirror, MirrorHandle { shared })
230+ }
231+
232+ /// resolves once the event stream ends, which is just after `Stopped`
233+ pub fn run(mut self) -> EngineBoxFuture<'static, ()> {
234+ Box::pin(async move {
235+ self.refetch_rooms();
236+ while let Some(event) = self.events.next().await {
237+ self.apply(event);
238+ }
239+ })
240+ }
241+
242+ fn apply(&mut self, event: Event) {
243+ match event {
244+ Event::ConnectionChanged(connection) => self.publish(|state| {
245+ state.connection = connection;
246+ Some(StateChange::Connection(connection))
247+ }),
248+ Event::Authenticated { user_id } => self.publish(|state| {
249+ state.user_id = Some(user_id.clone());
250+ Some(StateChange::Authenticated(user_id))
251+ }),
252+ Event::SessionSaveFailed { message } => self.publish(|state| {
253+ state.session_save_failure = Some(message.clone());
254+ Some(StateChange::SessionSaveFailed(message))
255+ }),
256+ Event::SessionTokensChanged => self.publish(|state| {
257+ state.session_save_failure.take()?;
258+ Some(StateChange::SessionSaved)
259+ }),
260+ Event::SessionAccessRejected { soft_logout } => self.publish(|state| {
261+ state.session = match soft_logout {
262+ Some(device_id) => SessionStatus::SoftLoggedOut { device_id },
263+ None => SessionStatus::AccessRevoked,
264+ };
265+ Some(StateChange::Session(state.session.clone()))
266+ }),
267+ Event::LoggedOut => self.publish(|state| {
268+ state.session = SessionStatus::LoggedOut;
269+ Some(StateChange::Session(SessionStatus::LoggedOut))
270+ }),
271+ Event::Stopped => {
272+ self.cancel_resyncs();
273+ self.publish(|state| {
274+ if state.session != SessionStatus::Active {
275+ return None;
276+ }
277+ state.session = SessionStatus::Stopped;
278+ Some(StateChange::Session(SessionStatus::Stopped))
279+ });
280+ }
281+ Event::Failed(failure) => self.publish(|state| {
282+ state.engine_failure = Some(failure.clone());
283+ Some(StateChange::Failed(failure))
284+ }),
285+ Event::NotificationSettingsChanged => {
286+ self.publish(|_| Some(StateChange::NotificationSettings));
287+ }
288+ Event::RoomsChanged => self.refetch_rooms(),
289+ Event::EncryptionChanged(encryption) => self.publish(|state| {
290+ state.encryption = Some(encryption.clone());
291+ Some(StateChange::Encryption(encryption))
292+ }),
293+ Event::VerificationChanged(verification) => self.publish(|state| {
294+ state.verification = Some(verification.clone());
295+ Some(StateChange::Verification(verification))
296+ }),
297+ Event::VerificationTargetChanged(target) => self.publish(|state| {
298+ state.verification_target = Some(target.clone());
299+ Some(StateChange::VerificationTarget(target))
300+ }),
301+ Event::TimelineReset { timeline_id, items } => {
302+ self.cancel_resync(&timeline_id);
303+ self.publish(|state| {
304+ let history = state
305+ .timelines
306+ .get(&timeline_id)
307+ .map_or(HistoryState::Idle, |timeline| timeline.history.clone());
308+ let timeline = TimelineState {
309+ items: items.clone(),
310+ history,
311+ diverged: false,
312+ };
313+ state.timelines.insert(timeline_id.clone(), timeline);
314+ Some(StateChange::Timeline {
315+ timeline_id,
316+ change: TimelineChange::Reset { items },
317+ })
318+ });
319+ }
320+ Event::TimelineChanged { timeline_id, diffs } => self.apply_diffs(timeline_id, diffs),
321+ Event::HistoryChanged {
322+ timeline_id,
323+ state: history,
324+ } => self.publish(|state| {
325+ state.timelines.get_mut(&timeline_id)?.history = history.clone();
326+ Some(StateChange::History {
327+ timeline_id,
328+ state: history,
329+ })
330+ }),
331+ Event::TimelineClosed { timeline_id } => {
332+ self.cancel_resync(&timeline_id);
333+ self.publish(|state| {
334+ state.timelines.remove(&timeline_id)?;
335+ Some(StateChange::Timeline {
336+ timeline_id,
337+ change: TimelineChange::Closed,
338+ })
339+ });
340+ }
341+ Event::TypingChanged(typing) => self.publish(|state| {
342+ if typing.users.is_empty() {
343+ state.typing.remove(&typing.room_id);
344+ } else {
345+ state
346+ .typing
347+ .insert(typing.room_id.clone(), typing.users.clone());
348+ }
349+ Some(StateChange::Typing(typing))
350+ }),
351+ Event::MediaUploadProgress {
352+ room_id,
353+ transaction_id,
354+ uploaded_bytes,
355+ total_bytes,
356+ complete,
357+ } => self.publish(|state| {
358+ let upload = UploadState {
359+ room_id,
360+ uploaded_bytes,
361+ total_bytes,
362+ complete,
363+ failure: None,
364+ };
365+ state.uploads.insert(transaction_id.clone(), upload.clone());
366+ Some(StateChange::Upload {
367+ transaction_id,
368+ upload,
369+ })
370+ }),
371+ Event::MediaUploadFailed {
372+ room_id,
373+ transaction_id,
374+ kind,
375+ will_retry,
376+ } => self.publish(|state| {
377+ let upload = state
378+ .uploads
379+ .entry(transaction_id.clone())
380+ .or_insert_with(|| UploadState {
381+ room_id,
382+ uploaded_bytes: 0,
383+ total_bytes: 0,
384+ complete: false,
385+ failure: None,
386+ });
387+ upload.failure = Some(UploadFailure { kind, will_retry });
388+ Some(StateChange::Upload {
389+ transaction_id,
390+ upload: upload.clone(),
391+ })
392+ }),
393+ Event::MediaUploadFinished {
394+ room_id,
395+ transaction_id,
396+ outcome,
397+ } => self.publish(|state| {
398+ state.uploads.remove(&transaction_id);
399+ Some(StateChange::UploadFinished {
400+ room_id,
401+ transaction_id,
402+ outcome,
403+ })
404+ }),
405+ }
406+ }
407+
408+ fn apply_diffs(&mut self, timeline_id: TimelineId, diffs: Vec<TimelineDiff>) {
409+ let mut diverged = false;
410+ self.publish(|state| {
411+ let Some(timeline) = state.timelines.get_mut(&timeline_id) else {
412+ diverged = true;
413+ state.timelines.insert(
414+ timeline_id.clone(),
415+ TimelineState {
416+ items: Vector::new(),
417+ history: HistoryState::Idle,
418+ diverged: true,
419+ },
420+ );
421+ return Some(StateChange::Timeline {
422+ timeline_id: timeline_id.clone(),
423+ change: TimelineChange::Diverged,
424+ });
425+ };
426+ if timeline.diverged {
427+ return None;
428+ }
429+ let diffs: Arc<[TimelineDiff]> = diffs.into();
430+ let change = match TimelineDiff::try_apply_all(diffs.to_vec(), &mut timeline.items) {
431+ Ok(()) => TimelineChange::Changed {
432+ diffs,
433+ items: timeline.items.clone(),
434+ },
435+ Err(error) => {
436+ tracing::debug!(?timeline_id, length = error.length, "timeline diverged");
437+ timeline.diverged = true;
438+ diverged = true;
439+ TimelineChange::Diverged
440+ }
441+ };
442+ Some(StateChange::Timeline {
443+ timeline_id: timeline_id.clone(),
444+ change,
445+ })
446+ });
447+ if diverged {
448+ let task = spawn(resync(self.handle.clone(), timeline_id.clone()));
449+ self.resyncs.insert(timeline_id, task.abort_handle());
450+ }
451+ }
452+
453+ fn refetch_rooms(&mut self) {
454+ let mut published = self.shared.lock();
455+ match published.rooms_fetch {
456+ RoomsFetch::Idle => {
457+ published.rooms_fetch = RoomsFetch::Running;
458+ drop(published);
459+ let task = spawn(fetch_rooms(self.handle.clone(), self.shared.clone()));
460+ self.rooms_fetch = Some(task.abort_handle());
461+ }
462+ RoomsFetch::Running => published.rooms_fetch = RoomsFetch::Again,
463+ RoomsFetch::Again => {}
464+ }
465+ }
466+
467+ fn cancel_resync(&mut self, timeline_id: &TimelineId) {
468+ if let Some(task) = self.resyncs.remove(timeline_id) {
469+ task.abort();
470+ }
471+ }
472+
473+ fn cancel_resyncs(&mut self) {
474+ for (_, task) in self.resyncs.drain() {
475+ task.abort();
476+ }
477+ }
478+
479+ fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {
480+ self.shared.publish(change);
481+ }
482+}
483+
484+impl Drop for Mirror {
485+ fn drop(&mut self) {
486+ self.cancel_resyncs();
487+ if let Some(task) = self.rooms_fetch.take() {
488+ task.abort();
489+ }
490+ self.shared.lock().updates = None;
491+ }
492+}
493+
494+/// asks until the engine answers, since a busy room or a full queue is only ever for now.
495+/// a reset or a close for the timeline aborts it, and the engine sends a reset before it answers
496+async fn resync(handle: ClientHandle, timeline_id: TimelineId) {
497+ let mut backoff = RESYNC_BACKOFF;
498+ loop {
499+ let command = Command::Timeline(TimelineCommand::Resync {
500+ timeline_id: timeline_id.clone(),
501+ });
502+ match handle.execute(command).await {
503+ Ok(_) => return,
504+ Err(error) if error.kind == ErrorKind::Interrupted => return,
505+ Err(error) => {
506+ tracing::debug!(%error, ?timeline_id, "resync refused, trying again");
507+ matrix_sdk::sleep::sleep(backoff).await;
508+ backoff = (backoff * 2).min(RESYNC_BACKOFF_CAP);
509+ }
510+ }
511+ }
512+}
513+
514+async fn fetch_rooms(handle: ClientHandle, shared: Arc<Shared>) {
515+ loop {
516+ let rooms: Vector<RoomSummary> = handle.rooms().await.into();
517+ shared.publish(|state| {
518+ state.rooms = rooms.clone();
519+ Some(StateChange::Rooms(rooms))
520+ });
521+ let mut published = shared.lock();
522+ if published.rooms_fetch != RoomsFetch::Again {
523+ published.rooms_fetch = RoomsFetch::Idle;
524+ return;
525+ }
526+ published.rooms_fetch = RoomsFetch::Running;
527+ }
528+}
529+
530+/// cheap to clone, one per frontend
531+#[derive(Clone, Debug)]
532+pub struct MirrorHandle {
533+ shared: Arc<Shared>,
534+}
535+
536+impl MirrorHandle {
537+ #[must_use]
538+ pub fn snapshot(&self) -> Arc<State> {
539+ self.shared.lock().state.clone()
540+ }
541+
542+ /// opens with the current state, then every update after it. ends once the mirror stops
543+ #[must_use]
544+ pub fn subscribe(&self) -> Subscription {
545+ let (first, receiving) = self.shared.clone().subscribe();
546+ let rest = futures_util::stream::unfold(receiving, |(shared, receiver)| async move {
547+ let mut receiver = receiver?;
548+ match receiver.recv().await {
549+ Ok(update) => Some((update, (shared, Some(receiver)))),
550+ Err(broadcast::error::RecvError::Lagged(missed)) => {
551+ tracing::debug!(missed, "mirror subscriber lagged, starting it over");
552+ Some(shared.subscribe())
553+ }
554+ Err(broadcast::error::RecvError::Closed) => None,
555+ }
556+ });
557+ let updates = futures_util::stream::once(std::future::ready(first)).chain(rest);
558+ #[cfg(not(target_family = "wasm"))]
559+ let updates = updates.boxed();
560+ #[cfg(target_family = "wasm")]
561+ let updates = updates.boxed_local();
562+ Subscription(updates)
563+ }
564+}
565+
566+pub struct Subscription(EngineBoxStream<Update>);
567+
568+impl fmt::Debug for Subscription {
569+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
570+ formatter
571+ .debug_struct("Subscription")
572+ .finish_non_exhaustive()
573+ }
574+}
575+
576+impl Stream for Subscription {
577+ type Item = Update;
578+
579+ fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Update>> {
580+ self.0.poll_next_unpin(context)
581+ }
582+}
583+
584+#[derive(Debug)]
585+struct Shared {
586+ published: Mutex<Published>,
587+}
588+
589+#[derive(Debug)]
590+struct Published {
591+ state: Arc<State>,
592+ sequence: u64,
593+ /// gone once the mirror stops, which ends every subscription
594+ updates: Option<broadcast::Sender<Update>>,
595+ rooms_fetch: RoomsFetch,
596+}
597+
598+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
599+enum RoomsFetch {
600+ Idle,
601+ Running,
602+ Again,
603+}
604+
605+impl Shared {
606+ fn lock(&self) -> MutexGuard<'_, Published> {
607+ self.published
608+ .lock()
609+ .unwrap_or_else(PoisonError::into_inner)
610+ }
611+
612+ /// state, sequence and send move together under the one lock, which is what lets
613+ /// `subscribe` take a snapshot and a receiver with nothing between them
614+ fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {
615+ let mut published = self.lock();
616+ let Some(change) = change(Arc::make_mut(&mut published.state)) else {
617+ return;
618+ };
619+ published.sequence += 1;
620+ let update = Update {
621+ sequence: published.sequence,
622+ change,
623+ };
624+ if let Some(updates) = &published.updates {
625+ let _ = updates.send(update);
626+ }
627+ }
628+
629+ fn subscribe(self: Arc<Self>) -> (Update, (Arc<Self>, Option<broadcast::Receiver<Update>>)) {
630+ let published = self.lock();
631+ let update = Update {
632+ sequence: published.sequence,
633+ change: StateChange::State(published.state.clone()),
634+ };
635+ let receiver = published.updates.as_ref().map(broadcast::Sender::subscribe);
636+ drop(published);
637+ (update, (self, receiver))
638+ }
639+}
Added file: vendor/macltok/src/model.rs+1854 additions
@@ -0,0 +1,1854 @@
1+use std::{fmt, sync::Arc};
2+
3+use imbl::Vector;
4+use ruma::{
5+ OwnedDeviceId, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedRoomOrAliasId,
6+ OwnedServerName, OwnedTransactionId, OwnedUserId,
7+ events::{
8+ AnyGlobalAccountDataEventContent, AnyRoomAccountDataEventContent, AnySyncTimelineEvent,
9+ GlobalAccountDataEventType, RoomAccountDataEventType,
10+ room::member::StrippedRoomMemberEvent,
11+ },
12+ serde::Raw,
13+};
14+#[cfg(feature = "serde")]
15+use serde::{Deserialize, Serialize};
16+
17+/// every command answers exactly once through `ClientHandle::send` or `execute`.
18+/// the event stream only ever carries state
19+#[derive(Debug)]
20+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
21+#[non_exhaustive]
22+pub enum Command {
23+ Account(AccountCommand),
24+ Room(RoomCommand),
25+ Timeline(TimelineCommand),
26+ Media(MediaCommand),
27+ Encryption(EncryptionCommand),
28+ Notification(NotificationCommand),
29+ Lifecycle(LifecycleCommand),
30+}
31+
32+#[derive(Debug)]
33+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
34+#[non_exhaustive]
35+pub enum AccountCommand {
36+ Logout,
37+ SetAccountData {
38+ event_type: GlobalAccountDataEventType,
39+ content: Raw<AnyGlobalAccountDataEventContent>,
40+ },
41+ SetDisplayName(Option<String>),
42+ SetAvatar(Option<OwnedMxcUri>),
43+ /// uploads the image and points the profile at it, same byte cap as any upload
44+ UploadAvatar {
45+ mimetype: String,
46+ bytes: Vec<u8>,
47+ },
48+ RenameDevice {
49+ device_id: OwnedDeviceId,
50+ display_name: String,
51+ },
52+ DeleteDevices {
53+ device_ids: Vec<OwnedDeviceId>,
54+ auth: Option<crate::InteractiveAuth>,
55+ },
56+}
57+
58+#[derive(Clone, Debug)]
59+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
60+#[non_exhaustive]
61+pub enum RoomCommand {
62+ /// the snapshot turns up as `Event::TimelineReset`. opening an open room sends a fresh one
63+ Open(OwnedRoomId),
64+ Close(OwnedRoomId),
65+ Join {
66+ room: OwnedRoomOrAliasId,
67+ via: Vec<OwnedServerName>,
68+ },
69+ Leave(OwnedRoomId),
70+ Forget(OwnedRoomId),
71+ AcceptInvite(OwnedRoomId),
72+ RejectInvite(OwnedRoomId),
73+ InviteUser {
74+ room_id: OwnedRoomId,
75+ user_id: OwnedUserId,
76+ },
77+ KickUser {
78+ room_id: OwnedRoomId,
79+ user_id: OwnedUserId,
80+ reason: Option<String>,
81+ },
82+ BanUser {
83+ room_id: OwnedRoomId,
84+ user_id: OwnedUserId,
85+ reason: Option<String>,
86+ },
87+ UnbanUser {
88+ room_id: OwnedRoomId,
89+ user_id: OwnedUserId,
90+ reason: Option<String>,
91+ },
92+ SetPowerLevels {
93+ room_id: OwnedRoomId,
94+ changes: PowerLevelChanges,
95+ },
96+ SetUserPowerLevel {
97+ room_id: OwnedRoomId,
98+ user_id: OwnedUserId,
99+ level: i64,
100+ },
101+ Create(CreateRoomOptions),
102+ /// hands back the existing dm if there is one
103+ CreateDirect(OwnedUserId),
104+ CancelSend {
105+ room_id: OwnedRoomId,
106+ transaction_id: OwnedTransactionId,
107+ },
108+ RetrySend {
109+ room_id: OwnedRoomId,
110+ transaction_id: OwnedTransactionId,
111+ },
112+ SetTyping {
113+ room_id: OwnedRoomId,
114+ typing: bool,
115+ },
116+ /// the key work a send into an encrypted room would otherwise start with, done now.
117+ /// nothing in a plain room, and a second ask joins the first
118+ PrepareToSend(OwnedRoomId),
119+ SetAccountData {
120+ room_id: OwnedRoomId,
121+ event_type: RoomAccountDataEventType,
122+ content: Raw<AnyRoomAccountDataEventContent>,
123+ },
124+}
125+
126+#[derive(Clone, Debug, Default, Eq, PartialEq)]
127+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
128+#[non_exhaustive]
129+pub struct CreateRoomOptions {
130+ pub name: Option<String>,
131+ pub topic: Option<String>,
132+ pub alias_localpart: Option<String>,
133+ pub invite: Vec<OwnedUserId>,
134+ pub direct: bool,
135+ pub visibility: RoomVisibility,
136+ pub preset: Option<RoomPreset>,
137+}
138+
139+impl CreateRoomOptions {
140+ #[must_use]
141+ pub fn with_name(mut self, name: impl Into<String>) -> Self {
142+ self.name = Some(name.into());
143+ self
144+ }
145+
146+ #[must_use]
147+ pub fn with_topic(mut self, topic: impl Into<String>) -> Self {
148+ self.topic = Some(topic.into());
149+ self
150+ }
151+
152+ #[must_use]
153+ pub fn with_alias_localpart(mut self, localpart: impl Into<String>) -> Self {
154+ self.alias_localpart = Some(localpart.into());
155+ self
156+ }
157+
158+ #[must_use]
159+ pub fn with_invite(mut self, invite: impl IntoIterator<Item = OwnedUserId>) -> Self {
160+ self.invite = invite.into_iter().collect();
161+ self
162+ }
163+
164+ #[must_use]
165+ pub const fn with_direct(mut self, direct: bool) -> Self {
166+ self.direct = direct;
167+ self
168+ }
169+
170+ #[must_use]
171+ pub const fn with_visibility(mut self, visibility: RoomVisibility) -> Self {
172+ self.visibility = visibility;
173+ self
174+ }
175+
176+ #[must_use]
177+ pub const fn with_preset(mut self, preset: RoomPreset) -> Self {
178+ self.preset = Some(preset);
179+ self
180+ }
181+}
182+
183+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
184+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
185+pub enum RoomVisibility {
186+ #[default]
187+ Private,
188+ Public,
189+}
190+
191+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
192+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
193+pub enum RoomPreset {
194+ PrivateChat,
195+ TrustedPrivateChat,
196+ PublicChat,
197+}
198+
199+#[derive(Clone, Debug, Eq, PartialEq)]
200+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
201+#[non_exhaustive]
202+pub enum TimelineCommand {
203+ OpenThread {
204+ room_id: OwnedRoomId,
205+ root_event_id: OwnedEventId,
206+ },
207+ CloseThread {
208+ room_id: OwnedRoomId,
209+ root_event_id: OwnedEventId,
210+ },
211+ /// Re-emit an authoritative reset for an open room or thread without
212+ /// closing it. The reset arrives before this command's terminal result.
213+ Resync {
214+ timeline_id: TimelineId,
215+ },
216+ Paginate {
217+ timeline_id: TimelineId,
218+ limit: u16,
219+ },
220+ Send {
221+ timeline_id: TimelineId,
222+ content: MessageContent,
223+ },
224+ Edit {
225+ timeline_id: TimelineId,
226+ target: EventRef,
227+ content: MessageEditContent,
228+ },
229+ /// redacting your own unsent message just cancels it
230+ Redact {
231+ timeline_id: TimelineId,
232+ target: EventRef,
233+ reason: Option<String>,
234+ },
235+ ToggleReaction {
236+ timeline_id: TimelineId,
237+ target: EventRef,
238+ key: String,
239+ },
240+ /// works on a closed room too, it just can't refuse to move the marker backwards
241+ MarkRead {
242+ timeline_id: TimelineId,
243+ event_id: OwnedEventId,
244+ },
245+}
246+
247+impl TimelineCommand {
248+ #[must_use]
249+ pub const fn room_id(&self) -> &OwnedRoomId {
250+ match self {
251+ Self::OpenThread { room_id, .. } | Self::CloseThread { room_id, .. } => room_id,
252+ Self::Resync { timeline_id }
253+ | Self::Paginate { timeline_id, .. }
254+ | Self::Send { timeline_id, .. }
255+ | Self::Edit { timeline_id, .. }
256+ | Self::Redact { timeline_id, .. }
257+ | Self::ToggleReaction { timeline_id, .. }
258+ | Self::MarkRead { timeline_id, .. } => timeline_id.room_id(),
259+ }
260+ }
261+
262+ #[must_use]
263+ pub const fn timeline_id(&self) -> Option<&TimelineId> {
264+ match self {
265+ Self::OpenThread { .. } | Self::CloseThread { .. } => None,
266+ Self::Resync { timeline_id }
267+ | Self::Paginate { timeline_id, .. }
268+ | Self::Send { timeline_id, .. }
269+ | Self::Edit { timeline_id, .. }
270+ | Self::Redact { timeline_id, .. }
271+ | Self::ToggleReaction { timeline_id, .. }
272+ | Self::MarkRead { timeline_id, .. } => Some(timeline_id),
273+ }
274+ }
275+}
276+
277+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
278+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
279+#[non_exhaustive]
280+pub enum TimelineId {
281+ Room(OwnedRoomId),
282+ Thread {
283+ room_id: OwnedRoomId,
284+ root_event_id: OwnedEventId,
285+ },
286+}
287+
288+impl TimelineId {
289+ #[must_use]
290+ pub const fn room_id(&self) -> &OwnedRoomId {
291+ match self {
292+ Self::Room(room_id) | Self::Thread { room_id, .. } => room_id,
293+ }
294+ }
295+
296+ #[must_use]
297+ pub fn thread(room_id: OwnedRoomId, root_event_id: OwnedEventId) -> Self {
298+ Self::Thread {
299+ room_id,
300+ root_event_id,
301+ }
302+ }
303+
304+ #[must_use]
305+ pub fn thread_root(&self) -> Option<&OwnedEventId> {
306+ match self {
307+ Self::Room(_) => None,
308+ Self::Thread { root_event_id, .. } => Some(root_event_id),
309+ }
310+ }
311+}
312+
313+impl From<OwnedRoomId> for TimelineId {
314+ fn from(room_id: OwnedRoomId) -> Self {
315+ Self::Room(room_id)
316+ }
317+}
318+
319+#[derive(Clone, Debug, Eq, PartialEq)]
320+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
321+#[non_exhaustive]
322+pub enum MediaCommand {
323+ /// succeeds once the send queue has it. progress and failure arrive as events
324+ Upload(AttachmentUpload),
325+}
326+
327+#[derive(Clone, Debug, Eq, PartialEq)]
328+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
329+#[non_exhaustive]
330+pub struct AttachmentUpload {
331+ pub timeline_id: TimelineId,
332+ pub filename: String,
333+ pub mimetype: String,
334+ pub bytes: Vec<u8>,
335+ pub caption: Option<String>,
336+ pub details: Option<UploadDetails>,
337+ pub thumbnail: Option<UploadThumbnail>,
338+}
339+
340+impl AttachmentUpload {
341+ #[must_use]
342+ pub fn new(
343+ timeline_id: impl Into<TimelineId>,
344+ filename: impl Into<String>,
345+ mimetype: impl Into<String>,
346+ bytes: Vec<u8>,
347+ ) -> Self {
348+ Self {
349+ timeline_id: timeline_id.into(),
350+ filename: filename.into(),
351+ mimetype: mimetype.into(),
352+ bytes,
353+ caption: None,
354+ details: None,
355+ thumbnail: None,
356+ }
357+ }
358+}
359+
360+#[derive(Clone, Debug, Eq, PartialEq)]
361+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
362+#[non_exhaustive]
363+pub enum UploadDetails {
364+ Image {
365+ width: Option<u32>,
366+ height: Option<u32>,
367+ blurhash: Option<String>,
368+ },
369+ Video {
370+ width: Option<u32>,
371+ height: Option<u32>,
372+ duration_ms: Option<u64>,
373+ blurhash: Option<String>,
374+ },
375+ Audio {
376+ duration_ms: Option<u64>,
377+ },
378+ Voice {
379+ duration_ms: u64,
380+ waveform: Vec<u16>,
381+ },
382+ File,
383+}
384+
385+#[derive(Clone, Debug, Eq, PartialEq)]
386+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
387+#[non_exhaustive]
388+pub struct UploadThumbnail {
389+ pub bytes: Vec<u8>,
390+ pub mimetype: String,
391+ pub width: u32,
392+ pub height: u32,
393+}
394+
395+impl UploadThumbnail {
396+ #[must_use]
397+ pub fn new(bytes: Vec<u8>, mimetype: impl Into<String>, width: u32, height: u32) -> Self {
398+ Self {
399+ bytes,
400+ mimetype: mimetype.into(),
401+ width,
402+ height,
403+ }
404+ }
405+}
406+
407+#[derive(Eq, PartialEq)]
408+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
409+#[non_exhaustive]
410+pub enum EncryptionCommand {
411+ Recover(String),
412+ CreateKeyBackup,
413+ EnableKeyBackup,
414+ DisableKeyBackup { delete_from_server: bool },
415+ RecoverKeyBackup(String),
416+ StartVerification(VerificationTarget),
417+ Verify(VerificationAction),
418+}
419+
420+impl fmt::Debug for EncryptionCommand {
421+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
422+ match self {
423+ Self::Recover(_) => formatter.debug_tuple("Recover").field(&"...").finish(),
424+ Self::CreateKeyBackup => formatter.write_str("CreateKeyBackup"),
425+ Self::EnableKeyBackup => formatter.write_str("EnableKeyBackup"),
426+ Self::DisableKeyBackup { delete_from_server } => formatter
427+ .debug_struct("DisableKeyBackup")
428+ .field("delete_from_server", delete_from_server)
429+ .finish(),
430+ Self::RecoverKeyBackup(_) => formatter
431+ .debug_tuple("RecoverKeyBackup")
432+ .field(&"...")
433+ .finish(),
434+ Self::StartVerification(target) => formatter
435+ .debug_tuple("StartVerification")
436+ .field(target)
437+ .finish(),
438+ Self::Verify(action) => formatter.debug_tuple("Verify").field(action).finish(),
439+ }
440+ }
441+}
442+
443+#[derive(Clone, Debug, Eq, PartialEq)]
444+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
445+#[non_exhaustive]
446+pub enum NotificationCommand {
447+ SetRoomMode {
448+ room_id: OwnedRoomId,
449+ mode: RoomNotificationMode,
450+ },
451+ ResetRoomMode(OwnedRoomId),
452+ SetDefaultMode {
453+ encrypted: bool,
454+ one_to_one: bool,
455+ mode: RoomNotificationMode,
456+ },
457+ AddKeyword(String),
458+ RemoveKeyword(String),
459+}
460+
461+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
462+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
463+#[non_exhaustive]
464+pub enum LifecycleCommand {
465+ Shutdown,
466+}
467+
468+#[derive(Clone, Debug, Eq, PartialEq)]
469+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
470+#[non_exhaustive]
471+pub enum CommandOutput {
472+ Done,
473+ Room(OwnedRoomId),
474+}
475+
476+#[derive(Clone, Debug, Eq, PartialEq)]
477+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
478+#[non_exhaustive]
479+pub enum MessageContent {
480+ Text {
481+ content: RichText,
482+ mentions: MessageMentions,
483+ },
484+ Emote {
485+ content: RichText,
486+ mentions: MessageMentions,
487+ },
488+ Reply {
489+ event_id: OwnedEventId,
490+ content: RichText,
491+ mentions: MessageMentions,
492+ notify_replied_to: bool,
493+ },
494+}
495+
496+impl MessageContent {
497+ #[must_use]
498+ pub fn text(body: impl Into<String>) -> Self {
499+ Self::Text {
500+ content: RichText::plain(body),
501+ mentions: MessageMentions::default(),
502+ }
503+ }
504+
505+ #[must_use]
506+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {
507+ Self::Text {
508+ content: RichText::html(body, formatted_body),
509+ mentions: MessageMentions::default(),
510+ }
511+ }
512+
513+ #[must_use]
514+ pub fn emote(content: RichText, mentions: MessageMentions) -> Self {
515+ Self::Emote { content, mentions }
516+ }
517+
518+ #[must_use]
519+ pub fn reply(event_id: OwnedEventId, content: RichText, mentions: MessageMentions) -> Self {
520+ Self::Reply {
521+ event_id,
522+ content,
523+ mentions,
524+ notify_replied_to: true,
525+ }
526+ }
527+
528+ #[must_use]
529+ pub fn reply_without_notification(
530+ event_id: OwnedEventId,
531+ content: RichText,
532+ mentions: MessageMentions,
533+ ) -> Self {
534+ Self::Reply {
535+ event_id,
536+ content,
537+ mentions,
538+ notify_replied_to: false,
539+ }
540+ }
541+}
542+
543+#[derive(Clone, Debug, Eq, PartialEq)]
544+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
545+#[non_exhaustive]
546+pub enum MessageEditContent {
547+ Text {
548+ content: RichText,
549+ mentions: MessageMentions,
550+ },
551+ Emote {
552+ content: RichText,
553+ mentions: MessageMentions,
554+ },
555+}
556+
557+impl MessageEditContent {
558+ #[must_use]
559+ pub fn text(body: impl Into<String>) -> Self {
560+ Self::Text {
561+ content: RichText::plain(body),
562+ mentions: MessageMentions::default(),
563+ }
564+ }
565+
566+ #[must_use]
567+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {
568+ Self::Text {
569+ content: RichText::html(body, formatted_body),
570+ mentions: MessageMentions::default(),
571+ }
572+ }
573+
574+ #[must_use]
575+ pub fn emote(content: RichText, mentions: MessageMentions) -> Self {
576+ Self::Emote { content, mentions }
577+ }
578+}
579+
580+#[derive(Clone, Debug, Default, Eq, PartialEq)]
581+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
582+#[non_exhaustive]
583+pub struct RichText {
584+ pub body: String,
585+ pub formatted_body: Option<String>,
586+}
587+
588+impl RichText {
589+ #[must_use]
590+ pub fn plain(body: impl Into<String>) -> Self {
591+ Self {
592+ body: body.into(),
593+ formatted_body: None,
594+ }
595+ }
596+
597+ #[must_use]
598+ pub fn html(body: impl Into<String>, formatted_body: impl Into<String>) -> Self {
599+ Self {
600+ body: body.into(),
601+ formatted_body: Some(formatted_body.into()),
602+ }
603+ }
604+}
605+
606+#[derive(Clone, Debug, Default, Eq, PartialEq)]
607+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
608+#[non_exhaustive]
609+pub struct MessageMentions {
610+ pub user_ids: Vec<OwnedUserId>,
611+ pub room: bool,
612+}
613+
614+impl MessageMentions {
615+ #[must_use]
616+ pub fn with_user_ids(user_ids: impl IntoIterator<Item = OwnedUserId>) -> Self {
617+ Self {
618+ user_ids: user_ids.into_iter().collect(),
619+ room: false,
620+ }
621+ }
622+
623+ #[must_use]
624+ pub fn room() -> Self {
625+ Self {
626+ user_ids: Vec::new(),
627+ room: true,
628+ }
629+ }
630+}
631+
632+#[derive(Clone, Debug, Eq, PartialEq)]
633+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
634+#[non_exhaustive]
635+pub enum Event {
636+ ConnectionChanged(ConnectionState),
637+ Authenticated {
638+ user_id: OwnedUserId,
639+ },
640+ /// the tokens rotated. with a `SessionStore` the record is already saved by now,
641+ /// without one persist a fresh `ClientHandle::session_record()` atomically
642+ SessionTokensChanged,
643+ /// the rotated tokens didn't reach the `SessionStore`, so the saved record may not
644+ /// restore. the engine tries again on the next rotation
645+ SessionSaveFailed {
646+ message: String,
647+ },
648+ /// the server stopped honouring our token and the engine stops. a soft logout
649+ /// names the device to log in again as, keeping the store. otherwise the record
650+ /// and the store are both dead
651+ SessionAccessRejected {
652+ soft_logout: Option<OwnedDeviceId>,
653+ },
654+ NotificationSettingsChanged,
655+ VerificationTargetChanged(VerificationTarget),
656+ RoomsChanged,
657+ /// replaces whatever you had for this timeline. every diff after it applies to it
658+ TimelineReset {
659+ timeline_id: TimelineId,
660+ items: Vector<TimelineItem>,
661+ },
662+ TimelineChanged {
663+ timeline_id: TimelineId,
664+ diffs: Vec<TimelineDiff>,
665+ },
666+ HistoryChanged {
667+ timeline_id: TimelineId,
668+ state: HistoryState,
669+ },
670+ /// the timeline was closed, or its room was left, and nothing more comes for it
671+ /// until another `TimelineReset`
672+ TimelineClosed {
673+ timeline_id: TimelineId,
674+ },
675+ /// a room that stops being watched says so with an empty `users`
676+ TypingChanged(TypingState),
677+ MediaUploadProgress {
678+ room_id: OwnedRoomId,
679+ transaction_id: OwnedTransactionId,
680+ uploaded_bytes: u64,
681+ total_bytes: u64,
682+ complete: bool,
683+ },
684+ /// not the end of it. `will_retry` means the send queue has another go by itself,
685+ /// otherwise it waits for `retry_send` or `cancel_send`
686+ MediaUploadFailed {
687+ room_id: OwnedRoomId,
688+ transaction_id: OwnedTransactionId,
689+ kind: crate::ErrorKind,
690+ will_retry: bool,
691+ },
692+ /// an upload that turned up in a progress or failure event gets exactly one of
693+ /// these while the engine runs, and nothing about it after. stopping ends
694+ /// nothing, `Stopped` covers whatever is still going and the sdk resumes it next run
695+ MediaUploadFinished {
696+ room_id: OwnedRoomId,
697+ transaction_id: OwnedTransactionId,
698+ outcome: UploadOutcome,
699+ },
700+ EncryptionChanged(EncryptionState),
701+ VerificationChanged(VerificationState),
702+ /// logout went through and the engine stops. delete the record and the store
703+ LoggedOut,
704+ Failed(EngineFailure),
705+ /// always the last event. keep the saved record unless `LoggedOut` or a
706+ /// hard `SessionAccessRejected` came first. commands still in flight answer `Interrupted`
707+ Stopped,
708+}
709+
710+#[derive(Clone, Debug, Eq, PartialEq)]
711+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
712+#[non_exhaustive]
713+pub enum UploadOutcome {
714+ Sent {
715+ event_id: OwnedEventId,
716+ },
717+ Cancelled,
718+ /// its room was forgotten, and the queue with it
719+ RoomForgotten,
720+ /// the sdk's update channel overflowed while it ended, so whether it was sent is unknown
721+ Missed,
722+}
723+
724+/// why `Engine::run` came back, when nothing went wrong
725+#[derive(Clone, Debug, Eq, PartialEq)]
726+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
727+#[non_exhaustive]
728+pub enum Termination {
729+ ShutDown,
730+ /// every `ClientHandle` was dropped, so nobody could have asked for anything else
731+ HandlesDropped,
732+ LoggedOut,
733+ /// the server rejected our token for good
734+ AccessRevoked,
735+ /// the server wants this device to log in again, and its e2ee keys live in the store
736+ SoftLoggedOut {
737+ device_id: OwnedDeviceId,
738+ },
739+}
740+
741+impl Termination {
742+ #[must_use]
743+ pub const fn retain(&self) -> Retain {
744+ match self {
745+ Self::ShutDown | Self::HandlesDropped => Retain::RecordAndStore,
746+ Self::SoftLoggedOut { .. } => Retain::Store,
747+ Self::LoggedOut | Self::AccessRevoked => Retain::Nothing,
748+ }
749+ }
750+}
751+
752+/// what's still worth keeping of the saved record and the store
753+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
754+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
755+pub enum Retain {
756+ RecordAndStore,
757+ /// keep the store and a record naming the device to log in again as
758+ Store,
759+ Nothing,
760+}
761+
762+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
763+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
764+#[non_exhaustive]
765+pub enum ConnectionState {
766+ Idle,
767+ Connecting,
768+ Online,
769+ Offline,
770+ Stopped,
771+}
772+
773+#[derive(Clone, Debug, Eq, PartialEq)]
774+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
775+#[non_exhaustive]
776+pub struct RoomSummary {
777+ pub room_id: OwnedRoomId,
778+ pub name: String,
779+ pub membership: RoomMembership,
780+ pub unread_messages: u64,
781+ pub unread_mentions: u64,
782+ /// unread events the push rules said to notify about, which is neither of the above
783+ pub unread_notifications: u64,
784+ pub encrypted: bool,
785+ pub space: bool,
786+ pub direct: bool,
787+ pub favourite: bool,
788+ pub low_priority: bool,
789+ pub avatar: Option<OwnedMxcUri>,
790+ pub latest_event_timestamp_ms: Option<u64>,
791+ pub latest_event: Option<LatestEvent>,
792+ pub notification_mode: Option<RoomNotificationMode>,
793+ pub notification_mode_custom: bool,
794+ pub topic: Option<String>,
795+ pub canonical_alias: Option<OwnedRoomAliasId>,
796+ pub joined_members: u64,
797+ pub invited_members: u64,
798+ /// who a nameless room gets named after
799+ pub heroes: Vec<RoomHero>,
800+ /// set once the room has been upgraded, this is where everyone went
801+ pub successor: Option<OwnedRoomId>,
802+ pub inviter: Option<OwnedUserId>,
803+ /// joined spaces that list this room as a child
804+ pub parent_spaces: Vec<OwnedRoomId>,
805+ /// for a space, the children it lists, joined or not
806+ pub children: Vec<OwnedRoomId>,
807+}
808+
809+#[derive(Clone, Debug, Eq, PartialEq)]
810+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
811+#[non_exhaustive]
812+pub struct RoomHero {
813+ pub user_id: OwnedUserId,
814+ pub display_name: Option<String>,
815+ pub avatar: Option<OwnedMxcUri>,
816+}
817+
818+/// enough for a room list preview line. `kind` and `body` are only set for messages,
819+/// and an edit shows its new text
820+#[derive(Clone, Debug, Eq, PartialEq)]
821+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
822+#[non_exhaustive]
823+pub struct LatestEvent {
824+ pub sender: Option<OwnedUserId>,
825+ pub kind: Option<MessageKind>,
826+ pub body: Option<String>,
827+ pub timestamp_ms: Option<u64>,
828+ pub send_state: SendState,
829+}
830+
831+#[derive(Clone, Debug, Eq, PartialEq)]
832+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
833+#[non_exhaustive]
834+pub struct Profile {
835+ pub user_id: OwnedUserId,
836+ pub display_name: Option<String>,
837+ pub avatar: Option<OwnedMxcUri>,
838+}
839+
840+/// one room in a space's hierarchy, as the server describes it, which may be a room you're not in
841+#[derive(Clone, Debug, Eq, PartialEq)]
842+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
843+#[non_exhaustive]
844+pub struct SpaceChild {
845+ pub room_id: OwnedRoomId,
846+ pub name: String,
847+ pub topic: Option<String>,
848+ pub avatar: Option<OwnedMxcUri>,
849+ pub canonical_alias: Option<OwnedRoomAliasId>,
850+ pub joined_members: u64,
851+ pub space: bool,
852+ pub children_count: u64,
853+ pub membership: Option<RoomMembership>,
854+ pub via: Vec<OwnedServerName>,
855+ pub suggested: bool,
856+ pub world_readable: Option<bool>,
857+ pub guest_can_join: bool,
858+}
859+
860+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
861+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
862+#[non_exhaustive]
863+pub enum RoomNotificationMode {
864+ AllMessages,
865+ MentionsAndKeywordsOnly,
866+ Mute,
867+}
868+
869+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
870+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
871+#[non_exhaustive]
872+pub struct RoomNotificationSettings {
873+ pub mode: RoomNotificationMode,
874+ pub custom: bool,
875+}
876+
877+#[derive(Clone, Debug)]
878+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
879+#[non_exhaustive]
880+pub enum PushNotificationStatus {
881+ Event(Box<PushNotification>),
882+ NotFound,
883+ FilteredOut,
884+ Redacted,
885+}
886+
887+#[derive(Clone, Debug)]
888+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
889+#[non_exhaustive]
890+pub struct PushNotification {
891+ pub event_id: OwnedEventId,
892+ pub event: PushNotificationEvent,
893+ pub sender: PushNotificationSender,
894+ pub room: PushNotificationRoom,
895+ pub noisy: Option<bool>,
896+ pub has_mention: Option<bool>,
897+ pub thread_id: Option<OwnedEventId>,
898+}
899+
900+#[derive(Clone, Debug, Eq, PartialEq)]
901+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
902+#[non_exhaustive]
903+pub struct PushNotificationSender {
904+ pub user_id: OwnedUserId,
905+ pub display_name: Option<String>,
906+ pub avatar_url: Option<OwnedMxcUri>,
907+ pub name_ambiguous: bool,
908+}
909+
910+#[derive(Clone, Debug, Eq, PartialEq)]
911+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
912+#[non_exhaustive]
913+pub struct PushNotificationRoom {
914+ pub room_id: OwnedRoomId,
915+ pub name: String,
916+ pub avatar_url: Option<OwnedMxcUri>,
917+ pub canonical_alias: Option<OwnedRoomAliasId>,
918+ pub topic: Option<String>,
919+ pub encrypted: Option<bool>,
920+ pub direct: bool,
921+ pub joined_members_count: u64,
922+ pub service_members: Vec<OwnedUserId>,
923+ pub active_service_members_count: u64,
924+ pub space: bool,
925+}
926+
927+#[derive(Clone, Debug)]
928+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
929+#[non_exhaustive]
930+pub enum PushNotificationEvent {
931+ Timeline(Raw<AnySyncTimelineEvent>),
932+ Invite(Raw<StrippedRoomMemberEvent>),
933+}
934+
935+#[derive(Clone, Debug, Eq, PartialEq)]
936+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
937+#[non_exhaustive]
938+pub struct RoomPowerLevels {
939+ pub ban: i64,
940+ pub invite: i64,
941+ pub kick: i64,
942+ pub redact: i64,
943+ pub events_default: i64,
944+ pub state_default: i64,
945+ pub users_default: i64,
946+ pub room_notification: i64,
947+ pub users: Vec<(OwnedUserId, i64)>,
948+ pub events: Vec<(String, i64)>,
949+ /// in rooms from version 12 the creators outrank everyone and aren't listed in `users`
950+ pub creators: Vec<OwnedUserId>,
951+}
952+
953+#[derive(Clone, Debug, Default, Eq, PartialEq)]
954+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
955+#[non_exhaustive]
956+pub struct PowerLevelChanges {
957+ pub ban: Option<i64>,
958+ pub invite: Option<i64>,
959+ pub kick: Option<i64>,
960+ pub redact: Option<i64>,
961+ pub events_default: Option<i64>,
962+ pub state_default: Option<i64>,
963+ pub users_default: Option<i64>,
964+ pub room_notification: Option<i64>,
965+ pub room_name: Option<i64>,
966+ pub room_avatar: Option<i64>,
967+ pub room_topic: Option<i64>,
968+ pub space_child: Option<i64>,
969+ pub beacon: Option<i64>,
970+ pub beacon_info: Option<i64>,
971+}
972+
973+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
974+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
975+#[non_exhaustive]
976+pub enum RoomMembership {
977+ Joined,
978+ Invited,
979+ Left,
980+ Knocked,
981+ Banned,
982+}
983+
984+#[derive(Clone, Debug, Default, Eq, PartialEq)]
985+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
986+#[non_exhaustive]
987+pub struct RoomListFilter {
988+ pub membership: Option<RoomMembership>,
989+ pub category: Option<RoomCategory>,
990+ pub favourite: Option<bool>,
991+ pub unread_only: bool,
992+ pub search: Option<String>,
993+}
994+
995+impl RoomListFilter {
996+ #[must_use]
997+ pub fn with_membership(mut self, membership: RoomMembership) -> Self {
998+ self.membership = Some(membership);
999+ self
1000+ }
1001+
1002+ #[must_use]
1003+ pub fn with_category(mut self, category: RoomCategory) -> Self {
1004+ self.category = Some(category);
1005+ self
1006+ }
1007+
1008+ #[must_use]
1009+ pub fn with_favourite(mut self, favourite: bool) -> Self {
1010+ self.favourite = Some(favourite);
1011+ self
1012+ }
1013+
1014+ #[must_use]
1015+ pub const fn with_unread_only(mut self, unread_only: bool) -> Self {
1016+ self.unread_only = unread_only;
1017+ self
1018+ }
1019+
1020+ #[must_use]
1021+ pub fn with_search(mut self, search: impl Into<String>) -> Self {
1022+ self.search = Some(search.into());
1023+ self
1024+ }
1025+
1026+ #[must_use]
1027+ pub fn matches(&self, room: &RoomSummary) -> bool {
1028+ self.matcher().matches(room)
1029+ }
1030+
1031+ /// lowercases the search once, for filtering a whole list
1032+ #[must_use]
1033+ pub fn matcher(&self) -> RoomMatcher<'_> {
1034+ RoomMatcher {
1035+ filter: self,
1036+ search: self.search.as_deref().map(str::to_lowercase),
1037+ }
1038+ }
1039+}
1040+
1041+#[derive(Debug)]
1042+pub struct RoomMatcher<'a> {
1043+ filter: &'a RoomListFilter,
1044+ search: Option<String>,
1045+}
1046+
1047+impl RoomMatcher<'_> {
1048+ #[must_use]
1049+ pub fn matches(&self, room: &RoomSummary) -> bool {
1050+ let filter = self.filter;
1051+ filter
1052+ .membership
1053+ .is_none_or(|membership| room.membership == membership)
1054+ && filter.category.is_none_or(|category| match category {
1055+ RoomCategory::People => room.direct && !room.space,
1056+ RoomCategory::Rooms => !room.direct && !room.space,
1057+ RoomCategory::Spaces => room.space,
1058+ })
1059+ && filter
1060+ .favourite
1061+ .is_none_or(|favourite| room.favourite == favourite)
1062+ && (!filter.unread_only || room.unread_messages > 0 || room.unread_mentions > 0)
1063+ && self.search.as_ref().is_none_or(|search| {
1064+ room.name.to_lowercase().contains(search)
1065+ || room.room_id.as_str().to_lowercase().contains(search)
1066+ })
1067+ }
1068+}
1069+
1070+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1071+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1072+#[non_exhaustive]
1073+pub enum RoomCategory {
1074+ People,
1075+ Rooms,
1076+ Spaces,
1077+}
1078+
1079+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1080+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1081+#[non_exhaustive]
1082+pub enum MemberFilter {
1083+ Active,
1084+ Joined,
1085+ Invited,
1086+ Banned,
1087+ Left,
1088+ All,
1089+}
1090+
1091+#[derive(Clone, Debug, Eq, PartialEq)]
1092+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1093+#[non_exhaustive]
1094+pub enum MemberMembership {
1095+ Joined,
1096+ Invited,
1097+ Left,
1098+ Knocked,
1099+ Banned,
1100+ Custom(String),
1101+}
1102+
1103+#[derive(Clone, Debug, Eq, PartialEq)]
1104+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1105+#[non_exhaustive]
1106+pub struct MemberSummary {
1107+ pub user_id: OwnedUserId,
1108+ pub name: String,
1109+ pub display_name: Option<String>,
1110+ pub avatar: Option<OwnedMxcUri>,
1111+ pub membership: MemberMembership,
1112+ pub own: bool,
1113+ pub name_ambiguous: bool,
1114+ pub ignored: bool,
1115+ pub service: bool,
1116+}
1117+
1118+#[derive(Clone, Debug, Eq, PartialEq)]
1119+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1120+#[non_exhaustive]
1121+pub struct OwnDevice {
1122+ pub device_id: OwnedDeviceId,
1123+ pub display_name: Option<String>,
1124+ pub last_seen_ip: Option<String>,
1125+ pub last_seen_timestamp_ms: Option<u64>,
1126+ pub current: bool,
1127+}
1128+
1129+#[derive(Clone, Debug, Eq, PartialEq)]
1130+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1131+#[non_exhaustive]
1132+pub enum TimelineItem {
1133+ Event(Box<TimelineEvent>),
1134+ DayDivider { timestamp_ms: u64 },
1135+ ReadMarker,
1136+ TimelineStart,
1137+}
1138+
1139+#[derive(Clone, Debug, Eq, PartialEq)]
1140+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1141+#[non_exhaustive]
1142+pub enum TimelineDiff {
1143+ Append { values: Vec<TimelineItem> },
1144+ Clear,
1145+ PushFront { value: TimelineItem },
1146+ PushBack { value: TimelineItem },
1147+ PopFront,
1148+ PopBack,
1149+ Insert { index: usize, value: TimelineItem },
1150+ Set { index: usize, value: TimelineItem },
1151+ Remove { index: usize },
1152+ Truncate { length: usize },
1153+ Reset { values: Vec<TimelineItem> },
1154+}
1155+
1156+#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
1157+#[error("timeline diff is incompatible with the current {length}-item snapshot")]
1158+pub struct TimelineDiffError {
1159+ pub length: usize,
1160+}
1161+
1162+impl TimelineDiff {
1163+ /// Apply a diff only when its indices match the current snapshot.
1164+ /// On error the snapshot is unchanged; request `TimelineCommand::Resync`
1165+ /// and ignore further diffs until the corresponding `TimelineReset`.
1166+ pub fn try_apply(self, items: &mut Vector<TimelineItem>) -> Result<(), TimelineDiffError> {
1167+ let length = items.len();
1168+ if self.length_after(length).is_none() {
1169+ return Err(TimelineDiffError { length });
1170+ }
1171+ self.apply(items);
1172+ Ok(())
1173+ }
1174+
1175+ /// a whole `TimelineChanged` or none of it. whether a diff fits depends only on the
1176+ /// length before it, so the batch is checked against lengths alone before anything
1177+ /// moves. the error carries the length the snapshot would have had at the misfit
1178+ pub fn try_apply_all(
1179+ diffs: Vec<Self>,
1180+ items: &mut Vector<TimelineItem>,
1181+ ) -> Result<(), TimelineDiffError> {
1182+ diffs.iter().try_fold(items.len(), |length, diff| {
1183+ diff.length_after(length)
1184+ .ok_or(TimelineDiffError { length })
1185+ })?;
1186+ for diff in diffs {
1187+ diff.apply(items);
1188+ }
1189+ Ok(())
1190+ }
1191+
1192+ fn length_after(&self, length: usize) -> Option<usize> {
1193+ match self {
1194+ Self::Append { values } => Some(length + values.len()),
1195+ Self::Clear => Some(0),
1196+ Self::PushFront { .. } | Self::PushBack { .. } => Some(length + 1),
1197+ Self::PopFront | Self::PopBack => length.checked_sub(1),
1198+ Self::Insert { index, .. } => (*index <= length).then_some(length + 1),
1199+ Self::Set { index, .. } => (*index < length).then_some(length),
1200+ Self::Remove { index } => (*index < length).then(|| length - 1),
1201+ Self::Truncate { length: target } => (*target <= length).then_some(*target),
1202+ Self::Reset { values } => Some(values.len()),
1203+ }
1204+ }
1205+
1206+ fn apply(self, items: &mut Vector<TimelineItem>) {
1207+ match self {
1208+ Self::Append { values } => items.extend(values),
1209+ Self::Clear => items.clear(),
1210+ Self::PushFront { value } => items.push_front(value),
1211+ Self::PushBack { value } => items.push_back(value),
1212+ Self::PopFront => {
1213+ items.pop_front();
1214+ }
1215+ Self::PopBack => {
1216+ items.pop_back();
1217+ }
1218+ Self::Insert { index, value } => items.insert(index, value),
1219+ Self::Set { index, value } => {
1220+ items.set(index, value);
1221+ }
1222+ Self::Remove { index } => {
1223+ items.remove(index);
1224+ }
1225+ Self::Truncate { length } => items.truncate(length),
1226+ Self::Reset { values } => *items = values.into_iter().collect(),
1227+ }
1228+ }
1229+}
1230+
1231+#[cfg(test)]
1232+mod tests {
1233+ use imbl::{Vector, vector};
1234+
1235+ use super::{TimelineDiff, TimelineItem};
1236+
1237+ fn item(timestamp_ms: u64) -> TimelineItem {
1238+ TimelineItem::DayDivider { timestamp_ms }
1239+ }
1240+
1241+ #[test]
1242+ fn timeline_diff_try_apply_handles_every_variant() {
1243+ let mut items = vector![item(1), item(2)];
1244+
1245+ TimelineDiff::Append {
1246+ values: vec![item(3)],
1247+ }
1248+ .try_apply(&mut items)
1249+ .unwrap();
1250+ assert_eq!(items, vector![item(1), item(2), item(3)]);
1251+
1252+ TimelineDiff::Clear.try_apply(&mut items).unwrap();
1253+ assert!(items.is_empty());
1254+
1255+ TimelineDiff::PushFront { value: item(2) }
1256+ .try_apply(&mut items)
1257+ .unwrap();
1258+ TimelineDiff::PushBack { value: item(3) }
1259+ .try_apply(&mut items)
1260+ .unwrap();
1261+ assert_eq!(items, vector![item(2), item(3)]);
1262+
1263+ TimelineDiff::PopFront.try_apply(&mut items).unwrap();
1264+ TimelineDiff::PopBack.try_apply(&mut items).unwrap();
1265+ assert!(items.is_empty());
1266+
1267+ TimelineDiff::Reset {
1268+ values: vec![item(1), item(3)],
1269+ }
1270+ .try_apply(&mut items)
1271+ .unwrap();
1272+ TimelineDiff::Insert {
1273+ index: 1,
1274+ value: item(2),
1275+ }
1276+ .try_apply(&mut items)
1277+ .unwrap();
1278+ assert_eq!(items, vector![item(1), item(2), item(3)]);
1279+
1280+ TimelineDiff::Set {
1281+ index: 1,
1282+ value: item(4),
1283+ }
1284+ .try_apply(&mut items)
1285+ .unwrap();
1286+ assert_eq!(items, vector![item(1), item(4), item(3)]);
1287+
1288+ TimelineDiff::Remove { index: 1 }
1289+ .try_apply(&mut items)
1290+ .unwrap();
1291+ assert_eq!(items, vector![item(1), item(3)]);
1292+
1293+ TimelineDiff::Truncate { length: 1 }
1294+ .try_apply(&mut items)
1295+ .unwrap();
1296+ assert_eq!(items, vector![item(1)]);
1297+ }
1298+
1299+ #[test]
1300+ fn invalid_timeline_diff_leaves_snapshot_unchanged() {
1301+ let invalid = [
1302+ TimelineDiff::Insert {
1303+ index: 3,
1304+ value: item(3),
1305+ },
1306+ TimelineDiff::Set {
1307+ index: 2,
1308+ value: item(3),
1309+ },
1310+ TimelineDiff::Remove { index: 2 },
1311+ TimelineDiff::Truncate { length: 3 },
1312+ ];
1313+ for diff in invalid {
1314+ let mut items = vector![item(1), item(2)];
1315+ assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 2);
1316+ assert_eq!(items, vector![item(1), item(2)]);
1317+ }
1318+ for diff in [TimelineDiff::PopFront, TimelineDiff::PopBack] {
1319+ let mut items = Vector::new();
1320+ assert_eq!(diff.try_apply(&mut items).unwrap_err().length, 0);
1321+ assert!(items.is_empty());
1322+ }
1323+ }
1324+
1325+ #[test]
1326+ fn a_batch_that_goes_wrong_halfway_changes_nothing() {
1327+ let mut items = vector![item(1), item(2)];
1328+ let batch = vec![
1329+ TimelineDiff::PushBack { value: item(3) },
1330+ TimelineDiff::PushBack { value: item(4) },
1331+ TimelineDiff::Remove { index: 0 },
1332+ TimelineDiff::Set {
1333+ index: 3,
1334+ value: item(5),
1335+ },
1336+ ];
1337+ assert_eq!(
1338+ TimelineDiff::try_apply_all(batch, &mut items)
1339+ .unwrap_err()
1340+ .length,
1341+ 3
1342+ );
1343+ assert_eq!(items, vector![item(1), item(2)]);
1344+
1345+ let batch = vec![
1346+ TimelineDiff::PushBack { value: item(3) },
1347+ TimelineDiff::Remove { index: 0 },
1348+ TimelineDiff::Set {
1349+ index: 1,
1350+ value: item(4),
1351+ },
1352+ ];
1353+ TimelineDiff::try_apply_all(batch, &mut items).unwrap();
1354+ assert_eq!(items, vector![item(2), item(4)]);
1355+ }
1356+}
1357+
1358+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1359+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1360+pub struct TimelineItemId(String);
1361+
1362+impl TimelineItemId {
1363+ pub(crate) fn new(value: String) -> Self {
1364+ Self(value)
1365+ }
1366+
1367+ #[must_use]
1368+ pub fn as_str(&self) -> &str {
1369+ &self.0
1370+ }
1371+}
1372+
1373+#[derive(Clone, Debug, Eq, PartialEq)]
1374+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1375+#[non_exhaustive]
1376+pub struct TimelineEvent {
1377+ pub item_id: TimelineItemId,
1378+ pub event_id: Option<OwnedEventId>,
1379+ pub transaction_id: Option<OwnedTransactionId>,
1380+ pub sender: Sender,
1381+ pub timestamp_ms: u64,
1382+ pub own: bool,
1383+ pub send_state: SendState,
1384+ pub content: TimelineContent,
1385+ pub reply_to: Option<ReplyReference>,
1386+ pub thread_root: Option<OwnedEventId>,
1387+ pub thread_summary: Option<ThreadSummary>,
1388+ pub reactions: Vec<Reaction>,
1389+ pub read_by: Vec<OwnedUserId>,
1390+ pub editable: bool,
1391+}
1392+
1393+impl TimelineEvent {
1394+ /// what to hand edit, redact and react. works before the server has echoed it back
1395+ #[must_use]
1396+ pub fn target(&self) -> Option<EventRef> {
1397+ self.event_id
1398+ .clone()
1399+ .map(EventRef::Remote)
1400+ .or_else(|| self.transaction_id.clone().map(EventRef::Local))
1401+ }
1402+}
1403+
1404+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1405+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1406+#[non_exhaustive]
1407+pub enum EventRef {
1408+ Remote(OwnedEventId),
1409+ /// a local echo, by the transaction id it was queued under
1410+ Local(OwnedTransactionId),
1411+}
1412+
1413+impl From<OwnedEventId> for EventRef {
1414+ fn from(event_id: OwnedEventId) -> Self {
1415+ Self::Remote(event_id)
1416+ }
1417+}
1418+
1419+#[derive(Clone, Debug, Eq, PartialEq)]
1420+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1421+#[non_exhaustive]
1422+pub struct Sender {
1423+ pub user_id: OwnedUserId,
1424+ pub display_name: Option<String>,
1425+ pub avatar: Option<OwnedMxcUri>,
1426+ /// someone else in the room goes by the same name, so show the user id too
1427+ pub name_ambiguous: bool,
1428+}
1429+
1430+impl Sender {
1431+ #[must_use]
1432+ pub fn name(&self) -> &str {
1433+ self.display_name
1434+ .as_deref()
1435+ .unwrap_or_else(|| self.user_id.as_str())
1436+ }
1437+}
1438+
1439+#[derive(Clone, Debug, Eq, PartialEq)]
1440+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1441+#[non_exhaustive]
1442+pub enum TimelineContent {
1443+ Message(Message),
1444+ Sticker {
1445+ body: String,
1446+ media: MediaDescriptor,
1447+ },
1448+ Poll {
1449+ question: String,
1450+ },
1451+ Redacted,
1452+ UnableToDecrypt(UtdCause),
1453+ Membership {
1454+ user_id: OwnedUserId,
1455+ display_name: Option<String>,
1456+ change: MembershipChange,
1457+ },
1458+ Profile {
1459+ user_id: OwnedUserId,
1460+ display_name: Option<Change<Option<String>>>,
1461+ avatar: Option<Change<Option<OwnedMxcUri>>>,
1462+ },
1463+ /// `None` means it was cleared, or the event was redacted
1464+ RoomName(Option<String>),
1465+ RoomTopic(Option<String>),
1466+ RoomAvatar(Option<OwnedMxcUri>),
1467+ EncryptionEnabled,
1468+ RoomCreated,
1469+ RoomUpgraded {
1470+ successor: OwnedRoomId,
1471+ },
1472+ OtherState {
1473+ event_type: String,
1474+ state_key: String,
1475+ },
1476+ Other {
1477+ event_type: String,
1478+ },
1479+}
1480+
1481+#[derive(Clone, Debug, Eq, PartialEq)]
1482+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1483+#[non_exhaustive]
1484+pub struct Message {
1485+ pub kind: MessageKind,
1486+ pub body: String,
1487+ /// only for `MessageKind::Location`
1488+ pub geo_uri: Option<String>,
1489+ pub formatted_body: Option<String>,
1490+ pub mentions: Option<MessageMentions>,
1491+ pub edited: bool,
1492+ pub media: Option<MediaDescriptor>,
1493+}
1494+
1495+#[derive(Clone, Debug, Eq, PartialEq)]
1496+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1497+#[non_exhaustive]
1498+pub enum MessageKind {
1499+ Text,
1500+ Emote,
1501+ Notice,
1502+ ServerNotice,
1503+ Image,
1504+ Video,
1505+ Audio,
1506+ File,
1507+ Location,
1508+ VerificationRequest,
1509+ Other(String),
1510+}
1511+
1512+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1513+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1514+#[non_exhaustive]
1515+pub enum MembershipChange {
1516+ Joined,
1517+ Left,
1518+ Banned,
1519+ Unbanned,
1520+ Kicked,
1521+ KickedAndBanned,
1522+ Invited,
1523+ InvitationAccepted,
1524+ InvitationRejected,
1525+ InvitationRevoked,
1526+ Knocked,
1527+ KnockAccepted,
1528+ KnockRetracted,
1529+ KnockDenied,
1530+ /// nothing a person would call a change, or something the sdk couldn't make sense of
1531+ Other,
1532+}
1533+
1534+#[derive(Clone, Debug, Eq, PartialEq)]
1535+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1536+pub struct Change<T> {
1537+ pub old: T,
1538+ pub new: T,
1539+}
1540+
1541+/// why a message couldn't be read, worth telling the user since some of these they can fix
1542+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1543+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1544+#[non_exhaustive]
1545+pub enum UtdCause {
1546+ Unknown,
1547+ SentBeforeWeJoined,
1548+ VerificationViolation,
1549+ UnsignedDevice,
1550+ UnknownDevice,
1551+ HistoricalMessageAndBackupIsDisabled,
1552+ WithheldForUnverifiedOrInsecureDevice,
1553+ WithheldBySender,
1554+ HistoricalMessageAndDeviceIsUnverified,
1555+}
1556+
1557+#[derive(Clone, Debug, Eq, PartialEq)]
1558+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1559+#[non_exhaustive]
1560+pub struct ThreadSummary {
1561+ pub reply_count: u32,
1562+ pub latest_event_id: Option<OwnedEventId>,
1563+ pub latest_event_timestamp_ms: Option<u64>,
1564+}
1565+
1566+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1567+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1568+#[non_exhaustive]
1569+pub enum SendState {
1570+ Sent,
1571+ Sending,
1572+ /// `will_retry` means macltok has it in hand. otherwise it's wedged until you
1573+ /// `retry_send` or `cancel_send`, so that's when to show a retry button
1574+ Failed {
1575+ will_retry: bool,
1576+ },
1577+}
1578+
1579+#[derive(Clone, Debug, Eq, PartialEq)]
1580+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1581+#[non_exhaustive]
1582+pub struct ReplyReference {
1583+ pub event_id: OwnedEventId,
1584+ pub details: ReplyDetails,
1585+}
1586+
1587+/// the replied-to event. macltok asks for it when it isn't loaded, `Pending` then turns into one of the others
1588+#[derive(Clone, Debug, Eq, PartialEq)]
1589+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1590+#[non_exhaustive]
1591+pub enum ReplyDetails {
1592+ Pending,
1593+ Ready {
1594+ sender: Sender,
1595+ content: Box<TimelineContent>,
1596+ timestamp_ms: u64,
1597+ },
1598+ Unavailable,
1599+}
1600+
1601+#[derive(Clone, Debug, Eq, PartialEq)]
1602+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1603+#[non_exhaustive]
1604+pub struct Reaction {
1605+ pub key: String,
1606+ pub count: usize,
1607+ pub own: bool,
1608+ pub senders: Vec<OwnedUserId>,
1609+}
1610+
1611+#[derive(Clone, Debug, Eq, PartialEq)]
1612+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1613+#[non_exhaustive]
1614+pub struct MediaDescriptor {
1615+ pub id: MediaId,
1616+ pub kind: MediaKind,
1617+ pub filename: String,
1618+ pub caption: Option<String>,
1619+ pub mimetype: Option<String>,
1620+ pub size_bytes: Option<u64>,
1621+ pub width: Option<u64>,
1622+ pub height: Option<u64>,
1623+ pub duration_ms: Option<u64>,
1624+ pub blurhash: Option<String>,
1625+ /// the uploader sent a thumbnail, so `MediaSize::Thumbnail` won't need the server to resize
1626+ pub has_thumbnail: bool,
1627+}
1628+
1629+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1630+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1631+pub struct MediaId(String);
1632+
1633+impl MediaId {
1634+ pub(crate) fn new(value: String) -> Self {
1635+ Self(value)
1636+ }
1637+
1638+ #[must_use]
1639+ pub fn as_str(&self) -> &str {
1640+ &self.0
1641+ }
1642+}
1643+
1644+/// media ids are opaque but they do have to survive a trip through a browser. a made up one just fails `MediaUnknown`
1645+impl std::str::FromStr for MediaId {
1646+ type Err = std::convert::Infallible;
1647+
1648+ fn from_str(value: &str) -> Result<Self, Self::Err> {
1649+ Ok(Self(value.to_owned()))
1650+ }
1651+}
1652+
1653+impl From<String> for MediaId {
1654+ fn from(value: String) -> Self {
1655+ Self(value)
1656+ }
1657+}
1658+
1659+impl std::fmt::Display for MediaId {
1660+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1661+ self.0.fmt(formatter)
1662+ }
1663+}
1664+
1665+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1666+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1667+#[non_exhaustive]
1668+pub enum MediaSize {
1669+ Full,
1670+ /// a hint. an uploader's own thumbnail wins over the server resizing anything
1671+ Thumbnail {
1672+ width: u32,
1673+ height: u32,
1674+ },
1675+}
1676+
1677+#[derive(Clone, Debug, Eq, PartialEq)]
1678+#[non_exhaustive]
1679+pub struct Media {
1680+ pub bytes: Arc<[u8]>,
1681+ pub mimetype: String,
1682+}
1683+
1684+/// where media lives, for a host that fetches and decrypts it itself
1685+#[derive(Clone, Debug, Eq, PartialEq)]
1686+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1687+#[non_exhaustive]
1688+pub enum MediaLocation {
1689+ File(MediaFile),
1690+ /// a resize of plaintext, so never encrypted. plenty of servers refuse some, and `media`
1691+ /// then fetches `fallback` instead, so should you
1692+ ServerThumbnail {
1693+ url: String,
1694+ fallback: MediaFile,
1695+ },
1696+ /// still in the send queue with no url yet. `media` has the bytes
1697+ Local,
1698+}
1699+
1700+#[derive(Clone, Debug, Eq, PartialEq)]
1701+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1702+#[non_exhaustive]
1703+pub struct MediaFile {
1704+ pub url: String,
1705+ pub mimetype: Option<String>,
1706+ pub declared_size: Option<u64>,
1707+ pub encryption: Option<AttachmentEncryption>,
1708+}
1709+
1710+/// AES-256-CTR, the only attachment cipher matrix has
1711+#[derive(Clone, Debug, Eq, PartialEq)]
1712+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1713+#[non_exhaustive]
1714+pub struct AttachmentEncryption {
1715+ /// the JWK `k`, unpadded url-safe base64
1716+ pub key: String,
1717+ /// unpadded base64, the initial counter block
1718+ pub iv: String,
1719+ /// unpadded base64 SHA-256 of the whole ciphertext, so a partial read goes unchecked
1720+ pub sha256: String,
1721+}
1722+
1723+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1724+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1725+#[non_exhaustive]
1726+pub enum MediaKind {
1727+ Image,
1728+ Video,
1729+ Audio,
1730+ File,
1731+ Sticker,
1732+}
1733+
1734+#[derive(Clone, Debug, Eq, PartialEq)]
1735+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1736+#[non_exhaustive]
1737+pub struct TypingState {
1738+ pub room_id: OwnedRoomId,
1739+ pub users: Vec<OwnedUserId>,
1740+}
1741+
1742+#[derive(Clone, Debug, Eq, PartialEq)]
1743+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1744+#[non_exhaustive]
1745+pub enum HistoryState {
1746+ Idle,
1747+ Loading,
1748+ StartReached,
1749+ Failed(String),
1750+}
1751+
1752+#[derive(Clone, Debug, Eq, PartialEq)]
1753+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1754+#[non_exhaustive]
1755+pub struct EncryptionState {
1756+ pub device_id: OwnedDeviceId,
1757+ pub device_verified: bool,
1758+ pub recovery: RecoveryState,
1759+ pub key_backup: KeyBackupState,
1760+}
1761+
1762+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1763+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1764+#[non_exhaustive]
1765+pub enum KeyBackupState {
1766+ Unknown,
1767+ Creating,
1768+ Enabling,
1769+ Resuming,
1770+ Downloading,
1771+ Enabled,
1772+ Disabling,
1773+}
1774+
1775+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1776+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1777+#[non_exhaustive]
1778+pub enum RecoveryState {
1779+ Unknown,
1780+ Disabled,
1781+ Incomplete,
1782+ Enabled,
1783+}
1784+
1785+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1786+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1787+#[non_exhaustive]
1788+pub enum VerificationAction {
1789+ /// take up a request another device sent, which arrives as `VerificationState::Incoming`
1790+ Accept,
1791+ Confirm,
1792+ Reject,
1793+ Cancel,
1794+}
1795+
1796+#[derive(Clone, Debug, Eq, PartialEq)]
1797+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1798+#[non_exhaustive]
1799+pub enum VerificationTarget {
1800+ OwnIdentity,
1801+ User(OwnedUserId),
1802+ Device {
1803+ user_id: OwnedUserId,
1804+ device_id: OwnedDeviceId,
1805+ },
1806+}
1807+
1808+#[derive(Clone, Debug, Eq, PartialEq)]
1809+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1810+#[non_exhaustive]
1811+pub enum VerificationState {
1812+ Waiting,
1813+ /// someone else asked. `Accept` to go ahead, `Cancel` to turn them down
1814+ Incoming {
1815+ user_id: OwnedUserId,
1816+ device_id: Option<OwnedDeviceId>,
1817+ },
1818+ Comparison {
1819+ emojis: Vec<(String, String)>,
1820+ decimals: (u16, u16, u16),
1821+ },
1822+ Confirmed,
1823+ Done,
1824+ Cancelled(String),
1825+ Failed(VerificationFailure),
1826+}
1827+
1828+#[derive(Clone, Debug, Eq, PartialEq)]
1829+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1830+#[non_exhaustive]
1831+pub enum VerificationFailure {
1832+ RequestAcceptance(String),
1833+ SasStartup(String),
1834+ SasAcceptance(String),
1835+ RequestStreamEnded,
1836+ SasStreamEnded,
1837+}
1838+
1839+#[derive(Clone, Debug, Eq, PartialEq)]
1840+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1841+#[non_exhaustive]
1842+pub struct EngineFailure {
1843+ pub kind: EngineErrorKind,
1844+ pub message: String,
1845+}
1846+
1847+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1848+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1849+#[non_exhaustive]
1850+pub enum EngineErrorKind {
1851+ EventReceiverClosed,
1852+ Watcher,
1853+ Other,
1854+}
Added file: vendor/macltok/src/normalise/media.rs+328 additions
@@ -0,0 +1,328 @@
1+use std::sync::atomic::{AtomicU64, Ordering};
2+
3+use matrix_sdk::{media::MediaEventContent, ruma::events::room::MediaSource};
4+use matrix_sdk_ui::timeline::{MsgLikeKind, TimelineItem as SdkTimelineItem, TimelineItemContent};
5+use ruma::events::{
6+ room::{
7+ ImageInfo, ThumbnailInfo,
8+ message::{FileInfo, MessageType, VideoInfo},
9+ },
10+ sticker::StickerEventContent,
11+};
12+
13+use crate::{MediaDescriptor, MediaId, MediaKind};
14+
15+static NEXT_MEDIA_SCOPE: AtomicU64 = AtomicU64::new(1);
16+
17+/// Per-timeline-session nonce namespacing recycled SDK unique IDs.
18+///
19+/// SDK 0.18 recycles [`SdkTimelineItem::unique_id`] across the local-to-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+ pub(crate) fn owns(&self, id: &MediaId) -> bool {
36+ id.as_str()
37+ .split_once('#')
38+ .is_some_and(|(nonce, _)| nonce.parse() == Ok(self.nonce))
39+ }
40+}
41+
42+#[derive(Clone)]
43+pub(crate) struct MediaRegistration {
44+ pub(crate) id: MediaId,
45+ pub(crate) kind: MediaKind,
46+ pub(crate) full: MediaSourceInfo,
47+ pub(crate) thumbnail: Option<MediaSourceInfo>,
48+}
49+
50+#[derive(Clone)]
51+pub(crate) struct MediaSourceInfo {
52+ pub(crate) source: MediaSource,
53+ pub(crate) mimetype: Option<String>,
54+ pub(crate) declared_size: Option<u64>,
55+}
56+
57+pub(super) fn media_registration_for_item(
58+ item: &SdkTimelineItem,
59+ scope: &MediaScope,
60+) -> Option<MediaRegistration> {
61+ let event = item.as_event()?;
62+ let identity = media_identity(item, scope);
63+ let content = match event.content() {
64+ TimelineItemContent::MsgLike(content) => content,
65+ _ => return None,
66+ };
67+ match &content.kind {
68+ MsgLikeKind::Message(message) => media_registration(&identity, message.msgtype()),
69+ MsgLikeKind::Sticker(sticker) => media_sticker_registration(&identity, sticker.content()),
70+ _ => None,
71+ }
72+}
73+
74+pub(super) fn media_identity(item: &SdkTimelineItem, scope: &MediaScope) -> String {
75+ format!("{}#{}", scope.nonce, item.unique_id().0)
76+}
77+
78+pub(super) fn media_from_message(identity: &str, message: &MessageType) -> Option<MediaDescriptor> {
79+ let descriptor = |kind, filename: &str, caption: Option<&str>| MediaDescriptor {
80+ id: media_id(identity, "media"),
81+ kind,
82+ filename: filename.to_owned(),
83+ caption: caption.map(str::to_owned),
84+ mimetype: None,
85+ size_bytes: None,
86+ width: None,
87+ height: None,
88+ duration_ms: None,
89+ blurhash: None,
90+ has_thumbnail: false,
91+ };
92+ let millis = |duration: std::time::Duration| u64::try_from(duration.as_millis()).ok();
93+ Some(match message {
94+ MessageType::Image(content) => {
95+ let info = content.info.as_deref();
96+ MediaDescriptor {
97+ mimetype: info.and_then(|info| info.mimetype.clone()),
98+ size_bytes: info.and_then(|info| info.size).map(u64::from),
99+ width: info.and_then(|info| info.width).map(u64::from),
100+ height: info.and_then(|info| info.height).map(u64::from),
101+ blurhash: info.and_then(|info| info.blurhash.clone()),
102+ has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),
103+ ..descriptor(MediaKind::Image, content.filename(), content.caption())
104+ }
105+ }
106+ MessageType::Video(content) => {
107+ let info = content.info.as_deref();
108+ MediaDescriptor {
109+ mimetype: info.and_then(|info| info.mimetype.clone()),
110+ size_bytes: info.and_then(|info| info.size).map(u64::from),
111+ width: info.and_then(|info| info.width).map(u64::from),
112+ height: info.and_then(|info| info.height).map(u64::from),
113+ duration_ms: info.and_then(|info| info.duration).and_then(millis),
114+ blurhash: info.and_then(|info| info.blurhash.clone()),
115+ has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),
116+ ..descriptor(MediaKind::Video, content.filename(), content.caption())
117+ }
118+ }
119+ MessageType::Audio(content) => {
120+ let info = content.info.as_deref();
121+ MediaDescriptor {
122+ mimetype: info.and_then(|info| info.mimetype.clone()),
123+ size_bytes: info.and_then(|info| info.size).map(u64::from),
124+ duration_ms: info.and_then(|info| info.duration).and_then(millis),
125+ ..descriptor(MediaKind::Audio, content.filename(), content.caption())
126+ }
127+ }
128+ MessageType::File(content) => {
129+ let info = content.info.as_deref();
130+ MediaDescriptor {
131+ mimetype: info.and_then(|info| info.mimetype.clone()),
132+ size_bytes: info.and_then(|info| info.size).map(u64::from),
133+ has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),
134+ ..descriptor(MediaKind::File, content.filename(), content.caption())
135+ }
136+ }
137+ _ => return None,
138+ })
139+}
140+
141+pub(super) fn media_descriptor_for_sticker(
142+ identity: &str,
143+ content: &StickerEventContent,
144+) -> MediaDescriptor {
145+ MediaDescriptor {
146+ id: media_id(identity, "sticker"),
147+ kind: MediaKind::Sticker,
148+ filename: content.body.clone(),
149+ caption: None,
150+ mimetype: content.info.mimetype.clone(),
151+ size_bytes: content.info.size.map(u64::from),
152+ width: content.info.width.map(u64::from),
153+ height: content.info.height.map(u64::from),
154+ duration_ms: None,
155+ blurhash: content.info.blurhash.clone(),
156+ has_thumbnail: content.info.thumbnail_source.is_some(),
157+ }
158+}
159+
160+pub(super) fn media_id(identity: &str, suffix: &str) -> MediaId {
161+ MediaId::new(format!("{identity}#{suffix}"))
162+}
163+
164+fn media_sticker_registration(
165+ identity: &str,
166+ content: &StickerEventContent,
167+) -> Option<MediaRegistration> {
168+ Some(MediaRegistration {
169+ id: media_id(identity, "sticker"),
170+ kind: MediaKind::Sticker,
171+ full: MediaSourceInfo {
172+ source: content.source()?,
173+ mimetype: content.info.mimetype.clone(),
174+ declared_size: content.info.size.map(u64::from),
175+ },
176+ thumbnail: thumbnail_media_source(
177+ content.info.thumbnail_source.clone(),
178+ content.info.thumbnail_info.as_deref(),
179+ ),
180+ })
181+}
182+
183+fn media_registration(identity: &str, message: &MessageType) -> Option<MediaRegistration> {
184+ match message {
185+ MessageType::Image(content) => media_registration_with_info(
186+ identity,
187+ MediaKind::Image,
188+ content.source(),
189+ content.info.as_deref(),
190+ ),
191+ MessageType::Video(content) => media_registration_with_info(
192+ identity,
193+ MediaKind::Video,
194+ content.source(),
195+ content.info.as_deref(),
196+ ),
197+ MessageType::File(content) => media_registration_with_info(
198+ identity,
199+ MediaKind::File,
200+ content.source(),
201+ content.info.as_deref(),
202+ ),
203+ MessageType::Audio(content) => media_registration_with_parts(
204+ identity,
205+ MediaKind::Audio,
206+ content.source(),
207+ content.info.as_ref().and_then(|info| info.mimetype.clone()),
208+ content
209+ .info
210+ .as_ref()
211+ .and_then(|info| info.size)
212+ .map(u64::from),
213+ None,
214+ ),
215+ _ => None,
216+ }
217+}
218+
219+fn media_registration_with_info<T: MediaInfo>(
220+ identity: &str,
221+ kind: MediaKind,
222+ source: Option<MediaSource>,
223+ info: Option<&T>,
224+) -> Option<MediaRegistration> {
225+ media_registration_with_parts(
226+ identity,
227+ kind,
228+ source,
229+ info.and_then(MediaInfo::mimetype),
230+ info.and_then(MediaInfo::declared_size),
231+ info.and_then(MediaInfo::thumbnail),
232+ )
233+}
234+
235+fn media_registration_with_parts(
236+ identity: &str,
237+ kind: MediaKind,
238+ source: Option<MediaSource>,
239+ mimetype: Option<String>,
240+ declared_size: Option<u64>,
241+ thumbnail: Option<MediaSourceInfo>,
242+) -> Option<MediaRegistration> {
243+ Some(MediaRegistration {
244+ id: media_id(identity, "media"),
245+ kind,
246+ full: MediaSourceInfo {
247+ source: source?,
248+ mimetype,
249+ declared_size,
250+ },
251+ thumbnail,
252+ })
253+}
254+
255+trait MediaInfo {
256+ fn mimetype(&self) -> Option<String>;
257+ fn declared_size(&self) -> Option<u64>;
258+ fn thumbnail(&self) -> Option<MediaSourceInfo>;
259+}
260+
261+macro_rules! impl_media_info {
262+ ($type:ty) => {
263+ impl MediaInfo for $type {
264+ fn mimetype(&self) -> Option<String> {
265+ self.mimetype.clone()
266+ }
267+
268+ fn declared_size(&self) -> Option<u64> {
269+ self.size.map(u64::from)
270+ }
271+
272+ fn thumbnail(&self) -> Option<MediaSourceInfo> {
273+ thumbnail_media_source(
274+ self.thumbnail_source.clone(),
275+ self.thumbnail_info.as_deref(),
276+ )
277+ }
278+ }
279+ };
280+}
281+
282+impl_media_info!(ImageInfo);
283+impl_media_info!(VideoInfo);
284+impl_media_info!(FileInfo);
285+
286+fn thumbnail_media_source(
287+ source: Option<MediaSource>,
288+ info: Option<&ThumbnailInfo>,
289+) -> Option<MediaSourceInfo> {
290+ source.map(|source| MediaSourceInfo {
291+ source,
292+ mimetype: info.and_then(|info| info.mimetype.clone()),
293+ declared_size: info.and_then(|info| info.size).map(u64::from),
294+ })
295+}
296+
297+#[cfg(test)]
298+mod tests {
299+ use matrix_sdk::ruma::{
300+ events::room::{ImageInfo, message::ImageMessageEventContent},
301+ mxc_uri,
302+ };
303+
304+ use super::*;
305+
306+ #[test]
307+ fn image_messages_keep_media_metadata() {
308+ let mut info = ImageInfo::new();
309+ info.mimetype = Some("image/png".to_owned());
310+ info.size = Some(65_536_u32.into());
311+ info.width = Some(640_u32.into());
312+ info.height = Some(480_u32.into());
313+ let content = ImageMessageEventContent::plain(
314+ "screen.png".to_owned(),
315+ mxc_uri!("mxc://example.org/screen").to_owned(),
316+ )
317+ .info(Box::new(info));
318+
319+ let media = media_from_message("$event:example.org", &MessageType::Image(content)).unwrap();
320+ assert_eq!(media.id.as_str(), "$event:example.org#media");
321+ assert_eq!(media.kind, MediaKind::Image);
322+ assert_eq!(media.filename, "screen.png");
323+ assert_eq!(media.caption, None);
324+ assert_eq!(media.mimetype.as_deref(), Some("image/png"));
325+ assert_eq!(media.size_bytes, Some(65_536));
326+ assert_eq!((media.width, media.height), (Some(640), Some(480)));
327+ }
328+}
Added file: vendor/macltok/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: vendor/macltok/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, space_child};
10+pub(crate) use timeline::{message_kind, normalise_timeline, normalise_timeline_diffs};
Added file: vendor/macltok/src/normalise/room.rs+225 additions
@@ -0,0 +1,225 @@
1+use futures_util::{StreamExt, stream};
2+use matrix_sdk::{
3+ Client, Room, RoomState,
4+ deserialized_responses::SyncOrStrippedState,
5+ latest_events::LatestEventValue,
6+ ruma::events::{
7+ AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncStateEvent,
8+ room::message::{Relation, RoomMessageEventContent},
9+ space::child::SpaceChildEventContent,
10+ },
11+};
12+use matrix_sdk_ui::spaces::{SpaceRoom, SpaceService};
13+use ruma::OwnedRoomId;
14+
15+use super::message_kind;
16+use crate::{
17+ LatestEvent, MessageKind, RoomHero, RoomMembership, RoomNotificationMode, RoomSummary,
18+ SendState, SpaceChild,
19+};
20+
21+pub(crate) async fn room_summaries(client: &Client, spaces: &SpaceService) -> Vec<RoomSummary> {
22+ let mut summaries = stream::iter(client.rooms())
23+ .map(|room| async move { room_summary(&room, spaces).await })
24+ .buffer_unordered(16)
25+ .collect::<Vec<_>>()
26+ .await;
27+ summaries.sort_by(|left, right| {
28+ right
29+ .latest_event_timestamp_ms
30+ .cmp(&left.latest_event_timestamp_ms)
31+ .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
32+ .then_with(|| left.room_id.cmp(&right.room_id))
33+ });
34+ summaries
35+}
36+
37+async fn room_summary(room: &Room, spaces: &SpaceService) -> RoomSummary {
38+ let name = room
39+ .display_name()
40+ .await
41+ .map(|name| name.to_string())
42+ .unwrap_or_else(|_| room.room_id().to_string());
43+ let custom_notification_mode = room.user_defined_notification_mode().await;
44+ let effective_notification_mode = match custom_notification_mode {
45+ Some(mode) => Some(mode),
46+ None => room.notification_mode().await,
47+ };
48+ let inviter = match room.state() {
49+ RoomState::Invited => room
50+ .invite_details()
51+ .await
52+ .ok()
53+ .map(|invite| invite.inviter_id),
54+ _ => None,
55+ };
56+ let children = if room.is_space() {
57+ space_children(room).await
58+ } else {
59+ Vec::new()
60+ };
61+ RoomSummary {
62+ room_id: room.room_id().to_owned(),
63+ name,
64+ membership: membership(room.state()),
65+ unread_messages: room.num_unread_messages(),
66+ unread_mentions: room.num_unread_mentions(),
67+ unread_notifications: room.num_unread_notifications(),
68+ encrypted: room.encryption_state().is_encrypted(),
69+ space: room.is_space(),
70+ direct: room.is_direct().await.unwrap_or(false),
71+ favourite: room.is_favourite(),
72+ low_priority: room.is_low_priority(),
73+ avatar: room.avatar_url(),
74+ latest_event_timestamp_ms: room
75+ .latest_event_timestamp()
76+ .map(|value| value.get().into()),
77+ latest_event: latest_event(room),
78+ notification_mode: effective_notification_mode.map(notification_mode),
79+ notification_mode_custom: custom_notification_mode.is_some(),
80+ topic: room.topic(),
81+ canonical_alias: room.canonical_alias(),
82+ joined_members: room.joined_members_count(),
83+ invited_members: room.invited_members_count(),
84+ heroes: room
85+ .heroes()
86+ .into_iter()
87+ .map(|hero| RoomHero {
88+ user_id: hero.user_id,
89+ display_name: hero.display_name,
90+ avatar: hero.avatar_url,
91+ })
92+ .collect(),
93+ successor: room.successor_room().map(|successor| successor.room_id),
94+ inviter,
95+ parent_spaces: spaces
96+ .joined_parents_of_child(room.room_id())
97+ .await
98+ .into_iter()
99+ .map(|parent| parent.room_id)
100+ .collect(),
101+ children,
102+ }
103+}
104+
105+const fn membership(state: RoomState) -> RoomMembership {
106+ match state {
107+ RoomState::Joined => RoomMembership::Joined,
108+ RoomState::Invited => RoomMembership::Invited,
109+ RoomState::Left => RoomMembership::Left,
110+ RoomState::Knocked => RoomMembership::Knocked,
111+ RoomState::Banned => RoomMembership::Banned,
112+ }
113+}
114+
115+/// what the space itself lists, straight from local state. redacted children don't count
116+async fn space_children(room: &Room) -> Vec<OwnedRoomId> {
117+ let Ok(events) = room
118+ .get_state_events_static::<SpaceChildEventContent>()
119+ .await
120+ else {
121+ return Vec::new();
122+ };
123+ events
124+ .into_iter()
125+ .filter_map(|event| match event.deserialize().ok()? {
126+ SyncOrStrippedState::Sync(SyncStateEvent::Original(event)) => Some(event.state_key),
127+ SyncOrStrippedState::Stripped(event) => Some(event.state_key),
128+ SyncOrStrippedState::Sync(SyncStateEvent::Redacted(_)) => None,
129+ })
130+ .collect()
131+}
132+
133+fn latest_event(room: &Room) -> Option<LatestEvent> {
134+ let own = || Some(room.own_user_id().to_owned());
135+ let local = |local: &matrix_sdk::latest_events::LocalLatestEventValue| {
136+ let preview = match local.content.deserialize() {
137+ Ok(AnyMessageLikeEventContent::RoomMessage(content)) => preview(&content),
138+ _ => (None, None),
139+ };
140+ (preview, Some(local.timestamp))
141+ };
142+ let (sender, (kind, body), timestamp, send_state) = match room.latest_event() {
143+ LatestEventValue::None => return None,
144+ LatestEventValue::Remote(event) => {
145+ let preview = match event.raw().deserialize() {
146+ Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(
147+ message,
148+ ))) => message
149+ .as_original()
150+ .map_or((None, None), |message| preview(&message.content)),
151+ _ => (None, None),
152+ };
153+ (event.sender(), preview, event.timestamp(), SendState::Sent)
154+ }
155+ LatestEventValue::RemoteInvite {
156+ timestamp, inviter, ..
157+ } => (inviter, (None, None), Some(timestamp), SendState::Sent),
158+ LatestEventValue::LocalIsSending(value) => {
159+ let (preview, timestamp) = local(&value);
160+ (own(), preview, timestamp, SendState::Sending)
161+ }
162+ LatestEventValue::LocalHasBeenSent { value, .. } => {
163+ let (preview, timestamp) = local(&value);
164+ (own(), preview, timestamp, SendState::Sent)
165+ }
166+ // the sdk won't say whether it'll retry, so assume the user has to
167+ LatestEventValue::LocalCannotBeSent(value) => {
168+ let (preview, timestamp) = local(&value);
169+ (
170+ own(),
171+ preview,
172+ timestamp,
173+ SendState::Failed { will_retry: false },
174+ )
175+ }
176+ };
177+ Some(LatestEvent {
178+ sender,
179+ kind,
180+ body,
181+ timestamp_ms: timestamp.map(|timestamp| timestamp.get().into()),
182+ send_state,
183+ })
184+}
185+
186+/// the latest event of an edited message is the edit, whose own body is the `* fallback`
187+fn preview(content: &RoomMessageEventContent) -> (Option<MessageKind>, Option<String>) {
188+ let msgtype = match &content.relates_to {
189+ Some(Relation::Replacement(replacement)) => &replacement.new_content.msgtype,
190+ _ => &content.msgtype,
191+ };
192+ (Some(message_kind(msgtype)), Some(msgtype.body().to_owned()))
193+}
194+
195+pub(crate) fn space_child(room: SpaceRoom) -> SpaceChild {
196+ SpaceChild {
197+ name: room.display_name,
198+ room_id: room.room_id,
199+ topic: room.topic,
200+ avatar: room.avatar_url,
201+ canonical_alias: room.canonical_alias,
202+ joined_members: room.num_joined_members,
203+ space: room.room_type == Some(ruma::room::RoomType::Space),
204+ children_count: room.children_count,
205+ membership: room.state.map(membership),
206+ via: room.via,
207+ suggested: room.suggested,
208+ world_readable: room.world_readable,
209+ guest_can_join: room.guest_can_join,
210+ }
211+}
212+
213+pub(crate) const fn notification_mode(
214+ mode: matrix_sdk::notification_settings::RoomNotificationMode,
215+) -> RoomNotificationMode {
216+ match mode {
217+ matrix_sdk::notification_settings::RoomNotificationMode::AllMessages => {
218+ RoomNotificationMode::AllMessages
219+ }
220+ matrix_sdk::notification_settings::RoomNotificationMode::MentionsAndKeywordsOnly => {
221+ RoomNotificationMode::MentionsAndKeywordsOnly
222+ }
223+ matrix_sdk::notification_settings::RoomNotificationMode::Mute => RoomNotificationMode::Mute,
224+ }
225+}
Added file: vendor/macltok/src/normalise/timeline.rs+443 additions
@@ -0,0 +1,443 @@
1+use std::sync::Arc;
2+
3+use matrix_sdk_crypto::types::events::UtdCause as SdkUtdCause;
4+use matrix_sdk_ui::{
5+ eyeball_im::VectorDiff,
6+ timeline::{
7+ AnyOtherStateEventContentChange, EncryptedMessage, EventSendState, EventTimelineItem,
8+ MembershipChange as SdkMembershipChange, MsgLikeContent, MsgLikeKind, Profile,
9+ TimelineDetails, TimelineEventItemId, TimelineItem as SdkTimelineItem, TimelineItemContent,
10+ TimelineItemKind, VirtualTimelineItem,
11+ },
12+};
13+use ruma::{
14+ OwnedEventId, UserId,
15+ events::{
16+ StateEventContentChange,
17+ room::message::{MessageFormat, MessageType},
18+ },
19+};
20+
21+use crate::{
22+ Change, MembershipChange, Message, MessageKind, MessageMentions, Reaction, ReplyDetails,
23+ ReplyReference, SendState, Sender, ThreadSummary, TimelineContent, TimelineDiff, TimelineEvent,
24+ TimelineItem, TimelineItemId, UtdCause,
25+};
26+
27+use super::media::{
28+ MediaRegistration, MediaScope, media_descriptor_for_sticker, media_from_message,
29+ media_identity, media_registration_for_item,
30+};
31+
32+#[derive(Default)]
33+pub(crate) struct NormalisedTimeline {
34+ pub(crate) items: imbl::Vector<TimelineItem>,
35+ pub(crate) media: Vec<MediaRegistration>,
36+ /// replies whose target isn't loaded, so somebody should go and ask for it
37+ pub(crate) missing_replies: Vec<OwnedEventId>,
38+}
39+
40+pub(crate) struct NormalisedTimelineDiffs {
41+ pub(crate) diffs: Vec<TimelineDiff>,
42+ pub(crate) media: Vec<MediaRegistration>,
43+ pub(crate) missing_replies: Vec<OwnedEventId>,
44+}
45+
46+struct Normaliser<'a> {
47+ current_user_id: Option<&'a UserId>,
48+ scope: &'a MediaScope,
49+ media: Vec<MediaRegistration>,
50+ missing_replies: Vec<OwnedEventId>,
51+}
52+
53+impl Normaliser<'_> {
54+ fn item(&mut self, item: &SdkTimelineItem) -> TimelineItem {
55+ if let Some(media) = media_registration_for_item(item, self.scope) {
56+ self.media.push(media);
57+ }
58+ match item.kind() {
59+ TimelineItemKind::Event(event) => {
60+ TimelineItem::Event(Box::new(self.event(item, event)))
61+ }
62+ TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp)) => {
63+ TimelineItem::DayDivider {
64+ timestamp_ms: timestamp.get().into(),
65+ }
66+ }
67+ TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => TimelineItem::ReadMarker,
68+ TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => {
69+ TimelineItem::TimelineStart
70+ }
71+ }
72+ }
73+
74+ fn event(&mut self, item: &SdkTimelineItem, event: &EventTimelineItem) -> TimelineEvent {
75+ let msg_like = match event.content() {
76+ TimelineItemContent::MsgLike(content) => Some(content),
77+ _ => None,
78+ };
79+ let reply_to = msg_like
80+ .and_then(|content| content.in_reply_to.as_ref())
81+ .map(|reply| {
82+ let details = match &reply.event {
83+ TimelineDetails::Ready(embedded) => ReplyDetails::Ready {
84+ sender: sender(&embedded.sender, &embedded.sender_profile),
85+ content: Box::new(content(&embedded.content, None)),
86+ timestamp_ms: embedded.timestamp.get().into(),
87+ },
88+ TimelineDetails::Unavailable => {
89+ self.missing_replies.push(reply.event_id.clone());
90+ ReplyDetails::Pending
91+ }
92+ TimelineDetails::Pending => ReplyDetails::Pending,
93+ TimelineDetails::Error(_) => ReplyDetails::Unavailable,
94+ };
95+ ReplyReference {
96+ event_id: reply.event_id.clone(),
97+ details,
98+ }
99+ });
100+ let mut read_by: Vec<_> = event
101+ .read_receipts()
102+ .keys()
103+ .filter(|user_id| *user_id != event.sender())
104+ .cloned()
105+ .collect();
106+ read_by.sort();
107+ TimelineEvent {
108+ item_id: TimelineItemId::new(item.unique_id().0.clone()),
109+ event_id: event.event_id().map(ToOwned::to_owned),
110+ transaction_id: event.transaction_id().map(ToOwned::to_owned),
111+ sender: sender(event.sender(), event.sender_profile()),
112+ timestamp_ms: event.timestamp().get().into(),
113+ own: event.is_own(),
114+ send_state: send_state(event.send_state()),
115+ content: content(event.content(), Some(&media_identity(item, self.scope))),
116+ reply_to,
117+ thread_root: msg_like.and_then(|content| content.thread_root.clone()),
118+ thread_summary: msg_like.and_then(thread_summary),
119+ reactions: reactions(event, self.current_user_id),
120+ read_by,
121+ editable: event.is_editable(),
122+ }
123+ }
124+}
125+
126+pub(crate) fn normalise_timeline<'a>(
127+ items: impl Iterator<Item = &'a Arc<SdkTimelineItem>>,
128+ current_user_id: Option<&UserId>,
129+ scope: &MediaScope,
130+) -> NormalisedTimeline {
131+ let mut normaliser = Normaliser {
132+ current_user_id,
133+ scope,
134+ media: Vec::new(),
135+ missing_replies: Vec::new(),
136+ };
137+ let items = items.map(|item| normaliser.item(item)).collect();
138+ NormalisedTimeline {
139+ items,
140+ media: normaliser.media,
141+ missing_replies: normaliser.missing_replies,
142+ }
143+}
144+
145+pub(crate) fn normalise_timeline_diffs(
146+ diffs: Vec<VectorDiff<Arc<SdkTimelineItem>>>,
147+ current_user_id: Option<&UserId>,
148+ scope: &MediaScope,
149+) -> NormalisedTimelineDiffs {
150+ let mut normaliser = Normaliser {
151+ current_user_id,
152+ scope,
153+ media: Vec::new(),
154+ missing_replies: Vec::new(),
155+ };
156+ let diffs = diffs
157+ .into_iter()
158+ .map(|diff| match diff.map(|item| normaliser.item(&item)) {
159+ VectorDiff::Append { values } => TimelineDiff::Append {
160+ values: values.into_iter().collect(),
161+ },
162+ VectorDiff::Clear => TimelineDiff::Clear,
163+ VectorDiff::PushFront { value } => TimelineDiff::PushFront { value },
164+ VectorDiff::PushBack { value } => TimelineDiff::PushBack { value },
165+ VectorDiff::PopFront => TimelineDiff::PopFront,
166+ VectorDiff::PopBack => TimelineDiff::PopBack,
167+ VectorDiff::Insert { index, value } => TimelineDiff::Insert { index, value },
168+ VectorDiff::Set { index, value } => TimelineDiff::Set { index, value },
169+ VectorDiff::Remove { index } => TimelineDiff::Remove { index },
170+ VectorDiff::Truncate { length } => TimelineDiff::Truncate { length },
171+ VectorDiff::Reset { values } => TimelineDiff::Reset {
172+ values: values.into_iter().collect(),
173+ },
174+ })
175+ .collect();
176+ NormalisedTimelineDiffs {
177+ diffs,
178+ media: normaliser.media,
179+ missing_replies: normaliser.missing_replies,
180+ }
181+}
182+
183+/// `media_identity` is `None` for embedded reply content, which never gets fetchable media of its own
184+fn content(content: &TimelineItemContent, media_identity: Option<&str>) -> TimelineContent {
185+ match content {
186+ TimelineItemContent::MsgLike(content) => msg_like(content, media_identity),
187+ TimelineItemContent::MembershipChange(change) => TimelineContent::Membership {
188+ user_id: change.user_id().to_owned(),
189+ display_name: change.display_name(),
190+ change: change
191+ .change()
192+ .map_or(MembershipChange::Other, membership_change),
193+ },
194+ TimelineItemContent::ProfileChange(change) => TimelineContent::Profile {
195+ user_id: change.user_id().to_owned(),
196+ display_name: change.displayname_change().map(|change| Change {
197+ old: change.old.clone(),
198+ new: change.new.clone(),
199+ }),
200+ avatar: change.avatar_url_change().map(|change| Change {
201+ old: change.old.clone(),
202+ new: change.new.clone(),
203+ }),
204+ },
205+ TimelineItemContent::OtherState(state) => match state.content() {
206+ AnyOtherStateEventContentChange::RoomName(change) => TimelineContent::RoomName(
207+ original(change).and_then(|content| non_empty(&content.name)),
208+ ),
209+ AnyOtherStateEventContentChange::RoomTopic(change) => TimelineContent::RoomTopic(
210+ original(change).and_then(|content| non_empty(&content.topic)),
211+ ),
212+ AnyOtherStateEventContentChange::RoomAvatar(change) => TimelineContent::RoomAvatar(
213+ original(change).and_then(|content| content.url.clone()),
214+ ),
215+ AnyOtherStateEventContentChange::RoomEncryption(_) => {
216+ TimelineContent::EncryptionEnabled
217+ }
218+ AnyOtherStateEventContentChange::RoomCreate(_) => TimelineContent::RoomCreated,
219+ AnyOtherStateEventContentChange::RoomTombstone(change) => match original(change) {
220+ Some(content) => TimelineContent::RoomUpgraded {
221+ successor: content.replacement_room.clone(),
222+ },
223+ None => other_state(state.content().event_type().to_string(), state.state_key()),
224+ },
225+ other => other_state(other.event_type().to_string(), state.state_key()),
226+ },
227+ TimelineItemContent::FailedToParseMessageLike { event_type, .. } => {
228+ TimelineContent::Other {
229+ event_type: event_type.to_string(),
230+ }
231+ }
232+ TimelineItemContent::FailedToParseState {
233+ event_type,
234+ state_key,
235+ ..
236+ } => other_state(event_type.to_string(), state_key),
237+ TimelineItemContent::CallInvite => TimelineContent::Other {
238+ event_type: "m.call.invite".to_owned(),
239+ },
240+ TimelineItemContent::RtcNotification { .. } => TimelineContent::Other {
241+ event_type: "org.matrix.msc4075.rtc.notification".to_owned(),
242+ },
243+ }
244+}
245+
246+/// a cleared name or topic arrives as an empty string
247+fn non_empty(value: &str) -> Option<String> {
248+ (!value.is_empty()).then(|| value.to_owned())
249+}
250+
251+fn other_state(event_type: String, state_key: &str) -> TimelineContent {
252+ TimelineContent::OtherState {
253+ event_type,
254+ state_key: state_key.to_owned(),
255+ }
256+}
257+
258+fn original<C>(change: &StateEventContentChange<C>) -> Option<&C>
259+where
260+ C: ruma::events::StaticStateEventContent + ruma::events::RedactContent,
261+{
262+ match change {
263+ StateEventContentChange::Original { content, .. } => Some(content),
264+ StateEventContentChange::Redacted(_) => None,
265+ }
266+}
267+
268+fn msg_like(content: &MsgLikeContent, media_identity: Option<&str>) -> TimelineContent {
269+ match &content.kind {
270+ MsgLikeKind::Message(message) => TimelineContent::Message(Message {
271+ kind: message_kind(message.msgtype()),
272+ body: message.body().to_owned(),
273+ geo_uri: match message.msgtype() {
274+ MessageType::Location(location) => Some(location.geo_uri.clone()),
275+ _ => None,
276+ },
277+ formatted_body: formatted_body(message.msgtype()),
278+ mentions: message.mentions().map(|mentions| MessageMentions {
279+ user_ids: mentions.user_ids.iter().cloned().collect(),
280+ room: mentions.room,
281+ }),
282+ edited: message.is_edited(),
283+ media: media_identity
284+ .and_then(|identity| media_from_message(identity, message.msgtype())),
285+ }),
286+ MsgLikeKind::Sticker(sticker) => {
287+ let sticker = sticker.content();
288+ match media_identity {
289+ Some(identity) => TimelineContent::Sticker {
290+ body: sticker.body.clone(),
291+ media: media_descriptor_for_sticker(identity, sticker),
292+ },
293+ None => TimelineContent::Other {
294+ event_type: "m.sticker".to_owned(),
295+ },
296+ }
297+ }
298+ MsgLikeKind::Poll(poll) => TimelineContent::Poll {
299+ question: poll.results().question,
300+ },
301+ MsgLikeKind::Redacted => TimelineContent::Redacted,
302+ MsgLikeKind::UnableToDecrypt(encrypted) => {
303+ TimelineContent::UnableToDecrypt(match encrypted {
304+ EncryptedMessage::MegolmV1AesSha2 { cause, .. } => utd_cause(*cause),
305+ _ => UtdCause::Unknown,
306+ })
307+ }
308+ MsgLikeKind::Other(other) => TimelineContent::Other {
309+ event_type: other.event_type().to_string(),
310+ },
311+ MsgLikeKind::LiveLocation(_) => TimelineContent::Other {
312+ event_type: "m.beacon_info".to_owned(),
313+ },
314+ }
315+}
316+
317+pub(crate) fn message_kind(message: &MessageType) -> MessageKind {
318+ match message {
319+ MessageType::Text(_) => MessageKind::Text,
320+ MessageType::Emote(_) => MessageKind::Emote,
321+ MessageType::Notice(_) => MessageKind::Notice,
322+ MessageType::ServerNotice(_) => MessageKind::ServerNotice,
323+ MessageType::Image(_) => MessageKind::Image,
324+ MessageType::Video(_) => MessageKind::Video,
325+ MessageType::Audio(_) => MessageKind::Audio,
326+ MessageType::File(_) => MessageKind::File,
327+ MessageType::Location(_) => MessageKind::Location,
328+ MessageType::VerificationRequest(_) => MessageKind::VerificationRequest,
329+ other => MessageKind::Other(other.msgtype().to_owned()),
330+ }
331+}
332+
333+const fn membership_change(change: SdkMembershipChange) -> MembershipChange {
334+ match change {
335+ SdkMembershipChange::Joined => MembershipChange::Joined,
336+ SdkMembershipChange::Left => MembershipChange::Left,
337+ SdkMembershipChange::Banned => MembershipChange::Banned,
338+ SdkMembershipChange::Unbanned => MembershipChange::Unbanned,
339+ SdkMembershipChange::Kicked => MembershipChange::Kicked,
340+ SdkMembershipChange::KickedAndBanned => MembershipChange::KickedAndBanned,
341+ SdkMembershipChange::Invited => MembershipChange::Invited,
342+ SdkMembershipChange::InvitationAccepted => MembershipChange::InvitationAccepted,
343+ SdkMembershipChange::InvitationRejected => MembershipChange::InvitationRejected,
344+ SdkMembershipChange::InvitationRevoked => MembershipChange::InvitationRevoked,
345+ SdkMembershipChange::Knocked => MembershipChange::Knocked,
346+ SdkMembershipChange::KnockAccepted => MembershipChange::KnockAccepted,
347+ SdkMembershipChange::KnockRetracted => MembershipChange::KnockRetracted,
348+ SdkMembershipChange::KnockDenied => MembershipChange::KnockDenied,
349+ SdkMembershipChange::None
350+ | SdkMembershipChange::Error
351+ | SdkMembershipChange::NotImplemented => MembershipChange::Other,
352+ }
353+}
354+
355+const fn utd_cause(cause: SdkUtdCause) -> UtdCause {
356+ match cause {
357+ SdkUtdCause::Unknown => UtdCause::Unknown,
358+ SdkUtdCause::SentBeforeWeJoined => UtdCause::SentBeforeWeJoined,
359+ SdkUtdCause::VerificationViolation => UtdCause::VerificationViolation,
360+ SdkUtdCause::UnsignedDevice => UtdCause::UnsignedDevice,
361+ SdkUtdCause::UnknownDevice => UtdCause::UnknownDevice,
362+ SdkUtdCause::HistoricalMessageAndBackupIsDisabled => {
363+ UtdCause::HistoricalMessageAndBackupIsDisabled
364+ }
365+ SdkUtdCause::WithheldForUnverifiedOrInsecureDevice => {
366+ UtdCause::WithheldForUnverifiedOrInsecureDevice
367+ }
368+ SdkUtdCause::WithheldBySender => UtdCause::WithheldBySender,
369+ SdkUtdCause::HistoricalMessageAndDeviceIsUnverified => {
370+ UtdCause::HistoricalMessageAndDeviceIsUnverified
371+ }
372+ }
373+}
374+
375+fn sender(user_id: &UserId, profile: &TimelineDetails<Profile>) -> Sender {
376+ let profile = match profile {
377+ TimelineDetails::Ready(profile) => Some(profile),
378+ _ => None,
379+ };
380+ Sender {
381+ user_id: user_id.to_owned(),
382+ display_name: profile.and_then(|profile| profile.display_name.clone()),
383+ avatar: profile.and_then(|profile| profile.avatar_url.clone()),
384+ name_ambiguous: profile.is_some_and(|profile| profile.display_name_ambiguous),
385+ }
386+}
387+
388+fn send_state(state: Option<&EventSendState>) -> SendState {
389+ match state {
390+ None | Some(EventSendState::Sent { .. }) => SendState::Sent,
391+ Some(EventSendState::NotSentYet { .. }) => SendState::Sending,
392+ // recoverable means the sdk parks the queue and we unpark it, nothing for the user to do
393+ Some(EventSendState::SendingFailed { is_recoverable, .. }) => SendState::Failed {
394+ will_retry: *is_recoverable,
395+ },
396+ }
397+}
398+
399+fn thread_summary(content: &MsgLikeContent) -> Option<ThreadSummary> {
400+ content.thread_summary.as_ref().map(|summary| {
401+ let latest_event = match &summary.latest_event {
402+ TimelineDetails::Ready(event) => Some(event.as_ref()),
403+ _ => None,
404+ };
405+ ThreadSummary {
406+ reply_count: summary.num_replies,
407+ latest_event_id: latest_event.and_then(|event| match &event.identifier {
408+ TimelineEventItemId::EventId(event_id) => Some(event_id.clone()),
409+ TimelineEventItemId::TransactionId(_) => None,
410+ }),
411+ latest_event_timestamp_ms: latest_event.map(|event| event.timestamp.get().into()),
412+ }
413+ })
414+}
415+
416+fn reactions(event: &EventTimelineItem, current_user_id: Option<&UserId>) -> Vec<Reaction> {
417+ event
418+ .content()
419+ .reactions()
420+ .into_iter()
421+ .flat_map(|reactions| reactions.iter())
422+ .map(|(key, senders)| Reaction {
423+ key: key.clone(),
424+ count: senders.len(),
425+ own: current_user_id.is_some_and(|user_id| senders.contains_key(user_id)),
426+ senders: senders.keys().cloned().collect(),
427+ })
428+ .collect()
429+}
430+
431+fn formatted_body(message: &MessageType) -> Option<String> {
432+ let formatted = match message {
433+ MessageType::Text(content) => content.formatted.as_ref(),
434+ MessageType::Emote(content) => content.formatted.as_ref(),
435+ MessageType::Notice(content) => content.formatted.as_ref(),
436+ MessageType::Audio(content) => content.formatted_caption(),
437+ MessageType::File(content) => content.formatted_caption(),
438+ MessageType::Image(content) => content.formatted_caption(),
439+ MessageType::Video(content) => content.formatted_caption(),
440+ _ => None,
441+ }?;
442+ (formatted.format == MessageFormat::Html).then(|| formatted.body.clone())
443+}
Added file: vendor/macltok/src/session_store.rs+566 additions
@@ -0,0 +1,566 @@
1+#[cfg(feature = "sqlite")]
2+use std::path::{Path, PathBuf};
3+
4+use matrix_sdk::{BoxFuture, SendOutsideWasm, SyncOutsideWasm};
5+use matrix_sdk_store_encryption::StoreCipher;
6+
7+use crate::{AccountError, SessionRecord, StoreConfig, account::account_error};
8+
9+#[cfg(feature = "sqlite")]
10+type RecordLocation = PathBuf;
11+#[cfg(feature = "indexeddb")]
12+type RecordLocation = String;
13+
14+#[cfg(not(target_family = "wasm"))]
15+type Lock<T> = std::sync::Mutex<T>;
16+#[cfg(target_family = "wasm")]
17+type Lock<T> = tokio::sync::Mutex<T>;
18+
19+/// one login's record and the matrix store it belongs to. the engine rotates refresh tokens
20+/// and each works once, so a `save` has to land before the next refresh or the login is gone
21+pub trait SessionStore: SendOutsideWasm + SyncOutsideWasm {
22+ fn store(&self) -> &StoreConfig;
23+
24+ fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>>;
25+
26+ /// replace the saved record, all or nothing
27+ fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>>;
28+
29+ /// `save`, but without awaiting. the sdk calls it from inside a refresh, before the new
30+ /// refresh token is ever used, so a store that can write synchronously closes the gap
31+ /// a crash would otherwise lose the login through. `None` leaves the refresh to `save`
32+ #[cfg(not(target_family = "wasm"))]
33+ fn save_blocking(&self, _record: &SessionRecord) -> Option<Result<(), AccountError>> {
34+ None
35+ }
36+
37+ /// swap the record for one that only says who to log in again as, keeping the store.
38+ /// `Session::restore` then answers `Restore::SoftLoggedOut`, even after a restart
39+ fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>>;
40+
41+ /// drop the record and delete the store, once nothing has the store open
42+ fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>>;
43+}
44+
45+/// keeps the record sealed with the store's passphrase, in a file natively and in an
46+/// IndexedDB database of its own in a browser
47+pub struct EncryptedSessionStore {
48+ record: RecordLocation,
49+ store: StoreConfig,
50+ sealer: Lock<Option<Sealer>>,
51+}
52+
53+struct Sealer {
54+ cipher: StoreCipher,
55+ exported: Vec<u8>,
56+}
57+
58+impl EncryptedSessionStore {
59+ #[cfg(feature = "sqlite")]
60+ #[must_use]
61+ pub fn file(record: impl Into<PathBuf>, store: StoreConfig) -> Self {
62+ Self::new(record.into(), store)
63+ }
64+
65+ /// the record lives beside the store, in `<name>::macltok-session`
66+ #[cfg(feature = "indexeddb")]
67+ #[must_use]
68+ pub fn indexeddb(store: StoreConfig) -> Self {
69+ let record = format!("{}::macltok-session", store.indexeddb_name());
70+ Self::new(record, store)
71+ }
72+
73+ fn new(record: RecordLocation, store: StoreConfig) -> Self {
74+ Self {
75+ record,
76+ store,
77+ sealer: Lock::new(None),
78+ }
79+ }
80+
81+ fn open(
82+ &self,
83+ sealer: &mut Option<Sealer>,
84+ sealed: Option<Vec<u8>>,
85+ ) -> Result<Option<SessionRecord>, AccountError> {
86+ let Some(sealed) = sealed else {
87+ return Ok(None);
88+ };
89+ let (opened, record) = unseal(self.store.passphrase(), &sealed)?;
90+ *sealer = Some(opened);
91+ Ok(Some(record))
92+ }
93+
94+ fn seal(
95+ &self,
96+ sealer: &mut Option<Sealer>,
97+ record: &SessionRecord,
98+ ) -> Result<Vec<u8>, AccountError> {
99+ let sealer = match sealer {
100+ Some(sealer) => sealer,
101+ empty => empty.insert(Sealer::new(self.store.passphrase())?),
102+ };
103+ sealer.seal(record)
104+ }
105+}
106+
107+#[cfg(not(target_family = "wasm"))]
108+impl EncryptedSessionStore {
109+ fn lock(&self) -> std::sync::MutexGuard<'_, Option<Sealer>> {
110+ self.sealer
111+ .lock()
112+ .unwrap_or_else(std::sync::PoisonError::into_inner)
113+ }
114+
115+ fn read(&self, sealer: &mut Option<Sealer>) -> Result<Option<SessionRecord>, AccountError> {
116+ let sealed = backend::read(&self.record)
117+ .map_err(|error| account_error("reading the session record", error))?;
118+ self.open(sealer, sealed)
119+ }
120+
121+ fn write(
122+ &self,
123+ sealer: &mut Option<Sealer>,
124+ record: &SessionRecord,
125+ ) -> Result<(), AccountError> {
126+ let sealed = self.seal(sealer, record)?;
127+ backend::write(&self.record, &sealed)
128+ .map_err(|error| account_error("writing the session record", error))
129+ }
130+
131+ fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {
132+ self.read(&mut self.lock())
133+ }
134+
135+ fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {
136+ self.write(&mut self.lock(), record)
137+ }
138+
139+ fn soft_log_out_record(&self) -> Result<(), AccountError> {
140+ let mut sealer = self.lock();
141+ match self.read(&mut sealer)? {
142+ Some(record) => self.write(&mut sealer, &record.soft_logged_out()),
143+ None => Ok(()),
144+ }
145+ }
146+
147+ fn forget_record(&self) -> Result<(), AccountError> {
148+ let mut sealer = self.lock();
149+ backend::remove(&self.record)
150+ .map_err(|error| account_error("removing the session record", error))?;
151+ *sealer = None;
152+ Ok(())
153+ }
154+}
155+
156+#[cfg(not(target_family = "wasm"))]
157+impl SessionStore for EncryptedSessionStore {
158+ fn store(&self) -> &StoreConfig {
159+ &self.store
160+ }
161+
162+ fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>> {
163+ Box::pin(async move { self.load_record() })
164+ }
165+
166+ fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>> {
167+ Box::pin(async move { self.save_record(record) })
168+ }
169+
170+ /// blocks the calling worker for the write and its fsyncs, about a millisecond
171+ fn save_blocking(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {
172+ Some(self.save_record(record))
173+ }
174+
175+ fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {
176+ Box::pin(async move { self.soft_log_out_record() })
177+ }
178+
179+ fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>> {
180+ Box::pin(async move {
181+ self.forget_record()?;
182+ self.store.delete().await
183+ })
184+ }
185+}
186+
187+#[cfg(target_family = "wasm")]
188+impl EncryptedSessionStore {
189+ async fn read(
190+ &self,
191+ sealer: &mut Option<Sealer>,
192+ ) -> Result<Option<SessionRecord>, AccountError> {
193+ let sealed = backend::read(&self.record)
194+ .await
195+ .map_err(|error| account_error("reading the session record", error))?;
196+ self.open(sealer, sealed)
197+ }
198+
199+ async fn write(
200+ &self,
201+ sealer: &mut Option<Sealer>,
202+ record: &SessionRecord,
203+ ) -> Result<(), AccountError> {
204+ let sealed = self.seal(sealer, record)?;
205+ backend::write(&self.record, &sealed)
206+ .await
207+ .map_err(|error| account_error("writing the session record", error))
208+ }
209+
210+ async fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {
211+ self.read(&mut *self.sealer.lock().await).await
212+ }
213+
214+ async fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {
215+ self.write(&mut *self.sealer.lock().await, record).await
216+ }
217+
218+ async fn soft_log_out_record(&self) -> Result<(), AccountError> {
219+ let mut sealer = self.sealer.lock().await;
220+ match self.read(&mut sealer).await? {
221+ Some(record) => self.write(&mut sealer, &record.soft_logged_out()).await,
222+ None => Ok(()),
223+ }
224+ }
225+
226+ async fn discard_all(&self) -> Result<(), AccountError> {
227+ let mut sealer = self.sealer.lock().await;
228+ backend::remove(&self.record)
229+ .await
230+ .map_err(|error| error.into_account_error("removing the session record"))?;
231+ *sealer = None;
232+ self.store.delete().await
233+ }
234+}
235+
236+#[cfg(target_family = "wasm")]
237+impl SessionStore for EncryptedSessionStore {
238+ fn store(&self) -> &StoreConfig {
239+ &self.store
240+ }
241+
242+ fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>> {
243+ Box::pin(self.load_record())
244+ }
245+
246+ fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>> {
247+ Box::pin(self.save_record(record))
248+ }
249+
250+ fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {
251+ Box::pin(self.soft_log_out_record())
252+ }
253+
254+ fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>> {
255+ Box::pin(self.discard_all())
256+ }
257+}
258+
259+impl Sealer {
260+ fn new(passphrase: &str) -> Result<Self, AccountError> {
261+ let cipher = StoreCipher::new()
262+ .map_err(|error| account_error("creating the session record cipher", error))?;
263+ let exported = cipher
264+ .export(passphrase)
265+ .map_err(|error| account_error("sealing the session record cipher", error))?;
266+ Ok(Self { cipher, exported })
267+ }
268+
269+ fn seal(&self, record: &SessionRecord) -> Result<Vec<u8>, AccountError> {
270+ let value = self
271+ .cipher
272+ .encrypt_value(record)
273+ .map_err(|error| account_error("sealing the session record", error))?;
274+ let length = u32::try_from(self.exported.len())
275+ .expect("an exported store cipher is a few hundred bytes");
276+ let mut sealed = Vec::with_capacity(4 + self.exported.len() + value.len());
277+ sealed.extend_from_slice(&length.to_be_bytes());
278+ sealed.extend_from_slice(&self.exported);
279+ sealed.extend_from_slice(&value);
280+ Ok(sealed)
281+ }
282+}
283+
284+fn unseal(passphrase: &str, sealed: &[u8]) -> Result<(Sealer, SessionRecord), AccountError> {
285+ let truncated = || {
286+ account_error(
287+ "opening the session record",
288+ std::io::Error::from(std::io::ErrorKind::UnexpectedEof),
289+ )
290+ };
291+ let (length, rest) = sealed.split_first_chunk::<4>().ok_or_else(truncated)?;
292+ let (exported, value) = rest
293+ .split_at_checked(u32::from_be_bytes(*length) as usize)
294+ .ok_or_else(truncated)?;
295+ let cipher = StoreCipher::import(passphrase, exported)
296+ .map_err(|error| account_error("opening the session record cipher", error))?;
297+ let record = cipher
298+ .decrypt_value(value)
299+ .map_err(|error| account_error("opening the session record", error))?;
300+ let sealer = Sealer {
301+ cipher,
302+ exported: exported.to_vec(),
303+ };
304+ Ok((sealer, record))
305+}
306+
307+#[cfg(feature = "sqlite")]
308+mod backend {
309+ use std::{
310+ ffi::OsString,
311+ fs::{self, File, OpenOptions},
312+ io::{self, ErrorKind, Write},
313+ path::{Path, PathBuf},
314+ sync::atomic::{AtomicU64, Ordering},
315+ };
316+
317+ static WRITES: AtomicU64 = AtomicU64::new(0);
318+
319+ pub(super) fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {
320+ match fs::read(path) {
321+ Ok(sealed) => Ok(Some(sealed)),
322+ Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
323+ Err(error) => Err(error),
324+ }
325+ }
326+
327+ pub(super) fn write(path: &Path, sealed: &[u8]) -> io::Result<()> {
328+ let parent = match path.parent() {
329+ Some(parent) if !parent.as_os_str().is_empty() => parent,
330+ _ => Path::new("."),
331+ };
332+ fs::create_dir_all(parent)?;
333+ let (temporary, mut file) = temporary_file(path)?;
334+ let written = replace(path, &temporary, &mut file, parent, sealed);
335+ if written.is_err() {
336+ let _ = fs::remove_file(&temporary);
337+ }
338+ written
339+ }
340+
341+ fn temporary_file(path: &Path) -> io::Result<(PathBuf, File)> {
342+ loop {
343+ let mut name = OsString::from(path.as_os_str());
344+ let write = WRITES.fetch_add(1, Ordering::Relaxed);
345+ name.push(format!(".{}.{write}.tmp", std::process::id()));
346+ let temporary = PathBuf::from(name);
347+ match private_file(&temporary) {
348+ Ok(file) => return Ok((temporary, file)),
349+ Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
350+ Err(error) => return Err(error),
351+ }
352+ }
353+ }
354+
355+ fn replace(
356+ path: &Path,
357+ temporary: &Path,
358+ file: &mut File,
359+ parent: &Path,
360+ sealed: &[u8],
361+ ) -> io::Result<()> {
362+ file.write_all(sealed)?;
363+ file.sync_all()?;
364+ fs::rename(temporary, path)?;
365+ sync_directory(parent)
366+ }
367+
368+ pub(super) fn remove(path: &Path) -> io::Result<()> {
369+ match fs::remove_file(path) {
370+ Err(error) if error.kind() != ErrorKind::NotFound => Err(error),
371+ _ => Ok(()),
372+ }
373+ }
374+
375+ /// `create_new` is `O_EXCL`, which posix says never follows a symlink, even a dangling one
376+ #[cfg(unix)]
377+ fn private_file(path: &Path) -> io::Result<File> {
378+ use std::os::unix::fs::OpenOptionsExt;
379+ OpenOptions::new()
380+ .write(true)
381+ .create_new(true)
382+ .mode(0o600)
383+ .open(path)
384+ }
385+
386+ #[cfg(not(unix))]
387+ fn private_file(path: &Path) -> io::Result<File> {
388+ OpenOptions::new().write(true).create_new(true).open(path)
389+ }
390+
391+ /// a rename isn't durable until its directory is synced
392+ #[cfg(unix)]
393+ fn sync_directory(path: &Path) -> io::Result<()> {
394+ File::open(path)?.sync_all()
395+ }
396+
397+ #[cfg(not(unix))]
398+ fn sync_directory(_: &Path) -> io::Result<()> {
399+ Ok(())
400+ }
401+}
402+
403+#[cfg(feature = "indexeddb")]
404+mod backend {
405+ use indexed_db_futures::{
406+ Build,
407+ database::Database,
408+ error::{Error, OpenDbError},
409+ prelude::*,
410+ transaction::{Transaction, TransactionMode},
411+ typed_array::{Uint8Array, Uint8ArraySlice},
412+ };
413+
414+ const RECORDS: &str = "records";
415+ const KEY: &str = "record";
416+
417+ async fn open(name: &str) -> Result<Database, OpenDbError> {
418+ Database::open(name)
419+ .with_version(1u32)
420+ .with_on_upgrade_needed(|_, transaction: &Transaction<'_>| -> Result<(), Error> {
421+ transaction.db().create_object_store(RECORDS).build()?;
422+ Ok(())
423+ })
424+ .build()?
425+ .await
426+ }
427+
428+ /// the crate's `Database` never closes itself on drop, and an open one blocks deleting it
429+ pub(super) async fn read(name: &str) -> Result<Option<Vec<u8>>, OpenDbError> {
430+ let database = open(name).await?;
431+ let sealed = get(&database).await;
432+ database.close();
433+ Ok(sealed?.map(Into::into))
434+ }
435+
436+ pub(super) async fn write(name: &str, sealed: &[u8]) -> Result<(), OpenDbError> {
437+ let database = open(name).await?;
438+ let written = put(&database, sealed).await;
439+ database.close();
440+ Ok(written?)
441+ }
442+
443+ pub(super) async fn remove(name: &str) -> Result<(), super::DeleteError> {
444+ super::delete_database(name).await
445+ }
446+
447+ async fn get(database: &Database) -> Result<Option<Uint8Array>, Error> {
448+ database
449+ .transaction(RECORDS)
450+ .with_mode(TransactionMode::Readonly)
451+ .build()?
452+ .object_store(RECORDS)?
453+ .get(KEY)
454+ .await
455+ }
456+
457+ async fn put(database: &Database, sealed: &[u8]) -> Result<(), Error> {
458+ let transaction = database
459+ .transaction(RECORDS)
460+ .with_mode(TransactionMode::Readwrite)
461+ .build()?;
462+ transaction
463+ .object_store(RECORDS)?
464+ .put(Uint8ArraySlice::new(sealed))
465+ .with_key(KEY)
466+ .build()?;
467+ transaction.commit().await
468+ }
469+}
470+
471+#[cfg(feature = "sqlite")]
472+pub(crate) fn delete_sqlite_store(path: &Path) -> std::io::Result<()> {
473+ use std::{fs, io::ErrorKind};
474+
475+ // matrix-sdk-sqlite 0.18's database names, which it keeps private
476+ const DATABASES: [&str; 4] = [
477+ "matrix-sdk-state.sqlite3",
478+ "matrix-sdk-crypto.sqlite3",
479+ "matrix-sdk-event-cache.sqlite3",
480+ "matrix-sdk-media.sqlite3",
481+ ];
482+ for database in DATABASES {
483+ for suffix in ["", "-wal", "-shm", "-journal"] {
484+ match fs::remove_file(path.join(format!("{database}{suffix}"))) {
485+ Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
486+ _ => {}
487+ }
488+ }
489+ }
490+ match fs::remove_file(path.join(crate::store_lock::LOCK_FILE)) {
491+ Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
492+ _ => {}
493+ }
494+ match fs::remove_dir(path) {
495+ Err(error) if error.kind() != ErrorKind::NotFound => Err(error),
496+ _ => Ok(()),
497+ }
498+}
499+
500+#[cfg(feature = "indexeddb")]
501+pub(crate) async fn delete_indexeddb_store(name: &str) -> Result<(), DeleteError> {
502+ // the databases matrix-sdk-indexeddb 0.18 opens under one store name
503+ for suffix in [
504+ "",
505+ "::matrix-sdk-state",
506+ "::matrix-sdk-crypto",
507+ "::matrix-sdk-crypto-meta",
508+ "::event_cache",
509+ "::media",
510+ ] {
511+ delete_database(&format!("{name}{suffix}")).await?;
512+ }
513+ Ok(())
514+}
515+
516+#[cfg(feature = "indexeddb")]
517+#[derive(Debug, thiserror::Error)]
518+pub(crate) enum DeleteError {
519+ #[error("the database is still open somewhere, it goes once that closes")]
520+ Blocked,
521+ #[error("this global scope has no IndexedDB")]
522+ Unavailable,
523+ #[error("deleting the database failed, {0}")]
524+ Failed(String),
525+}
526+
527+#[cfg(feature = "indexeddb")]
528+impl DeleteError {
529+ pub(crate) fn into_account_error(self, context: &'static str) -> AccountError {
530+ match self {
531+ Self::Blocked => AccountError::StoreDeletePending,
532+ error => account_error(context, error),
533+ }
534+ }
535+}
536+
537+/// matrix_indexed_db_futures 0.7 ignores `blocked` on a delete, which then waits forever
538+#[cfg(feature = "indexeddb")]
539+async fn delete_database(name: &str) -> Result<(), DeleteError> {
540+ use wasm_bindgen::{JsCast, JsValue};
541+
542+ const BLOCKED: &str = "blocked";
543+ let global = js_sys::global();
544+ let factory = if let Some(window) = global.dyn_ref::<web_sys::Window>() {
545+ window.indexed_db()
546+ } else if let Some(worker) = global.dyn_ref::<web_sys::WorkerGlobalScope>() {
547+ worker.indexed_db()
548+ } else {
549+ return Err(DeleteError::Unavailable);
550+ };
551+ let factory = factory.ok().flatten().ok_or(DeleteError::Unavailable)?;
552+ let request = factory
553+ .delete_database(name)
554+ .map_err(|error| DeleteError::Failed(format!("{error:?}")))?;
555+ let settled = js_sys::Promise::new(&mut |resolve, reject| {
556+ request.set_onsuccess(Some(&resolve));
557+ request.set_onerror(Some(&reject));
558+ let blocked = reject.bind1(&JsValue::NULL, &JsValue::from_str(BLOCKED));
559+ request.set_onblocked(Some(blocked.unchecked_ref()));
560+ });
561+ match wasm_bindgen_futures::JsFuture::from(settled).await {
562+ Ok(_) => Ok(()),
563+ Err(reason) if reason.as_string().as_deref() == Some(BLOCKED) => Err(DeleteError::Blocked),
564+ Err(_) => Err(DeleteError::Failed(format!("{:?}", request.error()))),
565+ }
566+}
Added file: vendor/macltok/src/store_lock.rs+117 additions
@@ -0,0 +1,117 @@
1+use crate::{AccountError, account::account_error};
2+
3+/// the platform's own exclusive lock on a store. matrix-sdk 0.18's cross process lease drops
4+/// itself silently after one failed renewal or a stall past 500ms, so it can't be what keeps a
5+/// second engine out
6+pub(crate) struct StoreLock {
7+ #[cfg(feature = "sqlite")]
8+ _file: std::fs::File,
9+ #[cfg(feature = "indexeddb")]
10+ release: js_sys::Function,
11+}
12+
13+#[cfg(feature = "sqlite")]
14+pub(crate) const LOCK_FILE: &str = "macltok.lock";
15+
16+#[cfg(feature = "sqlite")]
17+impl StoreLock {
18+ pub(crate) fn file(directory: &std::path::Path) -> Result<Self, AccountError> {
19+ use std::fs::{self, File, TryLockError};
20+
21+ fs::create_dir_all(directory)
22+ .map_err(|error| account_error("creating the Matrix store directory", error))?;
23+ let file = File::options()
24+ .create(true)
25+ .truncate(false)
26+ .write(true)
27+ .open(directory.join(LOCK_FILE))
28+ .map_err(|error| account_error("opening the Matrix store's lock file", error))?;
29+ match file.try_lock() {
30+ Ok(()) => Ok(Self { _file: file }),
31+ Err(TryLockError::WouldBlock) => Err(AccountError::StoreInUse),
32+ Err(TryLockError::Error(error)) => {
33+ Err(account_error("locking the Matrix store", error))
34+ }
35+ }
36+ }
37+}
38+
39+#[cfg(feature = "indexeddb")]
40+#[derive(Debug, thiserror::Error)]
41+enum WebLockError {
42+ #[error("this global scope has no Web Locks, they need a secure context")]
43+ Unavailable,
44+ #[error("requesting the Web Lock failed, {0}")]
45+ Failed(String),
46+}
47+
48+#[cfg(feature = "indexeddb")]
49+impl StoreLock {
50+ /// `ifAvailable` answers at once. the lock is ours until the promise we hand back settles
51+ pub(crate) async fn web(name: &str) -> Result<Self, AccountError> {
52+ use futures_util::future::{Either, select};
53+ use js_sys::{Function, Object, Promise, Reflect};
54+ use tokio::sync::oneshot;
55+ use wasm_bindgen::{JsCast, JsValue, closure::Closure};
56+
57+ let failed = |error: JsValue| {
58+ account_error(
59+ "locking the Matrix store",
60+ WebLockError::Failed(format!("{error:?}")),
61+ )
62+ };
63+ let locks = Reflect::get(&js_sys::global(), &JsValue::from_str("navigator"))
64+ .and_then(|navigator| Reflect::get(&navigator, &JsValue::from_str("locks")))
65+ .map_err(failed)?;
66+ if locks.is_undefined() {
67+ return Err(account_error(
68+ "locking the Matrix store",
69+ WebLockError::Unavailable,
70+ ));
71+ }
72+ let request: Function = Reflect::get(&locks, &JsValue::from_str("request"))
73+ .map_err(failed)?
74+ .dyn_into()
75+ .map_err(failed)?;
76+ let options = Object::new();
77+ Reflect::set(&options, &JsValue::from_str("ifAvailable"), &JsValue::TRUE)
78+ .map_err(failed)?;
79+
80+ let (granted, grant) = oneshot::channel();
81+ let callback = Closure::once(move |lock: JsValue| -> JsValue {
82+ if lock.is_null() {
83+ let _ = granted.send(None);
84+ return JsValue::UNDEFINED;
85+ }
86+ let mut release = None;
87+ let held = Promise::new(&mut |resolve, _| release = Some(resolve));
88+ let _ = granted.send(release);
89+ held.into()
90+ });
91+ let outcome: Promise = request
92+ .call3(
93+ &locks,
94+ &JsValue::from_str(&format!("macltok:{name}")),
95+ &options,
96+ callback.as_ref(),
97+ )
98+ .map_err(failed)?
99+ .dyn_into()
100+ .map_err(failed)?;
101+ match select(grant, wasm_bindgen_futures::JsFuture::from(outcome)).await {
102+ Either::Left((Ok(Some(release)), _)) => Ok(Self { release }),
103+ Either::Left((Ok(None), _)) => Err(AccountError::StoreInUse),
104+ Either::Left((Err(_), _)) | Either::Right((Ok(_), _)) => {
105+ Err(failed(JsValue::from_str("the lock callback never ran")))
106+ }
107+ Either::Right((Err(error), _)) => Err(failed(error)),
108+ }
109+ }
110+}
111+
112+#[cfg(feature = "indexeddb")]
113+impl Drop for StoreLock {
114+ fn drop(&mut self) {
115+ let _ = self.release.call0(&wasm_bindgen::JsValue::UNDEFINED);
116+ }
117+}
Added file: vendor/macltok/src/transport.rs+1104 additions
@@ -0,0 +1,1104 @@
1+use std::{
2+ fmt,
3+ future::Future,
4+ pin::Pin,
5+ sync::Arc,
6+ task::{Context, Poll},
7+};
8+
9+use matrix_sdk::{Client, RoomMemberships};
10+use matrix_sdk_ui::{
11+ notification_client::{
12+ NotificationClient, NotificationProcessSetup, NotificationStatus, RawNotificationEvent,
13+ },
14+ spaces::{SpaceRoomList, SpaceService, room_list::SpaceRoomListPaginationState},
15+ sync_service::SyncService,
16+};
17+use ruma::{
18+ EventId, MxcUri, OwnedDeviceId, OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedRoomOrAliasId,
19+ OwnedServerName, OwnedTransactionId, OwnedUserId, RoomId, UserId,
20+ events::{
21+ AnyGlobalAccountDataEventContent, AnyRoomAccountDataEvent, AnyRoomAccountDataEventContent,
22+ GlobalAccountDataEventType, RoomAccountDataEventType,
23+ },
24+ serde::Raw,
25+};
26+use tokio::sync::{OnceCell, mpsc, oneshot, watch};
27+
28+use crate::{
29+ AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,
30+ EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, InteractiveAuth,
31+ InteractiveAuthOutcome, LifecycleCommand, Media, MediaCommand, MediaId, MediaLocation,
32+ MediaSize, MemberFilter, MemberSummary, MessageContent, MessageEditContent,
33+ NotificationCommand, OwnDevice, PowerLevelChanges, Profile, PushNotification,
34+ PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,
35+ RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild,
36+ TimelineCommand, TimelineId, VerificationAction, VerificationTarget,
37+ encryption::encryption_state,
38+ engine::{MediaService, StoreLock},
39+ model::{PushNotificationRoom, PushNotificationSender},
40+ normalise::{member_summary, room_summaries, space_child},
41+};
42+
43+#[derive(Clone)]
44+pub struct ClientHandle {
45+ requests: mpsc::Sender<Request>,
46+ client: Client,
47+ connection: watch::Receiver<ConnectionState>,
48+ notification_client: Arc<OnceCell<NotificationClient>>,
49+ sync_service: Arc<SyncService>,
50+ media: Arc<MediaService>,
51+ spaces: Arc<OnceCell<SpaceService>>,
52+ _store_lock: Option<Arc<StoreLock>>,
53+}
54+
55+impl fmt::Debug for ClientHandle {
56+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57+ formatter
58+ .debug_struct("ClientHandle")
59+ .finish_non_exhaustive()
60+ }
61+}
62+
63+/// a command's answer. drop it if you don't care, the command still runs
64+#[derive(Debug)]
65+#[must_use = "dropping it is fine but then you'll never know"]
66+pub struct Pending(oneshot::Receiver<Result<CommandOutput, Error>>);
67+
68+impl Future for Pending {
69+ type Output = Result<CommandOutput, Error>;
70+
71+ fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
72+ Pin::new(&mut self.0)
73+ .poll(context)
74+ .map(|result| result.unwrap_or_else(|_| Err(Error::interrupted())))
75+ }
76+}
77+
78+impl ClientHandle {
79+ /// current credentials, it holds secrets. the engine saves it for you unless the
80+ /// session was built `without_persistence`
81+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
82+ pub fn session_record(&self) -> Result<crate::SessionRecord, crate::AccountError> {
83+ crate::account::session_record(&self.client)
84+ }
85+
86+ /// the bearer token. it rotates on every refresh so ask again after a 401 rather than
87+ /// keeping it, and keep it in memory and away from all but the homeserver's media endpoints
88+ pub fn access_token(&self) -> Option<String> {
89+ self.client.access_token()
90+ }
91+
92+ /// queue a command, waiting for room in the queue if there isn't any
93+ pub async fn send(&self, command: Command) -> Result<Pending, Error> {
94+ let (request, pending) = Request::new(command);
95+ self.requests
96+ .send(request)
97+ .await
98+ .map_err(|_| Error::interrupted())?;
99+ Ok(pending)
100+ }
101+
102+ /// queue a command or fail with `QueueFull` right now
103+ pub fn try_send(&self, command: Command) -> Result<Pending, Error> {
104+ let (request, pending) = Request::new(command);
105+ self.requests
106+ .try_send(request)
107+ .map_err(|error| match error {
108+ mpsc::error::TrySendError::Full(_) => {
109+ Error::new(ErrorKind::QueueFull, "the command queue is full")
110+ }
111+ mpsc::error::TrySendError::Closed(_) => Error::interrupted(),
112+ })?;
113+ Ok(pending)
114+ }
115+
116+ /// queue a command and wait for its answer. timeline sends answer once the
117+ /// sdk send queue has them, delivery shows up later as the item's send state
118+ pub async fn execute(&self, command: Command) -> Result<CommandOutput, Error> {
119+ self.send(command).await?.await
120+ }
121+
122+ async fn run(&self, command: Command) -> Result<(), Error> {
123+ self.execute(command).await.map(drop)
124+ }
125+
126+ async fn run_for_room(&self, command: RoomCommand) -> Result<OwnedRoomId, Error> {
127+ match self.execute(Command::Room(command)).await? {
128+ CommandOutput::Room(room_id) => Ok(room_id),
129+ output => Err(Error::new(
130+ ErrorKind::Other,
131+ format!("expected a room but got {output:?}"),
132+ )),
133+ }
134+ }
135+
136+ pub async fn shutdown(&self) -> Result<(), Error> {
137+ self.run(Command::Lifecycle(LifecycleCommand::Shutdown))
138+ .await
139+ }
140+
141+ pub async fn logout(&self) -> Result<(), Error> {
142+ self.run(Command::Account(AccountCommand::Logout)).await
143+ }
144+
145+ pub async fn delete_devices(
146+ &self,
147+ device_ids: Vec<OwnedDeviceId>,
148+ auth: Option<InteractiveAuth>,
149+ ) -> Result<InteractiveAuthOutcome, Error> {
150+ match self
151+ .run(Command::Account(AccountCommand::DeleteDevices {
152+ device_ids,
153+ auth,
154+ }))
155+ .await
156+ {
157+ Ok(()) => Ok(InteractiveAuthOutcome::Completed),
158+ Err(Error {
159+ challenge: Some(challenge),
160+ ..
161+ }) => Ok(InteractiveAuthOutcome::Challenge(*challenge)),
162+ Err(error) => Err(error),
163+ }
164+ }
165+
166+ pub async fn rename_device(
167+ &self,
168+ device_id: OwnedDeviceId,
169+ display_name: impl Into<String>,
170+ ) -> Result<(), Error> {
171+ self.run(Command::Account(AccountCommand::RenameDevice {
172+ device_id,
173+ display_name: display_name.into(),
174+ }))
175+ .await
176+ }
177+
178+ pub async fn set_global_account_data(
179+ &self,
180+ event_type: GlobalAccountDataEventType,
181+ content: Raw<AnyGlobalAccountDataEventContent>,
182+ ) -> Result<(), Error> {
183+ self.run(Command::Account(AccountCommand::SetAccountData {
184+ event_type,
185+ content,
186+ }))
187+ .await
188+ }
189+
190+ pub async fn set_room_account_data(
191+ &self,
192+ room_id: OwnedRoomId,
193+ event_type: RoomAccountDataEventType,
194+ content: Raw<AnyRoomAccountDataEventContent>,
195+ ) -> Result<(), Error> {
196+ self.run(Command::Room(RoomCommand::SetAccountData {
197+ room_id,
198+ event_type,
199+ content,
200+ }))
201+ .await
202+ }
203+
204+ pub async fn set_room_notification_mode(
205+ &self,
206+ room_id: OwnedRoomId,
207+ mode: RoomNotificationMode,
208+ ) -> Result<(), Error> {
209+ self.run(Command::Notification(NotificationCommand::SetRoomMode {
210+ room_id,
211+ mode,
212+ }))
213+ .await
214+ }
215+
216+ pub async fn reset_room_notification_mode(&self, room_id: OwnedRoomId) -> Result<(), Error> {
217+ self.run(Command::Notification(NotificationCommand::ResetRoomMode(
218+ room_id,
219+ )))
220+ .await
221+ }
222+
223+ pub async fn set_default_notification_mode(
224+ &self,
225+ encrypted: bool,
226+ one_to_one: bool,
227+ mode: RoomNotificationMode,
228+ ) -> Result<(), Error> {
229+ self.run(Command::Notification(NotificationCommand::SetDefaultMode {
230+ encrypted,
231+ one_to_one,
232+ mode,
233+ }))
234+ .await
235+ }
236+
237+ pub async fn add_notification_keyword(&self, keyword: impl Into<String>) -> Result<(), Error> {
238+ self.run(Command::Notification(NotificationCommand::AddKeyword(
239+ keyword.into(),
240+ )))
241+ .await
242+ }
243+
244+ pub async fn remove_notification_keyword(
245+ &self,
246+ keyword: impl Into<String>,
247+ ) -> Result<(), Error> {
248+ self.run(Command::Notification(NotificationCommand::RemoveKeyword(
249+ keyword.into(),
250+ )))
251+ .await
252+ }
253+
254+ pub async fn open_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {
255+ self.run(Command::Room(RoomCommand::Open(room_id))).await
256+ }
257+
258+ pub async fn close_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {
259+ self.run(Command::Room(RoomCommand::Close(room_id))).await
260+ }
261+
262+ pub async fn open_thread(
263+ &self,
264+ room_id: OwnedRoomId,
265+ root_event_id: OwnedEventId,
266+ ) -> Result<(), Error> {
267+ self.run(Command::Timeline(TimelineCommand::OpenThread {
268+ room_id,
269+ root_event_id,
270+ }))
271+ .await
272+ }
273+
274+ pub async fn close_thread(
275+ &self,
276+ room_id: OwnedRoomId,
277+ root_event_id: OwnedEventId,
278+ ) -> Result<(), Error> {
279+ self.run(Command::Timeline(TimelineCommand::CloseThread {
280+ room_id,
281+ root_event_id,
282+ }))
283+ .await
284+ }
285+
286+ pub async fn resync(&self, timeline_id: TimelineId) -> Result<(), Error> {
287+ self.run(Command::Timeline(TimelineCommand::Resync { timeline_id }))
288+ .await
289+ }
290+
291+ pub async fn paginate(&self, timeline_id: TimelineId, limit: u16) -> Result<(), Error> {
292+ self.run(Command::Timeline(TimelineCommand::Paginate {
293+ timeline_id,
294+ limit,
295+ }))
296+ .await
297+ }
298+
299+ pub async fn send_message(
300+ &self,
301+ timeline_id: TimelineId,
302+ content: MessageContent,
303+ ) -> Result<(), Error> {
304+ self.run(Command::Timeline(TimelineCommand::Send {
305+ timeline_id,
306+ content,
307+ }))
308+ .await
309+ }
310+
311+ pub async fn edit_message(
312+ &self,
313+ timeline_id: TimelineId,
314+ target: impl Into<EventRef>,
315+ content: MessageEditContent,
316+ ) -> Result<(), Error> {
317+ self.run(Command::Timeline(TimelineCommand::Edit {
318+ timeline_id,
319+ target: target.into(),
320+ content,
321+ }))
322+ .await
323+ }
324+
325+ pub async fn redact(
326+ &self,
327+ timeline_id: TimelineId,
328+ target: impl Into<EventRef>,
329+ reason: Option<String>,
330+ ) -> Result<(), Error> {
331+ self.run(Command::Timeline(TimelineCommand::Redact {
332+ timeline_id,
333+ target: target.into(),
334+ reason,
335+ }))
336+ .await
337+ }
338+
339+ pub async fn toggle_reaction(
340+ &self,
341+ timeline_id: TimelineId,
342+ target: impl Into<EventRef>,
343+ key: impl Into<String>,
344+ ) -> Result<(), Error> {
345+ self.run(Command::Timeline(TimelineCommand::ToggleReaction {
346+ timeline_id,
347+ target: target.into(),
348+ key: key.into(),
349+ }))
350+ .await
351+ }
352+
353+ pub async fn mark_read(
354+ &self,
355+ timeline_id: TimelineId,
356+ event_id: OwnedEventId,
357+ ) -> Result<(), Error> {
358+ self.run(Command::Timeline(TimelineCommand::MarkRead {
359+ timeline_id,
360+ event_id,
361+ }))
362+ .await
363+ }
364+
365+ pub async fn set_typing(&self, room_id: OwnedRoomId, typing: bool) -> Result<(), Error> {
366+ self.run(Command::Room(RoomCommand::SetTyping { room_id, typing }))
367+ .await
368+ }
369+
370+ pub async fn prepare_to_send(&self, room_id: OwnedRoomId) -> Result<(), Error> {
371+ self.run(Command::Room(RoomCommand::PrepareToSend(room_id)))
372+ .await
373+ }
374+
375+ pub async fn join_room(
376+ &self,
377+ room: OwnedRoomOrAliasId,
378+ via: Vec<OwnedServerName>,
379+ ) -> Result<OwnedRoomId, Error> {
380+ self.run_for_room(RoomCommand::Join { room, via }).await
381+ }
382+
383+ pub async fn leave_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {
384+ self.run(Command::Room(RoomCommand::Leave(room_id))).await
385+ }
386+
387+ pub async fn forget_room(&self, room_id: OwnedRoomId) -> Result<(), Error> {
388+ self.run(Command::Room(RoomCommand::Forget(room_id))).await
389+ }
390+
391+ pub async fn accept_invite(&self, room_id: OwnedRoomId) -> Result<(), Error> {
392+ self.run(Command::Room(RoomCommand::AcceptInvite(room_id)))
393+ .await
394+ }
395+
396+ pub async fn reject_invite(&self, room_id: OwnedRoomId) -> Result<(), Error> {
397+ self.run(Command::Room(RoomCommand::RejectInvite(room_id)))
398+ .await
399+ }
400+
401+ pub async fn invite_user(
402+ &self,
403+ room_id: OwnedRoomId,
404+ user_id: OwnedUserId,
405+ ) -> Result<(), Error> {
406+ self.run(Command::Room(RoomCommand::InviteUser { room_id, user_id }))
407+ .await
408+ }
409+
410+ pub async fn kick_user(
411+ &self,
412+ room_id: OwnedRoomId,
413+ user_id: OwnedUserId,
414+ reason: Option<String>,
415+ ) -> Result<(), Error> {
416+ self.run(Command::Room(RoomCommand::KickUser {
417+ room_id,
418+ user_id,
419+ reason,
420+ }))
421+ .await
422+ }
423+
424+ pub async fn ban_user(
425+ &self,
426+ room_id: OwnedRoomId,
427+ user_id: OwnedUserId,
428+ reason: Option<String>,
429+ ) -> Result<(), Error> {
430+ self.run(Command::Room(RoomCommand::BanUser {
431+ room_id,
432+ user_id,
433+ reason,
434+ }))
435+ .await
436+ }
437+
438+ pub async fn unban_user(
439+ &self,
440+ room_id: OwnedRoomId,
441+ user_id: OwnedUserId,
442+ reason: Option<String>,
443+ ) -> Result<(), Error> {
444+ self.run(Command::Room(RoomCommand::UnbanUser {
445+ room_id,
446+ user_id,
447+ reason,
448+ }))
449+ .await
450+ }
451+
452+ pub async fn set_power_levels(
453+ &self,
454+ room_id: OwnedRoomId,
455+ changes: PowerLevelChanges,
456+ ) -> Result<(), Error> {
457+ self.run(Command::Room(RoomCommand::SetPowerLevels {
458+ room_id,
459+ changes,
460+ }))
461+ .await
462+ }
463+
464+ pub async fn set_user_power_level(
465+ &self,
466+ room_id: OwnedRoomId,
467+ user_id: OwnedUserId,
468+ level: i64,
469+ ) -> Result<(), Error> {
470+ self.run(Command::Room(RoomCommand::SetUserPowerLevel {
471+ room_id,
472+ user_id,
473+ level,
474+ }))
475+ .await
476+ }
477+
478+ pub async fn create_room(&self, options: CreateRoomOptions) -> Result<OwnedRoomId, Error> {
479+ self.run_for_room(RoomCommand::Create(options)).await
480+ }
481+
482+ /// hands back the existing dm with them if there is one
483+ pub async fn create_direct(&self, user_id: OwnedUserId) -> Result<OwnedRoomId, Error> {
484+ self.run_for_room(RoomCommand::CreateDirect(user_id)).await
485+ }
486+
487+ pub async fn cancel_send(
488+ &self,
489+ room_id: OwnedRoomId,
490+ transaction_id: OwnedTransactionId,
491+ ) -> Result<(), Error> {
492+ self.run(Command::Room(RoomCommand::CancelSend {
493+ room_id,
494+ transaction_id,
495+ }))
496+ .await
497+ }
498+
499+ pub async fn retry_send(
500+ &self,
501+ room_id: OwnedRoomId,
502+ transaction_id: OwnedTransactionId,
503+ ) -> Result<(), Error> {
504+ self.run(Command::Room(RoomCommand::RetrySend {
505+ room_id,
506+ transaction_id,
507+ }))
508+ .await
509+ }
510+
511+ pub async fn upload(&self, upload: AttachmentUpload) -> Result<(), Error> {
512+ self.run(Command::Media(MediaCommand::Upload(upload))).await
513+ }
514+
515+ pub async fn recover(&self, recovery_key: impl Into<String>) -> Result<(), Error> {
516+ self.run(Command::Encryption(EncryptionCommand::Recover(
517+ recovery_key.into(),
518+ )))
519+ .await
520+ }
521+
522+ pub async fn create_key_backup(&self) -> Result<(), Error> {
523+ self.run(Command::Encryption(EncryptionCommand::CreateKeyBackup))
524+ .await
525+ }
526+
527+ pub async fn enable_key_backup(&self) -> Result<(), Error> {
528+ self.run(Command::Encryption(EncryptionCommand::EnableKeyBackup))
529+ .await
530+ }
531+
532+ pub async fn disable_key_backup(&self, delete_from_server: bool) -> Result<(), Error> {
533+ self.run(Command::Encryption(EncryptionCommand::DisableKeyBackup {
534+ delete_from_server,
535+ }))
536+ .await
537+ }
538+
539+ pub async fn recover_key_backup(&self, recovery_key: impl Into<String>) -> Result<(), Error> {
540+ self.run(Command::Encryption(EncryptionCommand::RecoverKeyBackup(
541+ recovery_key.into(),
542+ )))
543+ .await
544+ }
545+
546+ pub async fn start_verification(&self, target: VerificationTarget) -> Result<(), Error> {
547+ self.run(Command::Encryption(EncryptionCommand::StartVerification(
548+ target,
549+ )))
550+ .await
551+ }
552+
553+ pub async fn verify(&self, action: VerificationAction) -> Result<(), Error> {
554+ self.run(Command::Encryption(EncryptionCommand::Verify(action)))
555+ .await
556+ }
557+
558+ /// bytes for media a retained timeline showed you. concurrent asks share one download
559+ pub async fn media(&self, media_id: &MediaId, size: MediaSize) -> Result<Media, Error> {
560+ self.media.timeline_media(media_id, size).await
561+ }
562+
563+ /// where `media` would fetch from, for a host streaming big files itself. the byte cap
564+ /// doesn't apply since nothing is buffered
565+ pub async fn media_source(
566+ &self,
567+ media_id: &MediaId,
568+ size: MediaSize,
569+ ) -> Result<MediaLocation, Error> {
570+ self.media.media_location(media_id, size).await
571+ }
572+
573+ /// bytes for any plain mxc uri, avatars mostly. same byte cap as everything else
574+ pub async fn mxc_media(&self, uri: &MxcUri, size: MediaSize) -> Result<Media, Error> {
575+ self.media.mxc_media(uri, size).await
576+ }
577+
578+ /// the space graph builds on first use and then keeps itself current
579+ async fn spaces(&self) -> &SpaceService {
580+ self.spaces
581+ .get_or_init(|| SpaceService::new(self.client.clone()))
582+ .await
583+ }
584+
585+ pub async fn rooms(&self) -> Vec<RoomSummary> {
586+ room_summaries(&self.client, self.spaces().await).await
587+ }
588+
589+ /// browse a space the way the server sees it, rooms you haven't joined included
590+ pub async fn space_hierarchy(&self, space_id: OwnedRoomId) -> SpaceHierarchy {
591+ SpaceHierarchy(SpaceRoomList::new(self.client.clone(), space_id).await)
592+ }
593+
594+ pub async fn own_profile(&self) -> Result<Profile, Error> {
595+ let user_id = self
596+ .client
597+ .user_id()
598+ .ok_or_else(|| Error::new(ErrorKind::Unauthenticated, "the client is not logged in"))?
599+ .to_owned();
600+ let response = self
601+ .client
602+ .account()
603+ .fetch_user_profile()
604+ .await
605+ .map_err(|source| Error::from_sdk("fetching the profile failed", source))?;
606+ profile(user_id, &response)
607+ }
608+
609+ pub async fn user_profile(&self, user_id: &UserId) -> Result<Profile, Error> {
610+ let response = self
611+ .client
612+ .account()
613+ .fetch_user_profile_of(user_id)
614+ .await
615+ .map_err(|source| Error::from_sdk("fetching the profile failed", source))?;
616+ profile(user_id.to_owned(), &response)
617+ }
618+
619+ pub async fn set_display_name(&self, name: Option<String>) -> Result<(), Error> {
620+ self.run(Command::Account(AccountCommand::SetDisplayName(name)))
621+ .await
622+ }
623+
624+ pub async fn set_avatar(&self, avatar: Option<OwnedMxcUri>) -> Result<(), Error> {
625+ self.run(Command::Account(AccountCommand::SetAvatar(avatar)))
626+ .await
627+ }
628+
629+ pub async fn upload_avatar(
630+ &self,
631+ mimetype: impl Into<String>,
632+ bytes: Vec<u8>,
633+ ) -> Result<(), Error> {
634+ self.run(Command::Account(AccountCommand::UploadAvatar {
635+ mimetype: mimetype.into(),
636+ bytes,
637+ }))
638+ .await
639+ }
640+
641+ pub async fn rooms_filtered(&self, filter: &RoomListFilter) -> Vec<RoomSummary> {
642+ let matcher = filter.matcher();
643+ self.rooms()
644+ .await
645+ .into_iter()
646+ .filter(|room| matcher.matches(room))
647+ .collect()
648+ }
649+
650+ pub async fn devices(&self) -> Result<Vec<OwnDevice>, Error> {
651+ let current_device_id = self.client.device_id();
652+ Ok(self
653+ .client
654+ .devices()
655+ .await
656+ .map_err(|source| Error::from_http("listing devices failed", source))?
657+ .devices
658+ .into_iter()
659+ .map(|device| OwnDevice {
660+ current: current_device_id == Some(device.device_id.as_ref()),
661+ device_id: device.device_id,
662+ display_name: device.display_name,
663+ last_seen_ip: device.last_seen_ip,
664+ last_seen_timestamp_ms: device.last_seen_ts.map(|timestamp| timestamp.get().into()),
665+ })
666+ .collect())
667+ }
668+
669+ fn room(&self, room_id: &RoomId) -> Result<matrix_sdk::Room, Error> {
670+ self.client
671+ .get_room(room_id)
672+ .ok_or_else(Error::room_unknown)
673+ }
674+
675+ pub async fn room_notification_settings(
676+ &self,
677+ room_id: &RoomId,
678+ ) -> Result<Option<RoomNotificationSettings>, Error> {
679+ let room = self.room(room_id)?;
680+ let custom = room.user_defined_notification_mode().await;
681+ let mode = match custom {
682+ Some(mode) => mode,
683+ None => match room.notification_mode().await {
684+ Some(mode) => mode,
685+ None => return Ok(None),
686+ },
687+ };
688+ Ok(Some(RoomNotificationSettings {
689+ mode: crate::normalise::notification_mode(mode),
690+ custom: custom.is_some(),
691+ }))
692+ }
693+
694+ pub async fn notification_keywords(&self) -> Vec<String> {
695+ self.client
696+ .notification_settings()
697+ .await
698+ .enabled_keywords()
699+ .await
700+ .into_iter()
701+ .collect()
702+ }
703+
704+ pub async fn resolve_push_notification(
705+ &self,
706+ room_id: &RoomId,
707+ event_id: &EventId,
708+ ) -> Result<PushNotificationStatus, Error> {
709+ let notification_failed = |source| {
710+ Error::with_source(
711+ ErrorKind::Other,
712+ "resolving the push notification failed",
713+ source,
714+ )
715+ };
716+ let client = self
717+ .notification_client
718+ .get_or_try_init(|| async {
719+ NotificationClient::new(
720+ self.client.clone(),
721+ NotificationProcessSetup::SingleProcess {
722+ sync_service: self.sync_service.clone(),
723+ },
724+ )
725+ .await
726+ })
727+ .await
728+ .map_err(notification_failed)?;
729+ let status = client
730+ .get_notification(room_id, event_id)
731+ .await
732+ .map_err(notification_failed)?;
733+ Ok(normalise_push_notification(room_id, event_id, status))
734+ }
735+
736+ pub async fn members(
737+ &self,
738+ room_id: &RoomId,
739+ filter: MemberFilter,
740+ ) -> Result<Vec<MemberSummary>, Error> {
741+ let memberships = match filter {
742+ MemberFilter::Active => RoomMemberships::ACTIVE,
743+ MemberFilter::Joined => RoomMemberships::JOIN,
744+ MemberFilter::Invited => RoomMemberships::INVITE,
745+ MemberFilter::Banned => RoomMemberships::BAN,
746+ MemberFilter::Left => RoomMemberships::LEAVE,
747+ // An empty mask is the SDK's unfiltered wildcard: it matches every
748+ // membership, including custom states that `all()` would exclude.
749+ MemberFilter::All => RoomMemberships::empty(),
750+ };
751+ Ok(self
752+ .room(room_id)?
753+ .members(memberships)
754+ .await
755+ .map_err(|source| Error::from_sdk("listing members failed", source))?
756+ .into_iter()
757+ .map(member_summary)
758+ .collect())
759+ }
760+
761+ pub async fn room_power_levels(&self, room_id: &RoomId) -> Result<RoomPowerLevels, Error> {
762+ let levels = self
763+ .room(room_id)?
764+ .power_levels()
765+ .await
766+ .map_err(|source| Error::from_sdk("reading power levels failed", source.into()))?;
767+ Ok(RoomPowerLevels {
768+ ban: levels.ban.into(),
769+ invite: levels.invite.into(),
770+ kick: levels.kick.into(),
771+ redact: levels.redact.into(),
772+ events_default: levels.events_default.into(),
773+ state_default: levels.state_default.into(),
774+ users_default: levels.users_default.into(),
775+ room_notification: levels.notifications.room.into(),
776+ users: levels
777+ .users
778+ .into_iter()
779+ .map(|(user_id, level)| (user_id, level.into()))
780+ .collect(),
781+ events: levels
782+ .events
783+ .into_iter()
784+ .map(|(event_type, level)| (event_type.to_string(), level.into()))
785+ .collect(),
786+ creators: levels
787+ .rules
788+ .privileged_creators
789+ .into_iter()
790+ .flatten()
791+ .collect(),
792+ })
793+ }
794+
795+ pub async fn pending_send_ids(
796+ &self,
797+ room_id: &RoomId,
798+ ) -> Result<Vec<OwnedTransactionId>, Error> {
799+ let (echoes, _) = self
800+ .room(room_id)?
801+ .send_queue()
802+ .subscribe()
803+ .await
804+ .map_err(|source| {
805+ Error::with_source(ErrorKind::Store, "reading the send queue failed", source)
806+ })?;
807+ Ok(echoes
808+ .into_iter()
809+ .filter(|echo| {
810+ matches!(
811+ echo.content,
812+ matrix_sdk::send_queue::LocalEchoContent::Event { .. }
813+ )
814+ })
815+ .map(|echo| echo.transaction_id)
816+ .collect())
817+ }
818+
819+ pub async fn global_account_data(
820+ &self,
821+ event_type: GlobalAccountDataEventType,
822+ ) -> Result<Option<Raw<AnyGlobalAccountDataEventContent>>, Error> {
823+ self.client
824+ .account()
825+ .account_data_raw(event_type)
826+ .await
827+ .map_err(|source| Error::from_sdk("reading account data failed", source))
828+ }
829+
830+ pub async fn room_account_data(
831+ &self,
832+ room_id: &RoomId,
833+ event_type: RoomAccountDataEventType,
834+ ) -> Result<Option<Raw<AnyRoomAccountDataEvent>>, Error> {
835+ self.room(room_id)?
836+ .account_data(event_type)
837+ .await
838+ .map_err(|source| Error::from_sdk("reading room account data failed", source))
839+ }
840+
841+ #[must_use]
842+ pub fn connection_state(&self) -> watch::Receiver<ConnectionState> {
843+ self.connection.clone()
844+ }
845+
846+ #[must_use]
847+ pub fn encryption_state(&self) -> Option<EncryptionState> {
848+ let device_id = self.client.device_id()?.to_owned();
849+ let encryption = self.client.encryption();
850+ Some(encryption_state(
851+ device_id,
852+ encryption.recovery().state(),
853+ encryption.verification_state().get(),
854+ encryption.backups().state(),
855+ ))
856+ }
857+}
858+
859+/// one space's hierarchy, a page at a time. hold on to it while you browse, it remembers where it got to
860+pub struct SpaceHierarchy(SpaceRoomList);
861+
862+impl fmt::Debug for SpaceHierarchy {
863+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
864+ formatter
865+ .debug_struct("SpaceHierarchy")
866+ .finish_non_exhaustive()
867+ }
868+}
869+
870+impl SpaceHierarchy {
871+ /// fetches the next page. a no-op once the end is reached or while a page is in flight
872+ pub async fn paginate(&self) -> Result<(), Error> {
873+ self.0.paginate().await.map_err(|source| {
874+ Error::with_source(
875+ ErrorKind::Server,
876+ "fetching the space hierarchy failed",
877+ source,
878+ )
879+ })
880+ }
881+
882+ /// everything fetched so far, the space itself excluded
883+ pub async fn rooms(&self) -> Vec<SpaceChild> {
884+ self.0.rooms().await.into_iter().map(space_child).collect()
885+ }
886+
887+ #[must_use]
888+ pub fn end_reached(&self) -> bool {
889+ matches!(
890+ self.0.pagination_state(),
891+ SpaceRoomListPaginationState::Idle { end_reached: true }
892+ )
893+ }
894+}
895+
896+fn profile(
897+ user_id: OwnedUserId,
898+ response: &ruma::api::client::profile::get_profile::v3::Response,
899+) -> Result<Profile, Error> {
900+ use ruma::api::client::profile::{AvatarUrl, DisplayName};
901+ let malformed = |source| {
902+ Error::with_source(
903+ ErrorKind::Server,
904+ "the server sent a malformed profile",
905+ source,
906+ )
907+ };
908+ Ok(Profile {
909+ user_id,
910+ display_name: response.get_static::<DisplayName>().map_err(malformed)?,
911+ avatar: response.get_static::<AvatarUrl>().map_err(malformed)?,
912+ })
913+}
914+
915+#[derive(Debug)]
916+pub(crate) struct Request {
917+ pub(crate) command: Command,
918+ pub(crate) reply: Responder,
919+}
920+
921+impl Request {
922+ fn new(command: Command) -> (Self, Pending) {
923+ let (reply, pending) = oneshot::channel();
924+ (
925+ Self {
926+ command,
927+ reply: Responder(reply),
928+ },
929+ Pending(pending),
930+ )
931+ }
932+}
933+
934+/// whoever holds this owes the caller an answer. dropping it answers `Interrupted`
935+#[derive(Debug)]
936+pub(crate) struct Responder(oneshot::Sender<Result<CommandOutput, Error>>);
937+
938+impl Responder {
939+ pub(crate) fn send(self, result: Result<CommandOutput, Error>) {
940+ if let Err(error) = &result {
941+ tracing::debug!(%error, kind = ?error.kind, "command failed");
942+ }
943+ let _ = self.0.send(result);
944+ }
945+
946+ pub(crate) fn done(self, result: Result<(), Error>) {
947+ self.send(result.map(|()| CommandOutput::Done));
948+ }
949+}
950+
951+#[derive(Debug)]
952+pub struct EventStream {
953+ events: mpsc::Receiver<Event>,
954+}
955+
956+impl EventStream {
957+ pub async fn recv(&mut self) -> Option<Event> {
958+ self.events.recv().await
959+ }
960+
961+ pub fn try_recv(&mut self) -> Result<Event, mpsc::error::TryRecvError> {
962+ self.events.try_recv()
963+ }
964+}
965+
966+impl futures_util::Stream for EventStream {
967+ type Item = Event;
968+
969+ fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
970+ self.events.poll_recv(context)
971+ }
972+}
973+
974+#[derive(Debug)]
975+pub(crate) struct DriverPort {
976+ requests: mpsc::Receiver<Request>,
977+ events: mpsc::Sender<Event>,
978+ connection: watch::Sender<ConnectionState>,
979+}
980+
981+#[derive(Clone, Debug)]
982+pub(crate) struct EventSender(mpsc::Sender<Event>);
983+
984+impl EventSender {
985+ pub(crate) async fn send(&self, event: Event) -> Result<(), SendEventError> {
986+ self.0.send(event).await.map_err(|_| SendEventError)
987+ }
988+
989+ /// resolves once every `EventStream` is gone
990+ pub(crate) async fn closed(&self) {
991+ self.0.closed().await;
992+ }
993+}
994+
995+impl DriverPort {
996+ pub(crate) fn event_sender(&self) -> EventSender {
997+ EventSender(self.events.clone())
998+ }
999+
1000+ pub(crate) fn connection_sender(&self) -> watch::Sender<ConnectionState> {
1001+ self.connection.clone()
1002+ }
1003+
1004+ pub(crate) async fn recv(&mut self) -> Option<Request> {
1005+ self.requests.recv().await
1006+ }
1007+
1008+ pub(crate) fn try_recv(&mut self) -> Option<Request> {
1009+ self.requests.try_recv().ok()
1010+ }
1011+
1012+ pub(crate) fn close(&mut self) {
1013+ self.requests.close();
1014+ }
1015+}
1016+
1017+#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
1018+#[error("the event receiver has closed")]
1019+pub(crate) struct SendEventError;
1020+
1021+#[must_use]
1022+pub(crate) fn transport_pair(
1023+ client: Client,
1024+ sync_service: Arc<SyncService>,
1025+ media: Arc<MediaService>,
1026+ store_lock: Option<Arc<StoreLock>>,
1027+ command_capacity: usize,
1028+ event_capacity: usize,
1029+) -> (ClientHandle, DriverPort, EventStream) {
1030+ let (request_tx, request_rx) = mpsc::channel(command_capacity);
1031+ let (event_tx, event_rx) = mpsc::channel(event_capacity);
1032+ let (connection_tx, connection_rx) = watch::channel(ConnectionState::Idle);
1033+ (
1034+ ClientHandle {
1035+ requests: request_tx,
1036+ client,
1037+ connection: connection_rx,
1038+ notification_client: Arc::new(OnceCell::new()),
1039+ sync_service,
1040+ media,
1041+ spaces: Arc::new(OnceCell::new()),
1042+ _store_lock: store_lock,
1043+ },
1044+ DriverPort {
1045+ requests: request_rx,
1046+ events: event_tx,
1047+ connection: connection_tx,
1048+ },
1049+ EventStream { events: event_rx },
1050+ )
1051+}
1052+
1053+fn normalise_push_notification(
1054+ room_id: &RoomId,
1055+ event_id: &EventId,
1056+ status: NotificationStatus,
1057+) -> PushNotificationStatus {
1058+ match status {
1059+ NotificationStatus::Event(item) => {
1060+ let event = match &item.raw_event {
1061+ RawNotificationEvent::Timeline(event) => {
1062+ PushNotificationEvent::Timeline(event.clone())
1063+ }
1064+ RawNotificationEvent::Invite(event) => PushNotificationEvent::Invite(event.clone()),
1065+ };
1066+ PushNotificationStatus::Event(Box::new(PushNotification {
1067+ event_id: event_id.to_owned(),
1068+ event,
1069+ sender: PushNotificationSender {
1070+ user_id: item.event.sender().to_owned(),
1071+ display_name: item.sender_display_name,
1072+ avatar_url: item.sender_avatar_url.map(Into::into),
1073+ name_ambiguous: item.is_sender_name_ambiguous,
1074+ },
1075+ room: PushNotificationRoom {
1076+ room_id: room_id.to_owned(),
1077+ name: item.room_computed_display_name,
1078+ avatar_url: item.room_avatar_url.map(Into::into),
1079+ canonical_alias: item
1080+ .room_canonical_alias
1081+ .as_deref()
1082+ .and_then(|alias| alias.parse().ok()),
1083+ topic: item.room_topic,
1084+ encrypted: item.is_room_encrypted,
1085+ direct: item.room_is_dm,
1086+ joined_members_count: item.joined_members_count,
1087+ service_members: item
1088+ .service_members
1089+ .into_iter()
1090+ .filter_map(|user_id| user_id.parse().ok())
1091+ .collect(),
1092+ active_service_members_count: item.active_service_members_count,
1093+ space: item.is_space,
1094+ },
1095+ noisy: item.is_noisy,
1096+ has_mention: item.has_mention,
1097+ thread_id: item.thread_id,
1098+ }))
1099+ }
1100+ NotificationStatus::EventNotFound => PushNotificationStatus::NotFound,
1101+ NotificationStatus::EventFilteredOut => PushNotificationStatus::FilteredOut,
1102+ NotificationStatus::EventRedacted => PushNotificationStatus::Redacted,
1103+ }
1104+}
Added file: vendor/macltok/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: vendor/macltok/src/upload.rs+168 additions
@@ -0,0 +1,168 @@
1+use std::time::Duration;
2+
3+use matrix_sdk::{
4+ attachment::{
5+ AttachmentConfig, AttachmentInfo, BaseAudioInfo, BaseFileInfo, BaseImageInfo,
6+ BaseVideoInfo, Thumbnail,
7+ },
8+ ruma::events::room::message::TextMessageEventContent,
9+};
10+
11+use crate::{AttachmentUpload, Error, ErrorKind, UploadDetails};
12+
13+pub(crate) fn prepare_upload(
14+ mut upload: AttachmentUpload,
15+ max_media_bytes: usize,
16+) -> Result<(AttachmentUpload, mime::Mime, AttachmentConfig), Error> {
17+ if upload.bytes.len() > max_media_bytes {
18+ return Err(Error::new(
19+ ErrorKind::MediaTooLarge,
20+ "attachment exceeds the configured byte limit",
21+ ));
22+ }
23+ let mimetype: mime::Mime = upload
24+ .mimetype
25+ .parse()
26+ .map_err(|_| Error::new(ErrorKind::Invalid, "media MIME type is invalid"))?;
27+
28+ let size = ruma::UInt::try_from(upload.bytes.len()).map_err(|_| {
29+ Error::new(
30+ ErrorKind::MediaTooLarge,
31+ "attachment size is not representable",
32+ )
33+ })?;
34+ let info = match upload.details.as_ref() {
35+ Some(UploadDetails::Image {
36+ width,
37+ height,
38+ blurhash,
39+ }) if mimetype.type_() == mime::IMAGE => AttachmentInfo::Image(BaseImageInfo {
40+ width: width.map(Into::into),
41+ height: height.map(Into::into),
42+ size: Some(size),
43+ blurhash: blurhash.clone(),
44+ ..Default::default()
45+ }),
46+ Some(UploadDetails::Video {
47+ width,
48+ height,
49+ duration_ms,
50+ blurhash,
51+ }) if mimetype.type_() == mime::VIDEO => AttachmentInfo::Video(BaseVideoInfo {
52+ width: width.map(Into::into),
53+ height: height.map(Into::into),
54+ duration: duration_ms.map(Duration::from_millis),
55+ size: Some(size),
56+ blurhash: blurhash.clone(),
57+ }),
58+ Some(UploadDetails::Audio { duration_ms }) if mimetype.type_() == mime::AUDIO => {
59+ AttachmentInfo::Audio(BaseAudioInfo {
60+ duration: duration_ms.map(Duration::from_millis),
61+ size: Some(size),
62+ ..Default::default()
63+ })
64+ }
65+ Some(UploadDetails::Voice {
66+ duration_ms,
67+ waveform,
68+ }) if mimetype.type_() == mime::AUDIO => {
69+ if waveform.iter().any(|sample| *sample > 1024) {
70+ return Err(Error::new(
71+ ErrorKind::Invalid,
72+ "voice waveform samples must be at most 1024",
73+ ));
74+ }
75+ AttachmentInfo::Voice(BaseAudioInfo {
76+ duration: Some(Duration::from_millis(*duration_ms)),
77+ size: Some(size),
78+ waveform: Some(
79+ 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(Error::new(
110+ ErrorKind::Invalid,
111+ "attachment metadata does not match its MIME type",
112+ ));
113+ }
114+ };
115+ if upload.thumbnail.is_some() && mimetype.type_() == mime::AUDIO {
116+ return Err(Error::new(
117+ ErrorKind::Invalid,
118+ "audio attachments do not support thumbnails",
119+ ));
120+ }
121+ let thumbnail = 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(Error::new(
130+ if thumbnail.bytes.len() > max_media_bytes {
131+ ErrorKind::MediaTooLarge
132+ } else {
133+ ErrorKind::Invalid
134+ },
135+ "thumbnail size or dimensions are invalid",
136+ ));
137+ }
138+ let content_type: mime::Mime = thumbnail
139+ .mimetype
140+ .parse()
141+ .map_err(|_| Error::new(ErrorKind::Invalid, "thumbnail MIME type is invalid"))?;
142+ if content_type.type_() != mime::IMAGE {
143+ return Err(Error::new(
144+ ErrorKind::Invalid,
145+ "thumbnail MIME type must be an image",
146+ ));
147+ }
148+ let size = ruma::UInt::try_from(thumbnail.bytes.len()).map_err(|_| {
149+ Error::new(
150+ ErrorKind::MediaTooLarge,
151+ "thumbnail size is not representable",
152+ )
153+ })?;
154+ Ok(Thumbnail {
155+ data: thumbnail.bytes,
156+ content_type,
157+ width: thumbnail.width.into(),
158+ height: thumbnail.height.into(),
159+ size,
160+ })
161+ })
162+ .transpose()?;
163+ let config = AttachmentConfig::new()
164+ .info(info)
165+ .thumbnail(thumbnail)
166+ .caption(upload.caption.clone().map(TextMessageEventContent::plain));
167+ Ok((upload, mimetype, config))
168+}
Added file: vendor/macltok/src/wasm.rs+29 additions
@@ -0,0 +1,29 @@
1+use std::cell::RefCell;
2+
3+type Handler = Box<dyn Fn(&str)>;
4+
5+thread_local! {
6+ static HANDLER: RefCell<Option<Handler>> = const { RefCell::new(None) };
7+}
8+
9+/// a panic on wasm32 traps the task that raised it and nothing unwinds, so the engine
10+/// catches nothing. the rest of it keeps running in an undefined state and goes silent,
11+/// no `Failed` and no `Stopped`. `handler` runs from the panic hook with the message,
12+/// after whatever hook was there before, and is the host's one chance to react, say by
13+/// terminating the worker and starting a fresh one. a second call replaces the handler
14+pub fn on_panic(handler: impl Fn(&str) + 'static) {
15+ let installed = HANDLER.with(|slot| slot.replace(Some(Box::new(handler))).is_some());
16+ if installed {
17+ return;
18+ }
19+ let previous = std::panic::take_hook();
20+ std::panic::set_hook(Box::new(move |info| {
21+ previous(info);
22+ let message = info.to_string();
23+ HANDLER.with(|slot| {
24+ if let Some(handler) = &*slot.borrow() {
25+ handler(&message);
26+ }
27+ });
28+ }));
29+}
Added file: web/.env.device+1 additions
@@ -0,0 +1 @@
1+VITE_MALICE_TARGET=device
Added file: web/index.html+12 additions
@@ -0,0 +1,12 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>Malice — Matrix chat</title>
7+ </head>
8+ <body>
9+ <div id="app"></div>
10+ <script type="module" src="/src/main.ts"></script>
11+ </body>
12+</html>
Added file: web/package-lock.json+1315 additions
@@ -0,0 +1,1315 @@
1+{
2+ "name": "malice-web",
3+ "version": "0.1.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "malice-web",
9+ "version": "0.1.0",
10+ "dependencies": {
11+ "svelte": "^4.2.19"
12+ },
13+ "devDependencies": {
14+ "@sveltejs/vite-plugin-svelte": "^3.1.2",
15+ "typescript": "~5.6.3",
16+ "vite": "^5.4.11"
17+ }
18+ },
19+ "node_modules/@ampproject/remapping": {
20+ "version": "2.3.0",
21+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
22+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
23+ "license": "Apache-2.0",
24+ "dependencies": {
25+ "@jridgewell/gen-mapping": "^0.3.5",
26+ "@jridgewell/trace-mapping": "^0.3.24"
27+ },
28+ "engines": {
29+ "node": ">=6.0.0"
30+ }
31+ },
32+ "node_modules/@esbuild/aix-ppc64": {
33+ "version": "0.21.5",
34+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
35+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
36+ "cpu": [
37+ "ppc64"
38+ ],
39+ "dev": true,
40+ "license": "MIT",
41+ "optional": true,
42+ "os": [
43+ "aix"
44+ ],
45+ "engines": {
46+ "node": ">=12"
47+ }
48+ },
49+ "node_modules/@esbuild/android-arm": {
50+ "version": "0.21.5",
51+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
52+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
53+ "cpu": [
54+ "arm"
55+ ],
56+ "dev": true,
57+ "license": "MIT",
58+ "optional": true,
59+ "os": [
60+ "android"
61+ ],
62+ "engines": {
63+ "node": ">=12"
64+ }
65+ },
66+ "node_modules/@esbuild/android-arm64": {
67+ "version": "0.21.5",
68+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
69+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
70+ "cpu": [
71+ "arm64"
72+ ],
73+ "dev": true,
74+ "license": "MIT",
75+ "optional": true,
76+ "os": [
77+ "android"
78+ ],
79+ "engines": {
80+ "node": ">=12"
81+ }
82+ },
83+ "node_modules/@esbuild/android-x64": {
84+ "version": "0.21.5",
85+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
86+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
87+ "cpu": [
88+ "x64"
89+ ],
90+ "dev": true,
91+ "license": "MIT",
92+ "optional": true,
93+ "os": [
94+ "android"
95+ ],
96+ "engines": {
97+ "node": ">=12"
98+ }
99+ },
100+ "node_modules/@esbuild/darwin-arm64": {
101+ "version": "0.21.5",
102+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
103+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
104+ "cpu": [
105+ "arm64"
106+ ],
107+ "dev": true,
108+ "license": "MIT",
109+ "optional": true,
110+ "os": [
111+ "darwin"
112+ ],
113+ "engines": {
114+ "node": ">=12"
115+ }
116+ },
117+ "node_modules/@esbuild/darwin-x64": {
118+ "version": "0.21.5",
119+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
120+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
121+ "cpu": [
122+ "x64"
123+ ],
124+ "dev": true,
125+ "license": "MIT",
126+ "optional": true,
127+ "os": [
128+ "darwin"
129+ ],
130+ "engines": {
131+ "node": ">=12"
132+ }
133+ },
134+ "node_modules/@esbuild/freebsd-arm64": {
135+ "version": "0.21.5",
136+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
137+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
138+ "cpu": [
139+ "arm64"
140+ ],
141+ "dev": true,
142+ "license": "MIT",
143+ "optional": true,
144+ "os": [
145+ "freebsd"
146+ ],
147+ "engines": {
148+ "node": ">=12"
149+ }
150+ },
151+ "node_modules/@esbuild/freebsd-x64": {
152+ "version": "0.21.5",
153+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
154+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
155+ "cpu": [
156+ "x64"
157+ ],
158+ "dev": true,
159+ "license": "MIT",
160+ "optional": true,
161+ "os": [
162+ "freebsd"
163+ ],
164+ "engines": {
165+ "node": ">=12"
166+ }
167+ },
168+ "node_modules/@esbuild/linux-arm": {
169+ "version": "0.21.5",
170+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
171+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
172+ "cpu": [
173+ "arm"
174+ ],
175+ "dev": true,
176+ "license": "MIT",
177+ "optional": true,
178+ "os": [
179+ "linux"
180+ ],
181+ "engines": {
182+ "node": ">=12"
183+ }
184+ },
185+ "node_modules/@esbuild/linux-arm64": {
186+ "version": "0.21.5",
187+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
188+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
189+ "cpu": [
190+ "arm64"
191+ ],
192+ "dev": true,
193+ "license": "MIT",
194+ "optional": true,
195+ "os": [
196+ "linux"
197+ ],
198+ "engines": {
199+ "node": ">=12"
200+ }
201+ },
202+ "node_modules/@esbuild/linux-ia32": {
203+ "version": "0.21.5",
204+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
205+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
206+ "cpu": [
207+ "ia32"
208+ ],
209+ "dev": true,
210+ "license": "MIT",
211+ "optional": true,
212+ "os": [
213+ "linux"
214+ ],
215+ "engines": {
216+ "node": ">=12"
217+ }
218+ },
219+ "node_modules/@esbuild/linux-loong64": {
220+ "version": "0.21.5",
221+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
222+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
223+ "cpu": [
224+ "loong64"
225+ ],
226+ "dev": true,
227+ "license": "MIT",
228+ "optional": true,
229+ "os": [
230+ "linux"
231+ ],
232+ "engines": {
233+ "node": ">=12"
234+ }
235+ },
236+ "node_modules/@esbuild/linux-mips64el": {
237+ "version": "0.21.5",
238+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
239+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
240+ "cpu": [
241+ "mips64el"
242+ ],
243+ "dev": true,
244+ "license": "MIT",
245+ "optional": true,
246+ "os": [
247+ "linux"
248+ ],
249+ "engines": {
250+ "node": ">=12"
251+ }
252+ },
253+ "node_modules/@esbuild/linux-ppc64": {
254+ "version": "0.21.5",
255+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
256+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
257+ "cpu": [
258+ "ppc64"
259+ ],
260+ "dev": true,
261+ "license": "MIT",
262+ "optional": true,
263+ "os": [
264+ "linux"
265+ ],
266+ "engines": {
267+ "node": ">=12"
268+ }
269+ },
270+ "node_modules/@esbuild/linux-riscv64": {
271+ "version": "0.21.5",
272+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
273+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
274+ "cpu": [
275+ "riscv64"
276+ ],
277+ "dev": true,
278+ "license": "MIT",
279+ "optional": true,
280+ "os": [
281+ "linux"
282+ ],
283+ "engines": {
284+ "node": ">=12"
285+ }
286+ },
287+ "node_modules/@esbuild/linux-s390x": {
288+ "version": "0.21.5",
289+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
290+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
291+ "cpu": [
292+ "s390x"
293+ ],
294+ "dev": true,
295+ "license": "MIT",
296+ "optional": true,
297+ "os": [
298+ "linux"
299+ ],
300+ "engines": {
301+ "node": ">=12"
302+ }
303+ },
304+ "node_modules/@esbuild/linux-x64": {
305+ "version": "0.21.5",
306+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
307+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
308+ "cpu": [
309+ "x64"
310+ ],
311+ "dev": true,
312+ "license": "MIT",
313+ "optional": true,
314+ "os": [
315+ "linux"
316+ ],
317+ "engines": {
318+ "node": ">=12"
319+ }
320+ },
321+ "node_modules/@esbuild/netbsd-x64": {
322+ "version": "0.21.5",
323+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
324+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
325+ "cpu": [
326+ "x64"
327+ ],
328+ "dev": true,
329+ "license": "MIT",
330+ "optional": true,
331+ "os": [
332+ "netbsd"
333+ ],
334+ "engines": {
335+ "node": ">=12"
336+ }
337+ },
338+ "node_modules/@esbuild/openbsd-x64": {
339+ "version": "0.21.5",
340+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
341+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
342+ "cpu": [
343+ "x64"
344+ ],
345+ "dev": true,
346+ "license": "MIT",
347+ "optional": true,
348+ "os": [
349+ "openbsd"
350+ ],
351+ "engines": {
352+ "node": ">=12"
353+ }
354+ },
355+ "node_modules/@esbuild/sunos-x64": {
356+ "version": "0.21.5",
357+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
358+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
359+ "cpu": [
360+ "x64"
361+ ],
362+ "dev": true,
363+ "license": "MIT",
364+ "optional": true,
365+ "os": [
366+ "sunos"
367+ ],
368+ "engines": {
369+ "node": ">=12"
370+ }
371+ },
372+ "node_modules/@esbuild/win32-arm64": {
373+ "version": "0.21.5",
374+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
375+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
376+ "cpu": [
377+ "arm64"
378+ ],
379+ "dev": true,
380+ "license": "MIT",
381+ "optional": true,
382+ "os": [
383+ "win32"
384+ ],
385+ "engines": {
386+ "node": ">=12"
387+ }
388+ },
389+ "node_modules/@esbuild/win32-ia32": {
390+ "version": "0.21.5",
391+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
392+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
393+ "cpu": [
394+ "ia32"
395+ ],
396+ "dev": true,
397+ "license": "MIT",
398+ "optional": true,
399+ "os": [
400+ "win32"
401+ ],
402+ "engines": {
403+ "node": ">=12"
404+ }
405+ },
406+ "node_modules/@esbuild/win32-x64": {
407+ "version": "0.21.5",
408+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
409+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
410+ "cpu": [
411+ "x64"
412+ ],
413+ "dev": true,
414+ "license": "MIT",
415+ "optional": true,
416+ "os": [
417+ "win32"
418+ ],
419+ "engines": {
420+ "node": ">=12"
421+ }
422+ },
423+ "node_modules/@jridgewell/gen-mapping": {
424+ "version": "0.3.13",
425+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
426+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
427+ "license": "MIT",
428+ "dependencies": {
429+ "@jridgewell/sourcemap-codec": "^1.5.0",
430+ "@jridgewell/trace-mapping": "^0.3.24"
431+ }
432+ },
433+ "node_modules/@jridgewell/resolve-uri": {
434+ "version": "3.1.2",
435+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
436+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
437+ "license": "MIT",
438+ "engines": {
439+ "node": ">=6.0.0"
440+ }
441+ },
442+ "node_modules/@jridgewell/sourcemap-codec": {
443+ "version": "1.6.0",
444+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
445+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
446+ "license": "MIT"
447+ },
448+ "node_modules/@jridgewell/trace-mapping": {
449+ "version": "0.3.31",
450+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
451+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
452+ "license": "MIT",
453+ "dependencies": {
454+ "@jridgewell/resolve-uri": "^3.1.0",
455+ "@jridgewell/sourcemap-codec": "^1.4.14"
456+ }
457+ },
458+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
459+ "version": "1.5.1",
460+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
461+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
462+ "cpu": [
463+ "x64"
464+ ],
465+ "dev": true,
466+ "license": "MIT",
467+ "optional": true,
468+ "os": [
469+ "linux"
470+ ],
471+ "engines": {
472+ "node": "^22.20 || ^24.12 || >=25"
473+ }
474+ },
475+ "node_modules/@rollup/rollup-android-arm-eabi": {
476+ "version": "4.63.4",
477+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz",
478+ "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==",
479+ "cpu": [
480+ "arm"
481+ ],
482+ "dev": true,
483+ "license": "MIT",
484+ "optional": true,
485+ "os": [
486+ "android"
487+ ]
488+ },
489+ "node_modules/@rollup/rollup-android-arm64": {
490+ "version": "4.63.4",
491+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz",
492+ "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==",
493+ "cpu": [
494+ "arm64"
495+ ],
496+ "dev": true,
497+ "license": "MIT",
498+ "optional": true,
499+ "os": [
500+ "android"
501+ ]
502+ },
503+ "node_modules/@rollup/rollup-darwin-arm64": {
504+ "version": "4.63.4",
505+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz",
506+ "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==",
507+ "cpu": [
508+ "arm64"
509+ ],
510+ "dev": true,
511+ "license": "MIT",
512+ "optional": true,
513+ "os": [
514+ "darwin"
515+ ]
516+ },
517+ "node_modules/@rollup/rollup-darwin-x64": {
518+ "version": "4.63.4",
519+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz",
520+ "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==",
521+ "cpu": [
522+ "x64"
523+ ],
524+ "dev": true,
525+ "license": "MIT",
526+ "optional": true,
527+ "os": [
528+ "darwin"
529+ ]
530+ },
531+ "node_modules/@rollup/rollup-freebsd-arm64": {
532+ "version": "4.63.4",
533+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz",
534+ "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==",
535+ "cpu": [
536+ "arm64"
537+ ],
538+ "dev": true,
539+ "license": "MIT",
540+ "optional": true,
541+ "os": [
542+ "freebsd"
543+ ]
544+ },
545+ "node_modules/@rollup/rollup-freebsd-x64": {
546+ "version": "4.63.4",
547+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz",
548+ "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==",
549+ "cpu": [
550+ "x64"
551+ ],
552+ "dev": true,
553+ "license": "MIT",
554+ "optional": true,
555+ "os": [
556+ "freebsd"
557+ ]
558+ },
559+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
560+ "version": "4.63.4",
561+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz",
562+ "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==",
563+ "cpu": [
564+ "arm"
565+ ],
566+ "dev": true,
567+ "license": "MIT",
568+ "optional": true,
569+ "os": [
570+ "linux"
571+ ]
572+ },
573+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
574+ "version": "4.63.4",
575+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz",
576+ "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==",
577+ "cpu": [
578+ "arm"
579+ ],
580+ "dev": true,
581+ "license": "MIT",
582+ "optional": true,
583+ "os": [
584+ "linux"
585+ ]
586+ },
587+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
588+ "version": "4.63.4",
589+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz",
590+ "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==",
591+ "cpu": [
592+ "arm64"
593+ ],
594+ "dev": true,
595+ "license": "MIT",
596+ "optional": true,
597+ "os": [
598+ "linux"
599+ ]
600+ },
601+ "node_modules/@rollup/rollup-linux-arm64-musl": {
602+ "version": "4.63.4",
603+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz",
604+ "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==",
605+ "cpu": [
606+ "arm64"
607+ ],
608+ "dev": true,
609+ "license": "MIT",
610+ "optional": true,
611+ "os": [
612+ "linux"
613+ ]
614+ },
615+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
616+ "version": "4.63.4",
617+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz",
618+ "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==",
619+ "cpu": [
620+ "loong64"
621+ ],
622+ "dev": true,
623+ "license": "MIT",
624+ "optional": true,
625+ "os": [
626+ "linux"
627+ ]
628+ },
629+ "node_modules/@rollup/rollup-linux-loong64-musl": {
630+ "version": "4.63.4",
631+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz",
632+ "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==",
633+ "cpu": [
634+ "loong64"
635+ ],
636+ "dev": true,
637+ "license": "MIT",
638+ "optional": true,
639+ "os": [
640+ "linux"
641+ ]
642+ },
643+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
644+ "version": "4.63.4",
645+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz",
646+ "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==",
647+ "cpu": [
648+ "ppc64"
649+ ],
650+ "dev": true,
651+ "license": "MIT",
652+ "optional": true,
653+ "os": [
654+ "linux"
655+ ]
656+ },
657+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
658+ "version": "4.63.4",
659+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz",
660+ "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==",
661+ "cpu": [
662+ "ppc64"
663+ ],
664+ "dev": true,
665+ "license": "MIT",
666+ "optional": true,
667+ "os": [
668+ "linux"
669+ ]
670+ },
671+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
672+ "version": "4.63.4",
673+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz",
674+ "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==",
675+ "cpu": [
676+ "riscv64"
677+ ],
678+ "dev": true,
679+ "license": "MIT",
680+ "optional": true,
681+ "os": [
682+ "linux"
683+ ]
684+ },
685+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
686+ "version": "4.63.4",
687+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz",
688+ "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==",
689+ "cpu": [
690+ "riscv64"
691+ ],
692+ "dev": true,
693+ "license": "MIT",
694+ "optional": true,
695+ "os": [
696+ "linux"
697+ ]
698+ },
699+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
700+ "version": "4.63.4",
701+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz",
702+ "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==",
703+ "cpu": [
704+ "s390x"
705+ ],
706+ "dev": true,
707+ "license": "MIT",
708+ "optional": true,
709+ "os": [
710+ "linux"
711+ ]
712+ },
713+ "node_modules/@rollup/rollup-linux-x64-gnu": {
714+ "version": "4.63.4",
715+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz",
716+ "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==",
717+ "cpu": [
718+ "x64"
719+ ],
720+ "dev": true,
721+ "license": "MIT",
722+ "optional": true,
723+ "os": [
724+ "linux"
725+ ]
726+ },
727+ "node_modules/@rollup/rollup-linux-x64-musl": {
728+ "version": "4.63.4",
729+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz",
730+ "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==",
731+ "cpu": [
732+ "x64"
733+ ],
734+ "dev": true,
735+ "license": "MIT",
736+ "optional": true,
737+ "os": [
738+ "linux"
739+ ]
740+ },
741+ "node_modules/@rollup/rollup-openbsd-x64": {
742+ "version": "4.63.4",
743+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz",
744+ "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==",
745+ "cpu": [
746+ "x64"
747+ ],
748+ "dev": true,
749+ "license": "MIT",
750+ "optional": true,
751+ "os": [
752+ "openbsd"
753+ ]
754+ },
755+ "node_modules/@rollup/rollup-openharmony-arm64": {
756+ "version": "4.63.4",
757+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz",
758+ "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==",
759+ "cpu": [
760+ "arm64"
761+ ],
762+ "dev": true,
763+ "license": "MIT",
764+ "optional": true,
765+ "os": [
766+ "openharmony"
767+ ]
768+ },
769+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
770+ "version": "4.63.4",
771+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz",
772+ "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==",
773+ "cpu": [
774+ "arm64"
775+ ],
776+ "dev": true,
777+ "license": "MIT",
778+ "optional": true,
779+ "os": [
780+ "win32"
781+ ]
782+ },
783+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
784+ "version": "4.63.4",
785+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz",
786+ "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==",
787+ "cpu": [
788+ "ia32"
789+ ],
790+ "dev": true,
791+ "license": "MIT",
792+ "optional": true,
793+ "os": [
794+ "win32"
795+ ]
796+ },
797+ "node_modules/@rollup/rollup-win32-x64-gnu": {
798+ "version": "4.63.4",
799+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz",
800+ "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==",
801+ "cpu": [
802+ "x64"
803+ ],
804+ "dev": true,
805+ "license": "MIT",
806+ "optional": true,
807+ "os": [
808+ "win32"
809+ ]
810+ },
811+ "node_modules/@rollup/rollup-win32-x64-msvc": {
812+ "version": "4.63.4",
813+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz",
814+ "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==",
815+ "cpu": [
816+ "x64"
817+ ],
818+ "dev": true,
819+ "license": "MIT",
820+ "optional": true,
821+ "os": [
822+ "win32"
823+ ]
824+ },
825+ "node_modules/@sveltejs/vite-plugin-svelte": {
826+ "version": "3.1.2",
827+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",
828+ "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",
829+ "dev": true,
830+ "license": "MIT",
831+ "dependencies": {
832+ "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
833+ "debug": "^4.3.4",
834+ "deepmerge": "^4.3.1",
835+ "kleur": "^4.1.5",
836+ "magic-string": "^0.30.10",
837+ "svelte-hmr": "^0.16.0",
838+ "vitefu": "^0.2.5"
839+ },
840+ "engines": {
841+ "node": "^18.0.0 || >=20"
842+ },
843+ "peerDependencies": {
844+ "svelte": "^4.0.0 || ^5.0.0-next.0",
845+ "vite": "^5.0.0"
846+ }
847+ },
848+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
849+ "version": "2.1.0",
850+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
851+ "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
852+ "dev": true,
853+ "license": "MIT",
854+ "dependencies": {
855+ "debug": "^4.3.4"
856+ },
857+ "engines": {
858+ "node": "^18.0.0 || >=20"
859+ },
860+ "peerDependencies": {
861+ "@sveltejs/vite-plugin-svelte": "^3.0.0",
862+ "svelte": "^4.0.0 || ^5.0.0-next.0",
863+ "vite": "^5.0.0"
864+ }
865+ },
866+ "node_modules/@types/estree": {
867+ "version": "1.0.9",
868+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
869+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
870+ "license": "MIT"
871+ },
872+ "node_modules/acorn": {
873+ "version": "8.18.0",
874+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
875+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
876+ "license": "MIT",
877+ "bin": {
878+ "acorn": "bin/acorn"
879+ },
880+ "engines": {
881+ "node": ">=0.4.0"
882+ }
883+ },
884+ "node_modules/aria-query": {
885+ "version": "5.3.2",
886+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
887+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
888+ "license": "Apache-2.0",
889+ "engines": {
890+ "node": ">= 0.4"
891+ }
892+ },
893+ "node_modules/axobject-query": {
894+ "version": "4.1.0",
895+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
896+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
897+ "license": "Apache-2.0",
898+ "engines": {
899+ "node": ">= 0.4"
900+ }
901+ },
902+ "node_modules/code-red": {
903+ "version": "1.0.4",
904+ "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
905+ "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
906+ "license": "MIT",
907+ "dependencies": {
908+ "@jridgewell/sourcemap-codec": "^1.4.15",
909+ "@types/estree": "^1.0.1",
910+ "acorn": "^8.10.0",
911+ "estree-walker": "^3.0.3",
912+ "periscopic": "^3.1.0"
913+ }
914+ },
915+ "node_modules/css-tree": {
916+ "version": "2.3.1",
917+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
918+ "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
919+ "license": "MIT",
920+ "dependencies": {
921+ "mdn-data": "2.0.30",
922+ "source-map-js": "^1.0.1"
923+ },
924+ "engines": {
925+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
926+ }
927+ },
928+ "node_modules/debug": {
929+ "version": "4.4.3",
930+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
931+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
932+ "dev": true,
933+ "license": "MIT",
934+ "dependencies": {
935+ "ms": "^2.1.3"
936+ },
937+ "engines": {
938+ "node": ">=6.0"
939+ },
940+ "peerDependenciesMeta": {
941+ "supports-color": {
942+ "optional": true
943+ }
944+ }
945+ },
946+ "node_modules/deepmerge": {
947+ "version": "4.3.1",
948+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
949+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
950+ "dev": true,
951+ "license": "MIT",
952+ "engines": {
953+ "node": ">=0.10.0"
954+ }
955+ },
956+ "node_modules/esbuild": {
957+ "version": "0.21.5",
958+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
959+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
960+ "dev": true,
961+ "hasInstallScript": true,
962+ "license": "MIT",
963+ "bin": {
964+ "esbuild": "bin/esbuild"
965+ },
966+ "engines": {
967+ "node": ">=12"
968+ },
969+ "optionalDependencies": {
970+ "@esbuild/aix-ppc64": "0.21.5",
971+ "@esbuild/android-arm": "0.21.5",
972+ "@esbuild/android-arm64": "0.21.5",
973+ "@esbuild/android-x64": "0.21.5",
974+ "@esbuild/darwin-arm64": "0.21.5",
975+ "@esbuild/darwin-x64": "0.21.5",
976+ "@esbuild/freebsd-arm64": "0.21.5",
977+ "@esbuild/freebsd-x64": "0.21.5",
978+ "@esbuild/linux-arm": "0.21.5",
979+ "@esbuild/linux-arm64": "0.21.5",
980+ "@esbuild/linux-ia32": "0.21.5",
981+ "@esbuild/linux-loong64": "0.21.5",
982+ "@esbuild/linux-mips64el": "0.21.5",
983+ "@esbuild/linux-ppc64": "0.21.5",
984+ "@esbuild/linux-riscv64": "0.21.5",
985+ "@esbuild/linux-s390x": "0.21.5",
986+ "@esbuild/linux-x64": "0.21.5",
987+ "@esbuild/netbsd-x64": "0.21.5",
988+ "@esbuild/openbsd-x64": "0.21.5",
989+ "@esbuild/sunos-x64": "0.21.5",
990+ "@esbuild/win32-arm64": "0.21.5",
991+ "@esbuild/win32-ia32": "0.21.5",
992+ "@esbuild/win32-x64": "0.21.5"
993+ }
994+ },
995+ "node_modules/estree-walker": {
996+ "version": "3.0.3",
997+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
998+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
999+ "license": "MIT",
1000+ "dependencies": {
1001+ "@types/estree": "^1.0.0"
1002+ }
1003+ },
1004+ "node_modules/fsevents": {
1005+ "version": "2.3.3",
1006+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1007+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1008+ "dev": true,
1009+ "hasInstallScript": true,
1010+ "license": "MIT",
1011+ "optional": true,
1012+ "os": [
1013+ "darwin"
1014+ ],
1015+ "engines": {
1016+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1017+ }
1018+ },
1019+ "node_modules/is-reference": {
1020+ "version": "3.0.3",
1021+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
1022+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
1023+ "license": "MIT",
1024+ "dependencies": {
1025+ "@types/estree": "^1.0.6"
1026+ }
1027+ },
1028+ "node_modules/kleur": {
1029+ "version": "4.1.5",
1030+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
1031+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
1032+ "dev": true,
1033+ "license": "MIT",
1034+ "engines": {
1035+ "node": ">=6"
1036+ }
1037+ },
1038+ "node_modules/locate-character": {
1039+ "version": "3.0.0",
1040+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
1041+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
1042+ "license": "MIT"
1043+ },
1044+ "node_modules/magic-string": {
1045+ "version": "0.30.21",
1046+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
1047+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
1048+ "license": "MIT",
1049+ "dependencies": {
1050+ "@jridgewell/sourcemap-codec": "^1.5.5"
1051+ }
1052+ },
1053+ "node_modules/mdn-data": {
1054+ "version": "2.0.30",
1055+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
1056+ "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
1057+ "license": "CC0-1.0"
1058+ },
1059+ "node_modules/ms": {
1060+ "version": "2.1.3",
1061+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1062+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1063+ "dev": true,
1064+ "license": "MIT"
1065+ },
1066+ "node_modules/nanoid": {
1067+ "version": "3.3.19",
1068+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
1069+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
1070+ "dev": true,
1071+ "funding": [
1072+ {
1073+ "type": "github",
1074+ "url": "https://github.com/sponsors/ai"
1075+ }
1076+ ],
1077+ "license": "MIT",
1078+ "bin": {
1079+ "nanoid": "bin/nanoid.cjs"
1080+ },
1081+ "engines": {
1082+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1083+ }
1084+ },
1085+ "node_modules/periscopic": {
1086+ "version": "3.1.0",
1087+ "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
1088+ "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
1089+ "license": "MIT",
1090+ "dependencies": {
1091+ "@types/estree": "^1.0.0",
1092+ "estree-walker": "^3.0.0",
1093+ "is-reference": "^3.0.0"
1094+ }
1095+ },
1096+ "node_modules/picocolors": {
1097+ "version": "1.1.1",
1098+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1099+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1100+ "dev": true,
1101+ "license": "ISC"
1102+ },
1103+ "node_modules/postcss": {
1104+ "version": "8.5.28",
1105+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
1106+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
1107+ "dev": true,
1108+ "funding": [
1109+ {
1110+ "type": "opencollective",
1111+ "url": "https://opencollective.com/postcss/"
1112+ },
1113+ {
1114+ "type": "tidelift",
1115+ "url": "https://tidelift.com/funding/github/npm/postcss"
1116+ },
1117+ {
1118+ "type": "github",
1119+ "url": "https://github.com/sponsors/ai"
1120+ }
1121+ ],
1122+ "license": "MIT",
1123+ "dependencies": {
1124+ "nanoid": "^3.3.18",
1125+ "picocolors": "^1.1.1",
1126+ "source-map-js": "^1.2.1"
1127+ },
1128+ "engines": {
1129+ "node": "^10 || ^12 || >=14"
1130+ }
1131+ },
1132+ "node_modules/rollup": {
1133+ "version": "4.63.4",
1134+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
1135+ "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==",
1136+ "dev": true,
1137+ "license": "MIT",
1138+ "dependencies": {
1139+ "@types/estree": "1.0.9"
1140+ },
1141+ "bin": {
1142+ "rollup": "dist/bin/rollup"
1143+ },
1144+ "engines": {
1145+ "node": ">=18.0.0",
1146+ "npm": ">=8.0.0"
1147+ },
1148+ "optionalDependencies": {
1149+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1150+ "@rollup/rollup-android-arm-eabi": "4.63.4",
1151+ "@rollup/rollup-android-arm64": "4.63.4",
1152+ "@rollup/rollup-darwin-arm64": "4.63.4",
1153+ "@rollup/rollup-darwin-x64": "4.63.4",
1154+ "@rollup/rollup-freebsd-arm64": "4.63.4",
1155+ "@rollup/rollup-freebsd-x64": "4.63.4",
1156+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.4",
1157+ "@rollup/rollup-linux-arm-musleabihf": "4.63.4",
1158+ "@rollup/rollup-linux-arm64-gnu": "4.63.4",
1159+ "@rollup/rollup-linux-arm64-musl": "4.63.4",
1160+ "@rollup/rollup-linux-loong64-gnu": "4.63.4",
1161+ "@rollup/rollup-linux-loong64-musl": "4.63.4",
1162+ "@rollup/rollup-linux-ppc64-gnu": "4.63.4",
1163+ "@rollup/rollup-linux-ppc64-musl": "4.63.4",
1164+ "@rollup/rollup-linux-riscv64-gnu": "4.63.4",
1165+ "@rollup/rollup-linux-riscv64-musl": "4.63.4",
1166+ "@rollup/rollup-linux-s390x-gnu": "4.63.4",
1167+ "@rollup/rollup-linux-x64-gnu": "4.63.4",
1168+ "@rollup/rollup-linux-x64-musl": "4.63.4",
1169+ "@rollup/rollup-openbsd-x64": "4.63.4",
1170+ "@rollup/rollup-openharmony-arm64": "4.63.4",
1171+ "@rollup/rollup-win32-arm64-msvc": "4.63.4",
1172+ "@rollup/rollup-win32-ia32-msvc": "4.63.4",
1173+ "@rollup/rollup-win32-x64-gnu": "4.63.4",
1174+ "@rollup/rollup-win32-x64-msvc": "4.63.4",
1175+ "fsevents": "~2.3.2"
1176+ }
1177+ },
1178+ "node_modules/source-map-js": {
1179+ "version": "1.2.1",
1180+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1181+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1182+ "license": "BSD-3-Clause",
1183+ "engines": {
1184+ "node": ">=0.10.0"
1185+ }
1186+ },
1187+ "node_modules/svelte": {
1188+ "version": "4.2.20",
1189+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
1190+ "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
1191+ "license": "MIT",
1192+ "dependencies": {
1193+ "@ampproject/remapping": "^2.2.1",
1194+ "@jridgewell/sourcemap-codec": "^1.4.15",
1195+ "@jridgewell/trace-mapping": "^0.3.18",
1196+ "@types/estree": "^1.0.1",
1197+ "acorn": "^8.9.0",
1198+ "aria-query": "^5.3.0",
1199+ "axobject-query": "^4.0.0",
1200+ "code-red": "^1.0.3",
1201+ "css-tree": "^2.3.1",
1202+ "estree-walker": "^3.0.3",
1203+ "is-reference": "^3.0.1",
1204+ "locate-character": "^3.0.0",
1205+ "magic-string": "^0.30.4",
1206+ "periscopic": "^3.1.0"
1207+ },
1208+ "engines": {
1209+ "node": ">=16"
1210+ }
1211+ },
1212+ "node_modules/svelte-hmr": {
1213+ "version": "0.16.0",
1214+ "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",
1215+ "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",
1216+ "dev": true,
1217+ "license": "ISC",
1218+ "engines": {
1219+ "node": "^12.20 || ^14.13.1 || >= 16"
1220+ },
1221+ "peerDependencies": {
1222+ "svelte": "^3.19.0 || ^4.0.0"
1223+ }
1224+ },
1225+ "node_modules/typescript": {
1226+ "version": "5.6.3",
1227+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
1228+ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
1229+ "dev": true,
1230+ "license": "Apache-2.0",
1231+ "bin": {
1232+ "tsc": "bin/tsc",
1233+ "tsserver": "bin/tsserver"
1234+ },
1235+ "engines": {
1236+ "node": ">=14.17"
1237+ }
1238+ },
1239+ "node_modules/vite": {
1240+ "version": "5.4.21",
1241+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1242+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1243+ "dev": true,
1244+ "license": "MIT",
1245+ "dependencies": {
1246+ "esbuild": "^0.21.3",
1247+ "postcss": "^8.4.43",
1248+ "rollup": "^4.20.0"
1249+ },
1250+ "bin": {
1251+ "vite": "bin/vite.js"
1252+ },
1253+ "engines": {
1254+ "node": "^18.0.0 || >=20.0.0"
1255+ },
1256+ "funding": {
1257+ "url": "https://github.com/vitejs/vite?sponsor=1"
1258+ },
1259+ "optionalDependencies": {
1260+ "fsevents": "~2.3.3"
1261+ },
1262+ "peerDependencies": {
1263+ "@types/node": "^18.0.0 || >=20.0.0",
1264+ "less": "*",
1265+ "lightningcss": "^1.21.0",
1266+ "sass": "*",
1267+ "sass-embedded": "*",
1268+ "stylus": "*",
1269+ "sugarss": "*",
1270+ "terser": "^5.4.0"
1271+ },
1272+ "peerDependenciesMeta": {
1273+ "@types/node": {
1274+ "optional": true
1275+ },
1276+ "less": {
1277+ "optional": true
1278+ },
1279+ "lightningcss": {
1280+ "optional": true
1281+ },
1282+ "sass": {
1283+ "optional": true
1284+ },
1285+ "sass-embedded": {
1286+ "optional": true
1287+ },
1288+ "stylus": {
1289+ "optional": true
1290+ },
1291+ "sugarss": {
1292+ "optional": true
1293+ },
1294+ "terser": {
1295+ "optional": true
1296+ }
1297+ }
1298+ },
1299+ "node_modules/vitefu": {
1300+ "version": "0.2.5",
1301+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
1302+ "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
1303+ "dev": true,
1304+ "license": "MIT",
1305+ "peerDependencies": {
1306+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
1307+ },
1308+ "peerDependenciesMeta": {
1309+ "vite": {
1310+ "optional": true
1311+ }
1312+ }
1313+ }
1314+ }
1315+}
Added file: web/package.json+21 additions
@@ -0,0 +1,21 @@
1+{
2+ "name": "malice-web",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite --host 127.0.0.1 --port 5173",
8+ "build": "vite build",
9+ "build:device": "vite build --mode device",
10+ "preview": "vite preview --host 127.0.0.1 --port 4173",
11+ "test": "rm -rf .test-dist && tsc -p tsconfig.test.json && node --test .test-dist/test/media.test.js"
12+ },
13+ "dependencies": {
14+ "svelte": "^4.2.19"
15+ },
16+ "devDependencies": {
17+ "@sveltejs/vite-plugin-svelte": "^3.1.2",
18+ "typescript": "~5.6.3",
19+ "vite": "^5.4.11"
20+ }
21+}
Added file: web/src/App.svelte+1262 additions
@@ -0,0 +1,1262 @@
1+<script lang="ts">
2+ import { onMount, tick } from 'svelte';
3+ import LoginPanel from './lib/LoginPanel.svelte';
4+ import MessageRow from './lib/MessageRow.svelte';
5+ import MxcAvatar from './lib/MxcAvatar.svelte';
6+ import SecurityPanel from './lib/SecurityPanel.svelte';
7+ import {
8+ closeRoom,
9+ createRoom,
10+ getEncryption,
11+ getMembers,
12+ getProfile,
13+ getRooms,
14+ getSession,
15+ getSpaceChildren,
16+ joinRoom,
17+ leaveRoom,
18+ logout,
19+ markRead,
20+ openRoom,
21+ paginateRoom,
22+ reactToMessage,
23+ uploadAttachment
24+ } from './lib/api';
25+ import {
26+ banners,
27+ blocked,
28+ connectEvents,
29+ connection,
30+ disconnectEvents,
31+ dismissBanner,
32+ encryption,
33+ membersByRoom,
34+ membersLoading,
35+ pushBanner,
36+ resetClientState,
37+ rooms,
38+ selectedRoomId,
39+ sessionUser,
40+ softLogout,
41+ timelines,
42+ verification
43+ } from './lib/store';
44+ import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
45+ import type { PendingMessage } from './lib/pending';
46+ import { notifyTyping, stopTyping } from './lib/typing';
47+ import { formatDay } from './lib/format';
48+ import { parseEncryptionState, parseOwnProfile, parseVerificationState } from './lib/types';
49+ import type { MemberSummary, OwnProfile, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
50+
51+ let roomsLoading = false;
52+ let booted = false;
53+ let search = '';
54+ let sidebarOpen = false;
55+ let membersOpen = false;
56+ let dialog: 'join' | 'create' | 'security' | null = null;
57+ let joinTarget = '';
58+ let joinBusy = false;
59+ let createName = '';
60+ let createTopic = '';
61+ let createBusy = false;
62+ let leaveBusy = false;
63+ let paginateBusy = false;
64+ let reacting = false;
65+ let composer = '';
66+ let drafts: Record<string, string> = {};
67+ let stickToBottom = true;
68+ let timelineEl: HTMLElement | null = null;
69+ let composerEl: HTMLTextAreaElement | null = null;
70+ let fileInput: HTMLInputElement | null = null;
71+ let uploadBusy = false;
72+ let uploadName: string | null = null;
73+ let openedRoom: string | null = null;
74+ let openReady: Record<string, boolean> = {};
75+ let openFailed: Record<string, string> = {};
76+ let membersFor: string | null = null;
77+ let lastReadKey = '';
78+ let readTimer: number | null = null;
79+ let profile: OwnProfile | null = null;
80+ let profileMxc: string | null = null;
81+ let selectedSpaceId: string | null = null;
82+ let spaceChildIds: string[] | null = null;
83+ let spaceChildrenLoading = false;
84+ let spaceChildrenError: string | null = null;
85+ let avatarByUser: Record<string, string | null> = {};
86+
87+ $: selectedRoom = $rooms.find((r) => r.room_id === $selectedRoomId) ?? null;
88+ $: spaceRooms = $rooms.filter((r) => r.space);
89+ $: invites = $rooms.filter((r) => r.membership === 'Invited');
90+ $: query = search.trim().toLowerCase();
91+ $: selectedSpace = selectedSpaceId ? ($rooms.find((r) => r.room_id === selectedSpaceId) ?? null) : null;
92+ // While a space's children load (or fail), show no channels rather than the
93+ // full list — listing everything would falsely imply it belongs to the space.
94+ $: baseRooms = selectedSpaceId ? (spaceChildIds ? $rooms.filter((r) => spaceChildIds.includes(r.room_id)) : []) : $rooms;
95+ $: visibleRooms = query
96+ ? baseRooms.filter(
97+ (r) =>
98+ r.name.toLowerCase().includes(query) || r.room_id.toLowerCase().includes(query)
99+ )
100+ : baseRooms;
101+ $: directRooms = visibleRooms.filter((r) => r.direct && !r.space && r.membership !== 'Invited');
102+ $: groupRooms = visibleRooms.filter(
103+ (r) => !r.direct && !r.space && r.membership !== 'Invited'
104+ );
105+ $: spaceList = visibleRooms.filter((r) => r.space && r.membership !== 'Invited');
106+ $: channelRooms = baseRooms.filter((r) => !r.space && r.membership !== 'Invited');
107+ $: recoveryBanner = $encryption && ($encryption.recovery === 'Disabled' || $encryption.recovery === 'Incomplete') ? $encryption : null;
108+ $: verificationBanner = $verification && $verification.kind !== 'Done' ? $verification : null;
109+ $: items = $selectedRoomId ? ($timelines[$selectedRoomId] ?? []) : [];
110+ $: members = $selectedRoomId ? ($membersByRoom[$selectedRoomId] ?? []) : [];
111+ $: hasStart = items.some((i) => i.kind === 'start');
112+ $: grouped = buildGroups(items);
113+ $: pendingItems = $selectedRoomId ? ($pendingByRoom[$selectedRoomId] ?? []) : [];
114+ $: lastRealOwn = lastSenderIsOwn(items);
115+
116+ function lastSenderIsOwn(list: TimelineItem[]): boolean {
117+ for (let i = list.length - 1; i >= 0; i -= 1) {
118+ const it = list[i];
119+ if (it.kind === 'event') return it.event.sender.user_id === $sessionUser;
120+ }
121+ return false;
122+ }
123+
124+ function pendingShowSender(index: number): boolean {
125+ return index === 0 ? !lastRealOwn : false;
126+ }
127+
128+ function pendingToEvent(p: PendingMessage): TimelineEvent {
129+ const state: SendState = p.status === 'failed' ? { kind: 'Failed', will_retry: false } : { kind: 'Sending' };
130+ return {
131+ item_id: `pending-${p.id}`,
132+ event_id: null,
133+ transaction_id: p.id,
134+ sender: {
135+ user_id: $sessionUser ?? '',
136+ display_name: profile?.display_name ?? null,
137+ avatar: null,
138+ name_ambiguous: false
139+ },
140+ timestamp_ms: p.createdAt,
141+ own: true,
142+ send_state: state,
143+ content: {
144+ kind: 'Message',
145+ message: { kind: 'Text', body: p.body, geo_uri: null, formatted_body: null, edited: false, media: null }
146+ },
147+ reply_to: null,
148+ thread_root: null,
149+ thread_summary: null,
150+ reactions: [],
151+ read_by: [],
152+ editable: false
153+ };
154+ }
155+
156+ interface Group {
157+ key: string;
158+ item: TimelineItem;
159+ showSender: boolean;
160+ }
161+
162+ function buildGroups(list: TimelineItem[]): Group[] {
163+ const out: Group[] = [];
164+ let prevSender: string | null = null;
165+ let prevTs = 0;
166+ list.forEach((item, index) => {
167+ let showSender = true;
168+ if (item.kind === 'event') {
169+ const e = item.event;
170+ showSender =
171+ prevSender !== e.sender.user_id || Math.abs(e.timestamp_ms - prevTs) > 5 * 60 * 1000;
172+ prevSender = e.sender.user_id;
173+ prevTs = e.timestamp_ms;
174+ out.push({ key: e.item_id || e.event_id || `m-${index}`, item, showSender });
175+ } else {
176+ prevSender = null;
177+ out.push({ key: `${item.kind}-${index}`, item, showSender });
178+ }
179+ });
180+ return out;
181+ }
182+
183+ function roomUnread(r: RoomSummary): number {
184+ return r.unread_messages + r.unread_mentions;
185+ }
186+
187+ onMount(() => {
188+ void boot();
189+ return () => {
190+ disconnectEvents();
191+ stopTyping();
192+ if (readTimer !== null) window.clearTimeout(readTimer);
193+ };
194+ });
195+
196+ async function boot(): Promise<void> {
197+ try {
198+ const session = await getSession();
199+ blocked.set(session.blocked ?? false);
200+ sessionUser.set(session.user_id);
201+ if (session.user_id) {
202+ softLogout.set(null);
203+ await refreshRooms();
204+ connectEvents();
205+ void loadProfile();
206+ void loadEncryption();
207+ } else {
208+ softLogout.set(session.soft_logout ?? null);
209+ }
210+ } catch (e) {
211+ pushBanner(e instanceof Error ? `Session check failed: ${e.message}` : 'Session check failed');
212+ sessionUser.set(null);
213+ } finally {
214+ booted = true;
215+ }
216+ }
217+
218+ async function refreshRooms(): Promise<void> {
219+ roomsLoading = true;
220+ try {
221+ const res = await getRooms();
222+ rooms.set(res.rooms);
223+ pruneSpaceFilter();
224+ } catch (e) {
225+ pushBanner(e instanceof Error ? e.message : 'Could not load rooms');
226+ } finally {
227+ roomsLoading = false;
228+ }
229+ }
230+
231+ async function loadProfile(): Promise<void> {
232+ try {
233+ const raw = await getProfile();
234+ profile = parseOwnProfile(raw);
235+ profileMxc = profile.avatar_url;
236+ } catch {
237+ profile = null;
238+ }
239+ }
240+
241+ async function loadEncryption(): Promise<void> {
242+ try {
243+ const res = await getEncryption();
244+ const state = parseEncryptionState(res.state);
245+ if (state) encryption.set(state);
246+ const verificationState = parseVerificationState(res.verification);
247+ if (verificationState) verification.set(verificationState);
248+ } catch {
249+ // Encryption status also arrives over SSE; a failed first fetch is not fatal.
250+ }
251+ }
252+
253+ function afterLogin(userId: string): void {
254+ sessionUser.set(userId);
255+ softLogout.set(null);
256+ blocked.set(false);
257+ void refreshRooms();
258+ connectEvents();
259+ void loadProfile();
260+ void loadEncryption();
261+ }
262+
263+ async function doLogout(): Promise<void> {
264+ stopTyping();
265+ try {
266+ await logout();
267+ } catch (e) {
268+ pushBanner(e instanceof Error ? e.message : 'Logout failed');
269+ return;
270+ }
271+ disconnectEvents();
272+ resetClientState();
273+ softLogout.set(null);
274+ blocked.set(false);
275+ sessionUser.set(null);
276+ composer = '';
277+ drafts = {};
278+ dialog = null;
279+ membersOpen = false;
280+ sidebarOpen = false;
281+ profile = null;
282+ profileMxc = null;
283+ selectedSpaceId = null;
284+ spaceChildIds = null;
285+ spaceChildrenError = null;
286+ openReady = {};
287+ openFailed = {};
288+ avatarByUser = {};
289+ resetPending();
290+ }
291+
292+ function pruneSpaceFilter(): void {
293+ if (!selectedSpaceId) return;
294+ if (!$rooms.some((r) => r.room_id === selectedSpaceId)) {
295+ selectedSpaceId = null;
296+ spaceChildIds = null;
297+ spaceChildrenError = null;
298+ }
299+ }
300+
301+ async function selectHome(): Promise<void> {
302+ selectedSpaceId = null;
303+ spaceChildIds = null;
304+ spaceChildrenError = null;
305+ sidebarOpen = true;
306+ }
307+
308+ async function selectSpace(spaceId: string): Promise<void> {
309+ if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;
310+ stopTyping();
311+ const prev = openedRoom;
312+ if (prev) {
313+ openedRoom = null;
314+ closeRoom(prev).catch(() => undefined);
315+ }
316+ selectedSpaceId = spaceId;
317+ spaceChildIds = null;
318+ spaceChildrenError = null;
319+ spaceChildrenLoading = true;
320+ sidebarOpen = true;
321+ // A space is navigation, not a message timeline.
322+ selectedRoomId.set(null);
323+ try {
324+ const res = await getSpaceChildren(spaceId);
325+ if (selectedSpaceId === spaceId) spaceChildIds = res.room_ids ?? [];
326+ } catch (e) {
327+ if (selectedSpaceId === spaceId) {
328+ spaceChildrenError = e instanceof Error ? e.message : 'Could not load rooms in this space';
329+ spaceChildIds = [];
330+ }
331+ } finally {
332+ if (selectedSpaceId === spaceId) spaceChildrenLoading = false;
333+ }
334+ }
335+
336+ async function selectRoom(roomId: string): Promise<void> {
337+ if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;
338+ if (roomId === $selectedRoomId && openReady[roomId]) {
339+ sidebarOpen = false;
340+ return;
341+ }
342+ stopTyping();
343+ const prev = openedRoom;
344+ openedRoom = roomId;
345+ selectedRoomId.set(roomId);
346+ composer = drafts[roomId] ?? '';
347+ stickToBottom = true;
348+ lastReadKey = '';
349+ sidebarOpen = false;
350+ openReady = { ...openReady, [roomId]: false };
351+ if (openFailed[roomId]) {
352+ const nextFailed = { ...openFailed };
353+ delete nextFailed[roomId];
354+ openFailed = nextFailed;
355+ }
356+ try {
357+ await openRoom(roomId);
358+ openReady = { ...openReady, [roomId]: true };
359+ } catch (e) {
360+ const message = e instanceof Error ? e.message : `Could not open room ${roomId}`;
361+ openFailed = { ...openFailed, [roomId]: message };
362+ pushBanner(message);
363+ }
364+ if (membersOpen) void loadMembers(roomId);
365+ await tick();
366+ scrollToBottom();
367+ queueReadReceipt();
368+ composerEl?.focus();
369+ }
370+
371+ function scrollToBottom(): void {
372+ if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;
373+ }
374+
375+ function onTimelineScroll(): void {
376+ if (!timelineEl) return;
377+ const gap = timelineEl.scrollHeight - timelineEl.scrollTop - timelineEl.clientHeight;
378+ stickToBottom = gap < 80;
379+ }
380+
381+ $: void trackTimeline($selectedRoomId, items);
382+ async function trackTimeline(roomId: string | null, list: TimelineItem[]): Promise<void> {
383+ if (!roomId || list.length === 0) return;
384+ if (roomId !== $selectedRoomId) return;
385+ await tick();
386+ if (stickToBottom) scrollToBottom();
387+ queueReadReceipt();
388+ }
389+
390+ function queueReadReceipt(): void {
391+ if (readTimer !== null) window.clearTimeout(readTimer);
392+ readTimer = window.setTimeout(() => {
393+ readTimer = null;
394+ void sendReadReceipt();
395+ }, 800);
396+ }
397+
398+ async function sendReadReceipt(): Promise<void> {
399+ const roomId = $selectedRoomId;
400+ if (!roomId) return;
401+ const list = $timelines[roomId] ?? [];
402+ for (let i = list.length - 1; i >= 0; i -= 1) {
403+ const entry = list[i];
404+ if (entry.kind === 'event' && entry.event.event_id) {
405+ const key = `${roomId}:${entry.event.event_id}`;
406+ if (key === lastReadKey) return;
407+ lastReadKey = key;
408+ try {
409+ await markRead(roomId, entry.event.event_id);
410+ } catch {
411+ return;
412+ }
413+ return;
414+ }
415+ }
416+ }
417+
418+ function send(): void {
419+ const roomId = $selectedRoomId;
420+ const body = composer.trim();
421+ if (!roomId || !body) return;
422+ if (!openReady[roomId]) {
423+ pushBanner('Still opening this room — sending is disabled until the room is ready.');
424+ return;
425+ }
426+ composer = '';
427+ if (roomId in drafts) delete drafts[roomId];
428+ stickToBottom = true;
429+ queueSend(roomId, body);
430+ composerEl?.focus();
431+ }
432+
433+ let typedText = '';
434+ let typedRoom: string | null = null;
435+ $: trackTypingInput(composer, $selectedRoomId, openReady);
436+ function trackTypingInput(text: string, roomId: string | null, ready: Record<string, boolean>): void {
437+ const edited = roomId === typedRoom && text !== typedText;
438+ typedText = text;
439+ typedRoom = roomId;
440+ if (!roomId || !ready[roomId] || !text.trim()) {
441+ stopTyping();
442+ } else if (edited) {
443+ notifyTyping(roomId);
444+ }
445+ }
446+
447+ $: void trackPending($selectedRoomId, pendingItems);
448+ async function trackPending(roomId: string | null, list: PendingMessage[]): Promise<void> {
449+ if (!roomId || list.length === 0) return;
450+ if (roomId !== $selectedRoomId) return;
451+ await tick();
452+ if (stickToBottom) scrollToBottom();
453+ }
454+
455+ function onComposerKey(ev: KeyboardEvent): void {
456+ if (ev.key === 'Enter' && !ev.shiftKey) {
457+ ev.preventDefault();
458+ send();
459+ }
460+ }
461+
462+ async function upload(): Promise<void> {
463+ const roomId = $selectedRoomId;
464+ if (!roomId || uploadBusy || !openReady[roomId]) return;
465+ fileInput?.click();
466+ }
467+
468+ async function onFileChosen(): Promise<void> {
469+ const roomId = $selectedRoomId;
470+ const files = fileInput?.files;
471+ if (!roomId || !files || files.length === 0) return;
472+ const file = files[0];
473+ if (file.size === 0 || file.size > 20 * 1024 * 1024) {
474+ pushBanner('File must be between 1 byte and 20 MiB.');
475+ if (fileInput) fileInput.value = '';
476+ return;
477+ }
478+ uploadBusy = true;
479+ uploadName = file.name || 'attachment';
480+ try {
481+ await uploadAttachment(roomId, file);
482+ stickToBottom = true;
483+ } catch (e) {
484+ pushBanner(e instanceof Error ? e.message : 'Upload failed');
485+ } finally {
486+ uploadBusy = false;
487+ uploadName = null;
488+ if (fileInput) fileInput.value = '';
489+ }
490+ }
491+ async function react(eventId: string, key: string): Promise<void> {
492+ const roomId = $selectedRoomId;
493+ if (!roomId || !eventId || reacting) return;
494+ reacting = true;
495+ try {
496+ await reactToMessage(roomId, eventId, key);
497+ } catch (e) {
498+ pushBanner(e instanceof Error ? e.message : 'Reaction failed');
499+ } finally {
500+ reacting = false;
501+ }
502+ }
503+
504+ async function paginate(): Promise<void> {
505+ const roomId = $selectedRoomId;
506+ if (!roomId || paginateBusy) return;
507+ paginateBusy = true;
508+ try {
509+ await paginateRoom(roomId);
510+ } catch (e) {
511+ pushBanner(e instanceof Error ? e.message : 'Loading older messages failed');
512+ } finally {
513+ paginateBusy = false;
514+ }
515+ }
516+
517+ async function loadMembers(roomId: string): Promise<void> {
518+ if (membersFor === roomId && $membersLoading) return;
519+ membersFor = roomId;
520+ membersLoading.set(true);
521+ try {
522+ const res = await getMembers(roomId);
523+ membersByRoom.update((all) => ({ ...all, [roomId]: res.members }));
524+ avatarByUser = { ...avatarByUser, ...avatarMap(res.members) };
525+ } catch (e) {
526+ pushBanner(e instanceof Error ? e.message : 'Could not load members');
527+ } finally {
528+ if (membersFor === roomId) membersLoading.set(false);
529+ }
530+ }
531+
532+ function avatarMap(list: MemberSummary[]): Record<string, string | null> {
533+ const next: Record<string, string | null> = {};
534+ for (const member of list) next[member.user_id] = member.avatar;
535+ return next;
536+ }
537+
538+ function senderAvatarFor(userId: string): string | null {
539+ return avatarByUser[userId] ?? null;
540+ }
541+
542+ function toggleMembers(): void {
543+ membersOpen = !membersOpen;
544+ if (membersOpen && $selectedRoomId) void loadMembers($selectedRoomId);
545+ }
546+
547+ async function join(target: string): Promise<void> {
548+ const room = target.trim();
549+ if (!room || joinBusy) return;
550+ joinBusy = true;
551+ try {
552+ const res = await joinRoom(room);
553+ joinTarget = '';
554+ dialog = null;
555+ await refreshRooms();
556+ await selectRoom(res.room_id);
557+ } catch (e) {
558+ pushBanner(e instanceof Error ? e.message : 'Join failed');
559+ } finally {
560+ joinBusy = false;
561+ }
562+ }
563+
564+ async function create(): Promise<void> {
565+ const name = createName.trim();
566+ if (!name || createBusy) return;
567+ createBusy = true;
568+ try {
569+ const res = await createRoom(name, createTopic.trim() || undefined);
570+ createName = '';
571+ createTopic = '';
572+ dialog = null;
573+ await refreshRooms();
574+ await selectRoom(res.room_id);
575+ } catch (e) {
576+ pushBanner(e instanceof Error ? e.message : 'Create room failed');
577+ } finally {
578+ createBusy = false;
579+ }
580+ }
581+
582+ async function leave(): Promise<void> {
583+ const roomId = $selectedRoomId;
584+ if (!roomId || leaveBusy) return;
585+ leaveBusy = true;
586+ stopTyping();
587+ try {
588+ await leaveRoom(roomId);
589+ const prev = openedRoom;
590+ if (prev === roomId) openedRoom = null;
591+ if ($selectedRoomId === roomId) selectedRoomId.set(null);
592+ if (prev) closeRoom(prev).catch(() => undefined);
593+ await refreshRooms();
594+ } catch (e) {
595+ pushBanner(e instanceof Error ? e.message : 'Leave failed');
596+ } finally {
597+ leaveBusy = false;
598+ }
599+ }
600+
601+ function connectionClass(state: string): string {
602+ if (state === 'online') return 'dot-online';
603+ if (state === 'connecting' || state === 'reconnecting') return 'dot-busy';
604+ return 'dot-off';
605+ }
606+
607+ function onKeydown(ev: KeyboardEvent): void {
608+ if (ev.key === 'Escape') {
609+ if (dialog) dialog = null;
610+ else if (membersOpen && window.innerWidth < 1100) membersOpen = false;
611+ else if (sidebarOpen) sidebarOpen = false;
612+ }
613+ }
614+</script>
615+
616+<svelte:window on:keydown={onKeydown} />
617+
618+{#if !booted}
619+ <div class="splash" role="status" aria-label="Loading Malice">
620+ <div class="splash-mark" aria-hidden="true">M</div>
621+ <p>Loading Malice…</p>
622+ </div>
623+{:else if $blocked}
624+ <div class="splash" role="status" aria-label="Malice is open in another tab">
625+ <div class="splash-mark" aria-hidden="true">M</div>
626+ <p>Malice is open in another tab. Close this tab or use that one instead.</p>
627+ </div>
628+{:else if $sessionUser == null}
629+ <LoginPanel softLogout={$softLogout} on:done={(ev) => afterLogin(ev.detail)} />
630+{:else}
631+ <div class="app">
632+ <nav class="rail" aria-label="Servers and spaces">
633+ <button
634+ type="button"
635+ class="rail-btn"
636+ class:active={selectedSpaceId === null}
637+ title="Home — all channels"
638+ aria-label="Home — all channels"
639+ aria-pressed={selectedSpaceId === null}
640+ on:click={() => void selectHome()}
641+ >
642+ <span aria-hidden="true">⌂</span>
643+ </button>
644+ <div class="rail-sep" aria-hidden="true"></div>
645+ {#each spaceRooms as space (space.room_id)}
646+ <button
647+ type="button"
648+ class="rail-btn rail-space"
649+ class:active={selectedSpaceId === space.room_id}
650+ title={space.name}
651+ aria-label={`Show channels in space ${space.name}`}
652+ aria-pressed={selectedSpaceId === space.room_id}
653+ on:click={() => void selectSpace(space.room_id)}
654+ >
655+ <MxcAvatar mxc={space.avatar} label={space.name} size={44} shape="rounded" />
656+ </button>
657+ {/each}
658+ <button
659+ type="button"
660+ class="rail-btn rail-add"
661+ aria-label="Join or create a room"
662+ on:click={() => {
663+ dialog = 'join';
664+ }}
665+ >
666+ <span aria-hidden="true">+</span>
667+ </button>
668+ </nav>
669+
670+ <aside class="sidebar" class:open={sidebarOpen} aria-label="Rooms">
671+ <div class="side-search">
672+ <label class="visually-hidden" for="room-search">Search rooms</label>
673+ <input
674+ id="room-search"
675+ type="search"
676+ placeholder="Search rooms"
677+ bind:value={search}
678+ autocomplete="off"
679+ />
680+ <button
681+ type="button"
682+ class="icon-btn"
683+ title="Refresh rooms"
684+ aria-label="Refresh rooms"
685+ disabled={roomsLoading}
686+ on:click={() => void refreshRooms()}
687+ >
688+ <span aria-hidden="true">⟳</span>
689+ </button>
690+ </div>
691+ <div class="side-scroll">
692+ {#if selectedSpace}
693+ <div class="space-context">
694+ <MxcAvatar mxc={selectedSpace.avatar} label={selectedSpace.name} size={28} shape="rounded" />
695+ <div class="space-context-text">
696+ <strong>{selectedSpace.name}</strong>
697+ <span>Space — channels below</span>
698+ </div>
699+ </div>
700+ {:else}
701+ <div class="space-context">
702+ <div class="space-context-text">
703+ <strong>Home</strong>
704+ <span>All channels</span>
705+ </div>
706+ </div>
707+ {/if}
708+ {#if roomsLoading && $rooms.length === 0}
709+ <p class="side-note">Loading rooms…</p>
710+ {:else if $rooms.length === 0}
711+ <p class="side-note">No rooms yet. Join one or create your own.</p>
712+ <div class="side-cta">
713+ <button type="button" on:click={() => (dialog = 'join')}>Join a room</button>
714+ <button type="button" on:click={() => (dialog = 'create')}>Create one</button>
715+ </div>
716+ {:else}
717+ {#if selectedSpaceId && spaceChildrenLoading}
718+ <p class="side-note">Loading channels in this space…</p>
719+ {:else if selectedSpaceId && spaceChildrenError}
720+ <p class="side-note" role="alert">{spaceChildrenError}</p>
721+ <div class="side-cta">
722+ <button type="button" on:click={() => selectedSpaceId && void selectSpace(selectedSpaceId)}>Retry</button>
723+ <button type="button" on:click={() => void selectHome()}>Back to Home</button>
724+ </div>
725+ {:else if selectedSpaceId && spaceChildIds && channelRooms.length === 0}
726+ <p class="side-note">No channels found in this space yet.</p>
727+ <div class="side-cta">
728+ <button type="button" on:click={() => void selectHome()}>Back to Home</button>
729+ </div>
730+ {:else}
731+ {#if invites.length > 0 && !selectedSpaceId}
732+ <h2 class="side-h">Invites</h2>
733+ <ul class="room-list">
734+ {#each invites as room (room.room_id)}
735+ <li>
736+ <button
737+ type="button"
738+ class="room-row"
739+ class:active={$selectedRoomId === room.room_id}
740+ on:click={() => void selectRoom(room.room_id)}
741+ >
742+ <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
743+ <span class="room-name">{room.name}</span>
744+ <span
745+ class="invite-join"
746+ role="button"
747+ tabindex="0"
748+ aria-label={`Join ${room.name}`}
749+ on:click|stopPropagation={() => void join(room.room_id)}
750+ on:keydown|stopPropagation={(ev) => {
751+ if (ev.key === 'Enter' || ev.key === ' ') {
752+ ev.preventDefault();
753+ void join(room.room_id);
754+ }
755+ }}
756+ >
757+ Join
758+ </span>
759+ </button>
760+ </li>
761+ {/each}
762+ </ul>
763+ {/if}
764+
765+ {#if directRooms.length > 0}
766+ <h2 class="side-h">Direct messages</h2>
767+ <ul class="room-list">
768+ {#each directRooms as room (room.room_id)}
769+ <li>
770+ <button
771+ type="button"
772+ class="room-row"
773+ class:active={$selectedRoomId === room.room_id}
774+ aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
775+ on:click={() => void selectRoom(room.room_id)}
776+ >
777+ <MxcAvatar mxc={room.avatar} label={room.name} size={26} />
778+ <span class="room-name">{room.name}</span>
779+ {#if roomUnread(room) > 0}
780+ <span
781+ class="badge"
782+ class:mention={room.unread_mentions > 0}
783+ aria-label={`${roomUnread(room)} unread`}
784+ >
785+ {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
786+ </span>
787+ {/if}
788+ </button>
789+ </li>
790+ {/each}
791+ </ul>
792+ {/if}
793+
794+ {#if groupRooms.length > 0}
795+ <h2 class="side-h">{selectedSpace ? 'Channels in this space' : 'Rooms'}</h2>
796+ <ul class="room-list">
797+ {#each groupRooms as room (room.room_id)}
798+ <li>
799+ <button
800+ type="button"
801+ class="room-row"
802+ class:active={$selectedRoomId === room.room_id}
803+ aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
804+ on:click={() => void selectRoom(room.room_id)}
805+ >
806+ <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
807+ <span class="room-hash" aria-hidden="true">#</span>
808+ <span class="room-name">{room.name}</span>
809+ {#if roomUnread(room) > 0}
810+ <span
811+ class="badge"
812+ class:mention={room.unread_mentions > 0}
813+ aria-label={`${roomUnread(room)} unread`}
814+ >
815+ {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
816+ </span>
817+ {/if}
818+ </button>
819+ </li>
820+ {/each}
821+ </ul>
822+ {/if}
823+
824+ {#if !selectedSpaceId && spaceList.length > 0}
825+ <h2 class="side-h">Spaces</h2>
826+ <ul class="room-list">
827+ {#each spaceList as room (room.room_id)}
828+ <li>
829+ <button
830+ type="button"
831+ class="room-row"
832+ class:active={selectedSpaceId === room.room_id}
833+ aria-label={`Show channels in space ${room.name}`}
834+ on:click={() => void selectSpace(room.room_id)}
835+ >
836+ <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
837+ <span class="room-hash" aria-hidden="true">◇</span>
838+ <span class="room-name">{room.name}</span>
839+ </button>
840+ </li>
841+ {/each}
842+ </ul>
843+ {/if}
844+
845+ {#if query && visibleRooms.length === 0}
846+ <p class="side-note">No rooms match “{search.trim()}”.</p>
847+ {/if}
848+ {/if}
849+ {/if}
850+ </div>
851+
852+ <div class="side-footer">
853+ <div class="me" title={$sessionUser}>
854+ {#if profileMxc}
855+ <MxcAvatar mxc={profileMxc} label={profile?.display_name ?? $sessionUser} size={30} />
856+ {:else}
857+ <span class="me-avatar" aria-hidden="true">{$sessionUser.slice(1, 2).toUpperCase()}</span>
858+ {/if}
859+ <span class="me-id">{profile?.display_name && profile.display_name !== $sessionUser ? `${profile.display_name} (${$sessionUser})` : $sessionUser}</span>
860+ </div>
861+ <button
862+ type="button"
863+ class="icon-btn"
864+ title="Encryption and verification"
865+ aria-label="Encryption and verification"
866+ on:click={() => (dialog = 'security')}
867+ >
868+ <span aria-hidden="true">🛡</span>
869+ </button>
870+ <button type="button" class="icon-btn" title="Log out" aria-label="Log out" on:click={() => void doLogout()}>
871+ <span aria-hidden="true">⏻</span>
872+ </button>
873+ </div>
874+ </aside>
875+ {#if sidebarOpen}
876+ <button type="button" class="scrim" aria-label="Close room list" on:click={() => (sidebarOpen = false)}></button>
877+ {/if}
878+
879+ <main class="chat">
880+ {#if $banners.length > 0}
881+ <div class="banners" aria-live="polite">
882+ {#each $banners as banner (banner.id)}
883+ <div class="banner" role="alert">
884+ <span>{banner.message}</span>
885+ <button
886+ type="button"
887+ aria-label="Dismiss error"
888+ on:click={() => dismissBanner(banner.id)}
889+ >
890+ ✕
891+ </button>
892+ </div>
893+ {/each}
894+ </div>
895+ {/if}
896+ {#if !selectedRoom}
897+ <div class="empty">
898+ <div class="empty-mark" aria-hidden="true">◈</div>
899+ {#if selectedSpace}
900+ <h1>{selectedSpace.name}</h1>
901+ <p>Select a channel below to start chatting. Spaces group channels — they are not chat rooms.</p>
902+ {:else}
903+ <h1>Welcome to Malice</h1>
904+ <p>Pick a room from the sidebar, or join one to get started.</p>
905+ {/if}
906+ <div class="empty-cta">
907+ <button type="button" class="primary-btn" on:click={() => (dialog = 'join')}>Join a room</button>
908+ <button type="button" class="ghost-btn" on:click={() => (dialog = 'create')}>Create a room</button>
909+ </div>
910+ <p class="conn-line">
911+ <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>
912+ {$connection}
913+ </p>
914+ </div>
915+ {:else}
916+ <header class="chat-head">
917+ <button
918+ type="button"
919+ class="icon-btn only-mobile"
920+ aria-label="Open room list"
921+ on:click={() => (sidebarOpen = true)}
922+ >
923+ <span aria-hidden="true">☰</span>
924+ </button>
925+ <div class="head-title">
926+ <div class="head-room">
927+ <MxcAvatar mxc={selectedRoom.avatar} label={selectedRoom.name} size={36} shape={selectedRoom.space ? 'rounded' : 'circle'} />
928+ <div class="head-room-text">
929+ <h1>{selectedRoom.name}</h1>
930+ <div class="head-meta">
931+ {#if selectedRoom.space}
932+ <span class="pill">space</span>
933+ {/if}
934+ {#if selectedRoom.encrypted}
935+ <span class="pill" title="End-to-end encrypted">🔒 encrypted</span>
936+ {/if}
937+ {#if selectedRoom.direct}
938+ <span class="pill">direct</span>
939+ {/if}
940+ <span class="conn" title={`Sync state: ${$connection}`}>
941+ <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>
942+ {$connection}
943+ </span>
944+ </div>
945+ </div>
946+ </div>
947+ </div>
948+ <div class="head-actions">
949+ <button
950+ type="button"
951+ class="ghost-btn"
952+ aria-label="Encryption and verification"
953+ title="Encryption and verification"
954+ on:click={() => (dialog = 'security')}
955+ >
956+ 🛡 Security
957+ </button>
958+ <button
959+ type="button"
960+ class="ghost-btn"
961+ aria-pressed={membersOpen}
962+ aria-label={membersOpen ? 'Hide members' : 'Show members'}
963+ on:click={toggleMembers}
964+ >
965+ Members
966+ </button>
967+ {#if selectedRoom.membership === 'Joined'}
968+ <button
969+ type="button"
970+ class="ghost-btn danger"
971+ disabled={leaveBusy}
972+ on:click={() => void leave()}
973+ >
974+ {leaveBusy ? 'Leaving…' : 'Leave'}
975+ </button>
976+ {:else}
977+ <button
978+ type="button"
979+ class="primary-btn"
980+ disabled={joinBusy}
981+ on:click={() => void join(selectedRoom.room_id)}
982+ >
983+ {joinBusy ? 'Joining…' : 'Join'}
984+ </button>
985+ {/if}
986+ <button
987+ type="button"
988+ class="icon-btn only-mobile"
989+ aria-label="Toggle members"
990+ on:click={toggleMembers}
991+ >
992+ <span aria-hidden="true">ⓘ</span>
993+ </button>
994+ </div>
995+ </header>
996+
997+
998+ {#if recoveryBanner}
999+ <div class="sec-banner warn" role="status">
1000+ <span>Message history is locked — recovery is {recoveryBanner.recovery}. Enter your recovery key to decrypt.</span>
1001+ <button type="button" on:click={() => (dialog = 'security')}>Recover</button>
1002+ </div>
1003+ {/if}
1004+ {#if verificationBanner}
1005+ <div class="sec-banner" role="status">
1006+ {#if verificationBanner.kind === 'Incoming'}
1007+ <span>A device wants to verify this session. Review the request to accept it.</span>
1008+ <button type="button" on:click={() => (dialog = 'security')}>Review request</button>
1009+ {:else if verificationBanner.kind === 'Comparison'}
1010+ <span>Verification codes are ready — compare emojis and numbers.</span>
1011+ <button type="button" on:click={() => (dialog = 'security')}>Compare codes</button>
1012+ {:else if verificationBanner.kind === 'Waiting'}
1013+ <span>Verification started — approve on your other device, then compare codes here.</span>
1014+ <button type="button" on:click={() => (dialog = 'security')}>Open verification</button>
1015+ {:else if verificationBanner.kind === 'Failed'}
1016+ <span>Verification failed: {verificationBanner.message}</span>
1017+ <button type="button" on:click={() => (dialog = 'security')}>Try again</button>
1018+ {:else if verificationBanner.kind === 'Cancelled'}
1019+ <span>Verification cancelled: {verificationBanner.reason}</span>
1020+ <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
1021+ {:else}
1022+ <span>Verification in progress.</span>
1023+ <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
1024+ {/if}
1025+ </div>
1026+ {/if}
1027+ {#if $connection !== 'online'}
1028+ <p class="conn-banner" role="status">
1029+ Sync is {$connection}. New messages will appear when the connection recovers.
1030+ </p>
1031+ {/if}
1032+ {#if $selectedRoomId && !openReady[$selectedRoomId] && !openFailed[$selectedRoomId]}
1033+ <p class="conn-banner" role="status">Opening {selectedRoom.name}… sending is disabled until the room is ready.</p>
1034+ {/if}
1035+ {#if $selectedRoomId && openFailed[$selectedRoomId]}
1036+ <p class="conn-banner error" role="alert">{openFailed[$selectedRoomId]}</p>
1037+ {/if}
1038+
1039+ <div class="timeline" bind:this={timelineEl} on:scroll={onTimelineScroll} aria-label={`Messages in ${selectedRoom.name}`}>
1040+ <div class="timeline-top">
1041+ {#if hasStart}
1042+ <p class="start-note">This is the beginning of the visible history.</p>
1043+ {:else}
1044+ <button type="button" class="ghost-btn" disabled={paginateBusy} on:click={() => void paginate()}>
1045+ {paginateBusy ? 'Loading…' : 'Load older messages'}
1046+ </button>
1047+ {/if}
1048+ </div>
1049+ {#if grouped.length === 0}
1050+ <p class="side-note timeline-empty">No messages yet. Say hello!</p>
1051+ {:else}
1052+ {#each grouped as g (g.key)}
1053+ {#if g.item.kind === 'day'}
1054+ <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>
1055+ {:else if g.item.kind === 'readMarker'}
1056+ <div class="new-row"><span>New</span></div>
1057+ {:else if g.item.kind === 'start'}
1058+ <p class="start-note">This is the beginning of the visible history.</p>
1059+ {:else if g.item.kind === 'unknown'}
1060+ <p class="unknown-note">Unsupported event ({g.item.label})</p>
1061+ {:else}
1062+ <MessageRow
1063+ itemId={g.key}
1064+ event={g.item.event}
1065+ showSender={g.showSender}
1066+ senderAvatar={senderAvatarFor(g.item.event.sender.user_id)}
1067+ {reacting}
1068+ onReact={(eventId, key) => void react(eventId, key)}
1069+ />
1070+ {/if}
1071+ {/each}
1072+ {/if}
1073+ {#each pendingItems as p, i (p.id)}
1074+ <MessageRow
1075+ itemId={`pending-${p.id}`}
1076+ event={pendingToEvent(p)}
1077+ showSender={pendingShowSender(i)}
1078+ senderAvatar={senderAvatarFor($sessionUser ?? '')}
1079+ {reacting}
1080+ onReact={(eventId, key) => void react(eventId, key)}
1081+ />
1082+ {#if p.status === 'failed'}
1083+ <div class="pending-actions">
1084+ <span class="pending-error" role="alert">{p.error}</span>
1085+ <button type="button" class="ghost-btn" on:click={() => retryPending(p.id)}>Retry</button>
1086+ <button type="button" class="ghost-btn danger" on:click={() => discardPending(p.id)}>Discard</button>
1087+ </div>
1088+ {/if}
1089+ {/each}
1090+ </div>
1091+
1092+ {#if selectedRoom.membership === 'Joined'}
1093+ <form class="composer" on:submit|preventDefault={() => send()}>
1094+ <label class="visually-hidden" for="composer">Message {selectedRoom.name}</label>
1095+ <input
1096+ type="file"
1097+ bind:this={fileInput}
1098+ class="visually-hidden"
1099+ aria-label="Attach a file"
1100+ on:change={() => void onFileChosen()}
1101+ />
1102+ <button
1103+ type="button"
1104+ class="ghost-btn"
1105+ title="Attach an image or file"
1106+ aria-label="Attach an image or file"
1107+ disabled={!($selectedRoomId && openReady[$selectedRoomId]) || uploadBusy}
1108+ on:click={() => void upload()}
1109+ >
1110+ {uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : '📎'}
1111+ </button>
1112+ <textarea
1113+ id="composer"
1114+ bind:this={composerEl}
1115+ bind:value={composer}
1116+ rows="1"
1117+ placeholder={`Message ${selectedRoom.name}`}
1118+ aria-label={`Message ${selectedRoom.name}. Enter to send, Shift Enter for a new line.`}
1119+ on:keydown={onComposerKey}
1120+ disabled={!($selectedRoomId && openReady[$selectedRoomId])}
1121+ ></textarea>
1122+ <button
1123+ type="submit"
1124+ class="primary-btn"
1125+ disabled={!composer.trim() || !($selectedRoomId && openReady[$selectedRoomId])}
1126+ title={!($selectedRoomId && openReady[$selectedRoomId]) ? 'Waiting for the room to open' : 'Send message'}
1127+ >
1128+ Send
1129+ </button>
1130+ </form>
1131+ <p class="composer-note">Sending and failed states show under your message.</p>
1132+ {:else}
1133+ <div class="join-bar">
1134+ <p>You are not a member of this room.</p>
1135+ <button type="button" class="primary-btn" disabled={joinBusy} on:click={() => void join(selectedRoom.room_id)}>
1136+ {joinBusy ? 'Joining…' : `Join ${selectedRoom.name}`}
1137+ </button>
1138+ </div>
1139+ {/if}
1140+ {/if}
1141+ </main>
1142+
1143+ {#if selectedRoom && membersOpen}
1144+ <aside class="members" aria-label={`Members of ${selectedRoom.name}`}>
1145+ <div class="members-head">
1146+ <h2>Members</h2>
1147+ <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>
1148+ <span aria-hidden="true">✕</span>
1149+ </button>
1150+ </div>
1151+ {#if $membersLoading && members.length === 0}
1152+ <p class="side-note">Loading members…</p>
1153+ {:else if members.length === 0}
1154+ <p class="side-note">No members loaded.</p>
1155+ {#if $selectedRoomId}
1156+ <button type="button" class="ghost-btn" on:click={() => $selectedRoomId && void loadMembers($selectedRoomId)}>
1157+ Retry
1158+ </button>
1159+ {/if}
1160+ {:else}
1161+ <ul class="member-list">
1162+ {#each members as member (member.user_id)}
1163+ <li class="member">
1164+ <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} size={30} />
1165+ <span class="member-names">
1166+ <span class="member-name">{member.display_name || member.name}</span>
1167+ <span class="member-id">{member.user_id}</span>
1168+ </span>
1169+ </li>
1170+ {/each}
1171+ </ul>
1172+ {/if}
1173+ </aside>
1174+ {/if}
1175+
1176+ {#if dialog}
1177+ <div class="modal-wrap" role="presentation" on:click|self={() => (dialog = null)}>
1178+ {#if dialog === 'security'}
1179+ <SecurityPanel
1180+ encryptionState={$encryption}
1181+ verificationState={$verification}
1182+ onAction={() => void loadEncryption()}
1183+ on:close={() => (dialog = null)}
1184+ />
1185+ {:else}
1186+ <div class="modal" role="dialog" aria-modal="true" aria-label={dialog === 'join' ? 'Join a room' : 'Create a room'}>
1187+ <div class="modal-tabs" role="tablist" aria-label="Room actions">
1188+ <button
1189+ type="button"
1190+ role="tab"
1191+ aria-selected={dialog === 'join'}
1192+ class:active={dialog === 'join'}
1193+ on:click={() => (dialog = 'join')}
1194+ >
1195+ Join
1196+ </button>
1197+ <button
1198+ type="button"
1199+ role="tab"
1200+ aria-selected={dialog === 'create'}
1201+ class:active={dialog === 'create'}
1202+ on:click={() => (dialog = 'create')}
1203+ >
1204+ Create
1205+ </button>
1206+ <button
1207+ type="button"
1208+ class="icon-btn modal-x"
1209+ aria-label="Close dialog"
1210+ on:click={() => (dialog = null)}
1211+ >
1212+ <span aria-hidden="true">✕</span>
1213+ </button>
1214+ </div>
1215+ {#if dialog === 'join'}
1216+ <form on:submit|preventDefault={() => void join(joinTarget)}>
1217+ <label class="field">
1218+ <span>Room ID or alias</span>
1219+ <input
1220+ type="text"
1221+ placeholder="!room:example.org or #alias:example.org"
1222+ bind:value={joinTarget}
1223+ required
1224+ disabled={joinBusy}
1225+ />
1226+ </label>
1227+ <button type="submit" class="primary-btn wide" disabled={joinBusy || !joinTarget.trim()}>
1228+ {joinBusy ? 'Joining…' : 'Join room'}
1229+ </button>
1230+ </form>
1231+ {:else}
1232+ <form on:submit|preventDefault={() => void create()}>
1233+ <label class="field">
1234+ <span>Room name</span>
1235+ <input
1236+ type="text"
1237+ placeholder="Weekend plans"
1238+ bind:value={createName}
1239+ required
1240+ disabled={createBusy}
1241+ />
1242+ </label>
1243+ <label class="field">
1244+ <span>Topic (optional)</span>
1245+ <input
1246+ type="text"
1247+ placeholder="What should this room be about?"
1248+ bind:value={createTopic}
1249+ disabled={createBusy}
1250+ />
1251+ </label>
1252+ <button type="submit" class="primary-btn wide" disabled={createBusy || !createName.trim()}>
1253+ {createBusy ? 'Creating…' : 'Create room'}
1254+ </button>
1255+ </form>
1256+ {/if}
1257+ </div>
1258+ {/if}
1259+ </div>
1260+ {/if}
1261+ </div>
1262+{/if}
Added file: web/src/app.css+556 additions
@@ -0,0 +1,556 @@
1+:root {
2+ --bg: #14151c;
3+ --bg-soft: #1b1d27;
4+ --panel: #1e2029;
5+ --panel-2: #232634;
6+ --border: #2e3142;
7+ --text: #e6e8f0;
8+ --muted: #9aa0b4;
9+ --accent: #5865f2;
10+ --danger: #f26d6d;
11+ --warn: #e8b25a;
12+ color-scheme: dark;
13+}
14+
15+* { box-sizing: border-box; }
16+
17+html, body, #app {
18+ margin: 0;
19+ min-height: 100dvh;
20+}
21+
22+body {
23+ background: var(--bg);
24+ color: var(--text);
25+ font: 14px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
26+}
27+
28+button { font: inherit; }
29+input, textarea { font: inherit; }
30+
31+.visually-hidden {
32+ position: absolute;
33+ width: 1px; height: 1px;
34+ overflow: hidden;
35+ clip: rect(0 0 0 0);
36+ white-space: nowrap;
37+}
38+
39+.splash {
40+ min-height: 100dvh;
41+ display: flex;
42+ flex-direction: column;
43+ gap: 12px;
44+ align-items: center;
45+ justify-content: center;
46+ color: var(--muted);
47+}
48+.splash-mark {
49+ width: 52px; height: 52px;
50+ border-radius: 16px;
51+ display: grid; place-items: center;
52+ font-size: 26px; font-weight: 800; color: #fff;
53+ background: linear-gradient(135deg, var(--accent), #7c5cff);
54+}
55+
56+.app {
57+ display: grid;
58+ grid-template-columns: 68px 300px 1fr;
59+ height: 100dvh;
60+ overflow: hidden;
61+}
62+.app:has(.members) { grid-template-columns: 68px 300px 1fr 260px; }
63+
64+/* ——— Rail ——— */
65+.rail {
66+ background: #101117;
67+ display: flex;
68+ flex-direction: column;
69+ align-items: center;
70+ gap: 8px;
71+ padding: 12px 0;
72+ overflow-y: auto;
73+}
74+.rail-sep { width: 32px; height: 2px; border-radius: 2px; background: var(--border); }
75+.rail-btn {
76+ width: 46px; height: 46px;
77+ border-radius: 50%;
78+ border: none;
79+ background: var(--panel-2);
80+ color: var(--text);
81+ font-size: 19px;
82+ font-weight: 700;
83+ cursor: pointer;
84+ transition: border-radius 0.15s, background 0.15s;
85+ display: grid;
86+ place-items: center;
87+ overflow: hidden;
88+ padding: 0;
89+}
90+.rail-btn:hover { border-radius: 16px; background: var(--accent); color: #fff; }
91+.rail-btn.active { border-radius: 16px; background: var(--accent); color: #fff; }
92+.rail-btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
93+.rail-avatar { width: 100%; height: 100%; object-fit: cover; display: block; }
94+.rail-add { color: var(--muted); }
95+.rail-add:hover { color: #fff; }
96+.space-context {
97+ display: flex;
98+ align-items: center;
99+ gap: 10px;
100+ padding: 10px 8px 4px;
101+}
102+.space-context-text { display: flex; flex-direction: column; min-width: 0; }
103+.space-context-text strong { font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
104+.space-context-text span { font-size: 11.5px; color: var(--muted); }
105+.sec-banner {
106+ display: flex;
107+ align-items: center;
108+ gap: 10px;
109+ margin: 10px 16px 0;
110+ padding: 8px 12px;
111+ background: rgba(88, 101, 242, 0.14);
112+ border: 1px solid var(--accent);
113+ color: var(--text);
114+ border-radius: 8px;
115+ font-size: 12.5px;
116+}
117+.sec-banner.warn { background: #33300f; border-color: #5c551f; color: #ecd98a; }
118+.sec-banner button {
119+ margin-left: auto;
120+ flex: none;
121+ background: transparent;
122+ border: 1px solid currentColor;
123+ border-radius: 6px;
124+ color: inherit;
125+ cursor: pointer;
126+ padding: 3px 10px;
127+ font-size: 12.5px;
128+}
129+.composer-note { margin: 0; padding: 0 16px 10px; font-size: 11.5px; color: var(--muted); }
130+.head-room { display: flex; align-items: center; gap: 12px; min-width: 0; }
131+.head-room-text { min-width: 0; flex: 1; }
132+.conn-banner.error { background: #3a1d22; border-color: #6b2a33; color: #ffc9ce; }
133+/* ——— Sidebar ——— */
134+.sidebar {
135+ background: var(--panel);
136+ border-right: 1px solid var(--border);
137+ display: flex;
138+ flex-direction: column;
139+ min-height: 0;
140+}
141+.side-search {
142+ display: flex;
143+ gap: 6px;
144+ padding: 12px;
145+ border-bottom: 1px solid var(--border);
146+}
147+.side-search input {
148+ flex: 1;
149+ min-width: 0;
150+ background: var(--bg-soft);
151+ border: 1px solid var(--border);
152+ border-radius: 8px;
153+ color: var(--text);
154+ padding: 7px 10px;
155+}
156+.side-search input:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
157+.side-scroll { flex: 1; overflow-y: auto; padding: 8px; min-height: 0; }
158+.side-h {
159+ font-size: 11px;
160+ text-transform: uppercase;
161+ letter-spacing: 0.08em;
162+ color: var(--muted);
163+ margin: 12px 6px 6px;
164+}
165+.side-h:first-child { margin-top: 4px; }
166+.room-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
167+.room-row {
168+ width: 100%;
169+ display: flex;
170+ align-items: center;
171+ gap: 6px;
172+ background: transparent;
173+ border: none;
174+ border-radius: 8px;
175+ color: var(--text);
176+ padding: 7px 9px;
177+ cursor: pointer;
178+ text-align: left;
179+ font-size: 13.5px;
180+}
181+.room-row:hover { background: var(--panel-2); }
182+.room-row.active { background: rgba(88, 101, 242, 0.2); }
183+.room-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
184+.room-hash { color: var(--muted); }
185+.room-name {
186+ flex: 1;
187+ min-width: 0;
188+ overflow: hidden;
189+ text-overflow: ellipsis;
190+ white-space: nowrap;
191+}
192+.badge {
193+ flex: none;
194+ min-width: 20px;
195+ height: 20px;
196+ padding: 0 6px;
197+ border-radius: 999px;
198+ background: var(--panel-2);
199+ border: 1px solid var(--border);
200+ color: var(--text);
201+ font-size: 11px;
202+ font-weight: 700;
203+ display: inline-grid;
204+ place-items: center;
205+}
206+.badge.mention { background: var(--accent); border-color: var(--accent); color: #fff; }
207+.invite-join {
208+ flex: none;
209+ background: var(--accent);
210+ color: #fff;
211+ border-radius: 6px;
212+ padding: 2px 10px;
213+ font-size: 12px;
214+ font-weight: 600;
215+ cursor: pointer;
216+}
217+.side-note { color: var(--muted); font-size: 13px; padding: 8px 6px; }
218+.side-cta { display: flex; gap: 8px; padding: 0 6px; }
219+.side-cta button {
220+ flex: 1;
221+ background: var(--panel-2);
222+ border: 1px solid var(--border);
223+ color: var(--text);
224+ border-radius: 8px;
225+ padding: 8px;
226+ cursor: pointer;
227+}
228+.side-cta button:hover { border-color: var(--accent); }
229+.side-footer {
230+ display: flex;
231+ align-items: center;
232+ gap: 8px;
233+ padding: 10px 12px;
234+ border-top: 1px solid var(--border);
235+ background: var(--bg-soft);
236+}
237+.me { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
238+.me-avatar {
239+ flex: none;
240+ width: 30px; height: 30px;
241+ border-radius: 50%;
242+ background: var(--accent);
243+ color: #fff;
244+ font-weight: 700;
245+ font-size: 13px;
246+ display: inline-grid;
247+ place-items: center;
248+}
249+.me-id {
250+ min-width: 0;
251+ overflow: hidden;
252+ text-overflow: ellipsis;
253+ white-space: nowrap;
254+ font-size: 12.5px;
255+ color: var(--muted);
256+}
257+.icon-btn {
258+ flex: none;
259+ background: transparent;
260+ border: 1px solid transparent;
261+ border-radius: 8px;
262+ color: var(--muted);
263+ font-size: 16px;
264+ padding: 5px 9px;
265+ cursor: pointer;
266+}
267+.icon-btn:hover:not(:disabled) { background: var(--panel-2); color: var(--text); }
268+.icon-btn:disabled { opacity: 0.4; cursor: default; }
269+.scrim { display: none; }
270+
271+/* ——— Chat ——— */
272+.chat { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--bg); }
273+.empty {
274+ margin: auto;
275+ text-align: center;
276+ padding: 24px;
277+ max-width: 420px;
278+}
279+.empty-mark { font-size: 44px; color: var(--accent); }
280+.empty h1 { font-size: 22px; margin: 8px 0 4px; }
281+.empty p { color: var(--muted); }
282+.empty-cta { display: flex; gap: 10px; justify-content: center; margin: 16px 0; }
283+.conn-line { display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 13px; }
284+
285+.chat-head {
286+ display: flex;
287+ align-items: center;
288+ gap: 10px;
289+ padding: 10px 16px;
290+ border-bottom: 1px solid var(--border);
291+ background: var(--panel);
292+ min-height: 62px;
293+}
294+.head-title { flex: 1; min-width: 0; }
295+.head-title h1 {
296+ margin: 0;
297+ font-size: 15.5px;
298+ overflow: hidden;
299+ text-overflow: ellipsis;
300+ white-space: nowrap;
301+}
302+.head-meta { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
303+.pill {
304+ font-size: 11px;
305+ border: 1px solid var(--border);
306+ border-radius: 999px;
307+ padding: 0 8px;
308+ color: var(--muted);
309+}
310+.conn { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
311+.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; }
312+.dot-online { background: #3fb97f; }
313+.dot-busy { background: var(--warn); }
314+.dot-off { background: var(--danger); }
315+.head-actions { display: flex; align-items: center; gap: 8px; }
316+
317+.primary-btn {
318+ background: var(--accent);
319+ border: none;
320+ color: #fff;
321+ font-weight: 600;
322+ border-radius: 8px;
323+ padding: 8px 16px;
324+ cursor: pointer;
325+ font-size: 13.5px;
326+}
327+.primary-btn:hover:not(:disabled) { filter: brightness(1.1); }
328+.primary-btn:disabled { opacity: 0.55; cursor: default; }
329+.primary-btn.wide { width: 100%; margin-top: 12px; }
330+.ghost-btn {
331+ background: var(--panel-2);
332+ border: 1px solid var(--border);
333+ color: var(--text);
334+ border-radius: 8px;
335+ padding: 7px 14px;
336+ cursor: pointer;
337+ font-size: 13px;
338+}
339+.ghost-btn:hover:not(:disabled) { border-color: var(--accent); }
340+.ghost-btn:disabled { opacity: 0.55; cursor: wait; }
341+.ghost-btn.danger { color: var(--danger); }
342+.ghost-btn.danger:hover:not(:disabled) { border-color: var(--danger); }
343+
344+.banners { display: flex; flex-direction: column; gap: 6px; padding: 10px 16px 0; }
345+.banner {
346+ display: flex;
347+ align-items: center;
348+ gap: 10px;
349+ background: #3a1d22;
350+ border: 1px solid #6b2a33;
351+ color: #ffc9ce;
352+ border-radius: 8px;
353+ padding: 8px 12px;
354+ font-size: 13px;
355+}
356+.banner span { flex: 1; }
357+.banner button {
358+ background: transparent;
359+ border: none;
360+ color: inherit;
361+ cursor: pointer;
362+ font-size: 13px;
363+ padding: 2px 6px;
364+}
365+.conn-banner {
366+ margin: 10px 16px 0;
367+ padding: 7px 12px;
368+ background: #33300f;
369+ border: 1px solid #5c551f;
370+ color: #ecd98a;
371+ border-radius: 8px;
372+ font-size: 12.5px;
373+}
374+
375+.timeline {
376+ flex: 1;
377+ overflow-y: auto;
378+ padding: 12px 20px 16px;
379+ min-height: 0;
380+}
381+.timeline-top { display: flex; justify-content: center; padding: 4px 0 12px; }
382+.start-note { text-align: center; color: var(--muted); font-size: 12.5px; }
383+.timeline-empty { text-align: center; padding: 32px 0; }
384+.day-row {
385+ display: flex;
386+ align-items: center;
387+ gap: 12px;
388+ margin: 14px 0 8px;
389+ color: var(--muted);
390+ font-size: 12px;
391+}
392+.day-row::before, .day-row::after {
393+ content: '';
394+ flex: 1;
395+ height: 1px;
396+ background: var(--border);
397+}
398+.new-row {
399+ display: flex;
400+ align-items: center;
401+ gap: 12px;
402+ margin: 8px 0;
403+ color: var(--danger);
404+ font-size: 12px;
405+ font-weight: 600;
406+}
407+.new-row::before, .new-row::after { content: ''; flex: 1; height: 1px; background: #6b2a33; }
408+.unknown-note { color: var(--muted); font-size: 12.5px; font-style: italic; }
409+.pending-actions {
410+ display: flex;
411+ align-items: center;
412+ gap: 8px;
413+ margin: -4px 0 6px 44px;
414+}
415+.pending-error { color: var(--danger); font-size: 12px; flex: 1; min-width: 0; overflow-wrap: anywhere; }
416+
417+.composer {
418+ display: flex;
419+ gap: 10px;
420+ align-items: flex-end;
421+ padding: 12px 16px 16px;
422+ border-top: 1px solid var(--border);
423+ background: var(--panel);
424+}
425+.composer textarea {
426+ flex: 1;
427+ resize: none;
428+ background: var(--bg-soft);
429+ border: 1px solid var(--border);
430+ border-radius: 10px;
431+ color: var(--text);
432+ padding: 10px 12px;
433+ min-height: 40px;
434+ max-height: 160px;
435+ line-height: 1.4;
436+}
437+.composer textarea:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
438+.join-bar {
439+ display: flex;
440+ align-items: center;
441+ gap: 12px;
442+ padding: 12px 16px 16px;
443+ border-top: 1px solid var(--border);
444+ background: var(--panel);
445+}
446+.join-bar p { margin: 0; color: var(--muted); font-size: 13px; flex: 1; }
447+
448+/* ——— Members ——— */
449+.members {
450+ background: var(--panel);
451+ border-left: 1px solid var(--border);
452+ display: flex;
453+ flex-direction: column;
454+ min-height: 0;
455+ overflow: hidden;
456+}
457+.members-head {
458+ display: flex;
459+ align-items: center;
460+ justify-content: space-between;
461+ padding: 12px 12px 8px;
462+}
463+.members-head h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0; }
464+.member-list { list-style: none; margin: 0; padding: 0 8px 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
465+.member { display: flex; align-items: center; gap: 9px; padding: 6px 8px; border-radius: 8px; }
466+.member:hover { background: var(--panel-2); }
467+.member-names { min-width: 0; display: flex; flex-direction: column; }
468+.member-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
469+.member-id { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
470+
471+/* ——— Modal ——— */
472+.modal-wrap {
473+ position: fixed;
474+ inset: 0;
475+ background: rgba(0, 0, 0, 0.6);
476+ display: grid;
477+ place-items: center;
478+ padding: 20px;
479+ z-index: 50;
480+}
481+.modal {
482+ width: min(420px, 100%);
483+ background: var(--panel);
484+ border: 1px solid var(--border);
485+ border-radius: 14px;
486+ padding: 18px;
487+}
488+.modal-tabs { display: flex; gap: 4px; margin-bottom: 14px; }
489+.modal-tabs button[role='tab'] {
490+ background: transparent;
491+ border: none;
492+ border-bottom: 2px solid transparent;
493+ color: var(--muted);
494+ font-weight: 600;
495+ padding: 8px 14px;
496+ cursor: pointer;
497+ font-size: 14px;
498+}
499+.modal-tabs button[role='tab'].active { color: var(--text); border-bottom-color: var(--accent); }
500+.modal-x { margin-left: auto; }
501+.modal .field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--muted); margin-bottom: 12px; }
502+.modal .field input {
503+ background: var(--bg-soft);
504+ border: 1px solid var(--border);
505+ border-radius: 8px;
506+ color: var(--text);
507+ padding: 9px 11px;
508+ font-size: 14px;
509+}
510+.modal .field input:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
511+
512+.only-mobile { display: none; }
513+
514+@media (max-width: 1100px) {
515+ .app, .app:has(.members) { grid-template-columns: 60px 1fr; }
516+ .sidebar {
517+ position: fixed;
518+ top: 0; bottom: 0; left: 60px;
519+ width: min(320px, 85vw);
520+ z-index: 40;
521+ transform: translateX(calc(-100% - 60px));
522+ transition: transform 0.18s ease;
523+ box-shadow: 8px 0 32px rgba(0, 0, 0, 0.5);
524+ }
525+ .sidebar.open { transform: none; }
526+ .scrim {
527+ display: block;
528+ position: fixed;
529+ inset: 0;
530+ left: 60px;
531+ background: rgba(0, 0, 0, 0.55);
532+ border: none;
533+ z-index: 30;
534+ cursor: pointer;
535+ }
536+ .members {
537+ position: fixed;
538+ top: 0; bottom: 0; right: 0;
539+ width: min(300px, 85vw);
540+ z-index: 40;
541+ box-shadow: -8px 0 32px rgba(0, 0, 0, 0.5);
542+ }
543+ .only-mobile { display: inline-block; }
544+ .head-actions .ghost-btn { display: none; }
545+ .timeline { padding: 10px 12px 14px; }
546+ .chat-head { padding: 8px 12px; }
547+ .composer { padding: 10px 12px 12px; }
548+}
549+
550+@media (max-width: 640px) {
551+ .app, .app:has(.members) { grid-template-columns: 52px 1fr; }
552+ .sidebar { left: 52px; transform: translateX(calc(-100% - 52px)); }
553+ .scrim { left: 52px; }
554+ .rail-btn { width: 40px; height: 40px; font-size: 16px; }
555+ .empty-cta { flex-direction: column; }
556+}
Added file: web/src/lib/LoginPanel.svelte+196 additions
@@ -0,0 +1,196 @@
1+<script lang="ts">
2+ import { createEventDispatcher } from 'svelte';
3+ import { login } from './api';
4+ import type { SoftLogout } from './api';
5+ import { pushBanner } from './store';
6+
7+ export let softLogout: SoftLogout | null = null;
8+
9+ const dispatch = createEventDispatcher<{ done: string }>();
10+
11+ let homeserver = '';
12+ let username = '';
13+ let password = '';
14+ let busy = false;
15+ let error: string | null = null;
16+ let overridden = false;
17+
18+ $: locked = !!softLogout && !overridden;
19+ $: if (softLogout && !overridden) {
20+ homeserver = softLogout.homeserver;
21+ username = softLogout.user_id;
22+ }
23+
24+ function useDifferentAccount(): void {
25+ overridden = true;
26+ homeserver = '';
27+ username = '';
28+ password = '';
29+ }
30+
31+ async function submit(): Promise<void> {
32+ if (busy) return;
33+ error = null;
34+ const hs = homeserver.trim();
35+ const user = username.trim();
36+ if (!hs || !user || !password) {
37+ error = 'Homeserver, username and password are all required.';
38+ return;
39+ }
40+ busy = true;
41+ try {
42+ const res = await login(hs, user, password);
43+ dispatch('done', res.user_id);
44+ } catch (e) {
45+ const message = e instanceof Error ? e.message : 'Login failed';
46+ error = message;
47+ pushBanner(`Login failed: ${message}`);
48+ } finally {
49+ busy = false;
50+ }
51+ }
52+</script>
53+
54+<div class="login-wrap">
55+ <form class="login-card" on:submit|preventDefault={submit} aria-label="Log in to Matrix">
56+ <div class="login-brand" aria-hidden="true">M</div>
57+ <h1>Malice</h1>
58+ <p class="login-sub">Hosted Matrix chat. Sign in with your homeserver account.</p>
59+ {#if error}
60+ <p class="login-error" role="alert">{error}</p>
61+ {/if}
62+ {#if locked}
63+ <div class="login-notice" role="status">
64+ <p>This device was signed out by the server. Sign in again to keep its encrypted history.</p>
65+ <button type="button" class="login-link" on:click={useDifferentAccount}>Use a different account</button>
66+ </div>
67+ {/if}
68+ <label class="field">
69+ <span>Homeserver (network address — not a space)</span>
70+ <input
71+ type="url"
72+ inputmode="url"
73+ autocomplete="url"
74+ placeholder="https://matrix.example.org"
75+ bind:value={homeserver}
76+ required
77+ disabled={busy || locked}
78+ />
79+ </label>
80+ <label class="field">
81+ <span>Username</span>
82+ <input
83+ type="text"
84+ autocomplete="username"
85+ placeholder="alice"
86+ bind:value={username}
87+ required
88+ disabled={busy || locked}
89+ />
90+ </label>
91+ <label class="field">
92+ <span>Password</span>
93+ <input
94+ type="password"
95+ autocomplete="current-password"
96+ placeholder="••••••••"
97+ bind:value={password}
98+ required
99+ disabled={busy}
100+ />
101+ </label>
102+ <button type="submit" class="primary" disabled={busy}>
103+ {busy ? 'Signing in…' : 'Sign in'}
104+ </button>
105+ <p class="login-note">Your password is sent to this server for Matrix sign-in. Access tokens stay server-side.</p>
106+ </form>
107+</div>
108+
109+<style>
110+ .login-wrap {
111+ min-height: 100dvh;
112+ display: flex;
113+ align-items: center;
114+ justify-content: center;
115+ padding: 24px;
116+ background: radial-gradient(1200px 600px at 70% -10%, #1c2440 0%, var(--bg) 55%);
117+ }
118+ .login-card {
119+ width: min(400px, 100%);
120+ background: var(--panel);
121+ border: 1px solid var(--border);
122+ border-radius: 16px;
123+ padding: 28px 26px;
124+ display: flex;
125+ flex-direction: column;
126+ gap: 12px;
127+ }
128+ .login-brand {
129+ width: 44px;
130+ height: 44px;
131+ border-radius: 14px;
132+ display: grid;
133+ place-items: center;
134+ font-weight: 800;
135+ font-size: 22px;
136+ color: #fff;
137+ background: linear-gradient(135deg, var(--accent), #7c5cff);
138+ }
139+ h1 { font-size: 24px; margin: 2px 0 0; }
140+ .login-sub { color: var(--muted); font-size: 13.5px; margin: 0 0 6px; }
141+ .login-error {
142+ background: #3a1d22;
143+ border: 1px solid #6b2a33;
144+ color: #ffb4bc;
145+ border-radius: 8px;
146+ padding: 8px 10px;
147+ font-size: 13px;
148+ margin: 0;
149+ }
150+ .login-notice {
151+ background: var(--bg-soft);
152+ border: 1px solid var(--border);
153+ border-radius: 8px;
154+ padding: 10px 12px;
155+ font-size: 12.5px;
156+ color: var(--muted);
157+ display: flex;
158+ flex-direction: column;
159+ gap: 6px;
160+ }
161+ .login-notice p { margin: 0; }
162+ .login-link {
163+ align-self: flex-start;
164+ background: none;
165+ border: none;
166+ color: var(--accent);
167+ font-size: 12.5px;
168+ padding: 0;
169+ cursor: pointer;
170+ }
171+ .login-link:hover { text-decoration: underline; }
172+ .field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--muted); }
173+ .field input {
174+ background: var(--bg-soft);
175+ border: 1px solid var(--border);
176+ border-radius: 8px;
177+ color: var(--text);
178+ padding: 9px 11px;
179+ font-size: 14px;
180+ }
181+ .field input:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: var(--accent); }
182+ .primary {
183+ margin-top: 4px;
184+ background: var(--accent);
185+ border: none;
186+ color: #fff;
187+ font-weight: 600;
188+ border-radius: 9px;
189+ padding: 10px;
190+ font-size: 14.5px;
191+ cursor: pointer;
192+ }
193+ .primary:disabled { opacity: 0.6; cursor: wait; }
194+ .primary:not(:disabled):hover { filter: brightness(1.1); }
195+ .login-note { color: var(--muted); font-size: 12px; margin: 4px 0 0; line-height: 1.5; }
196+</style>
Added file: web/src/lib/MediaAttachmentView.svelte+126 additions
@@ -0,0 +1,126 @@
1+<script lang="ts">
2+ import type { MediaAttachment } from './types';
3+ import { messageMediaUrl } from './api';
4+
5+ export let attachment: MediaAttachment;
6+
7+ const PREVIEWABLE = new Set(['Image', 'Sticker']);
8+
9+ // Encrypted media often has no fetchable thumbnail, so request the bounded
10+ // full bytes directly instead of flashing a broken thumbnail first.
11+ $: fullUrl = messageMediaUrl(attachment.id, 'Full');
12+ $: thumbUrl = messageMediaUrl(attachment.id, 'Thumbnail');
13+ $: previewable = PREVIEWABLE.has(attachment.kind);
14+
15+ let fullFailed = false;
16+
17+ $: if (attachment.id) {
18+ fullFailed = false;
19+ }
20+
21+ function sizeLabel(): string {
22+ if (typeof attachment.size_bytes !== 'number') return '';
23+ const bytes = attachment.size_bytes;
24+ if (bytes < 1024) return `${bytes} B`;
25+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
26+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
27+ }
28+
29+ function durationLabel(): string {
30+ if (typeof attachment.duration_ms !== 'number') return '';
31+ const totalSeconds = Math.round(attachment.duration_ms / 1000);
32+ const minutes = Math.floor(totalSeconds / 60);
33+ const seconds = totalSeconds % 60;
34+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
35+ }
36+</script>
37+
38+{#if previewable && !fullFailed}
39+ <figure class="media-figure">
40+ <a href={fullUrl} target="_blank" rel="noopener noreferrer" title={attachment.filename}>
41+ <img
42+ class="media-image"
43+ src={fullUrl}
44+ alt={attachment.caption || attachment.filename}
45+ loading="lazy"
46+ on:error={() => (fullFailed = true)}
47+ />
48+ </a>
49+ <figcaption class="media-caption">
50+ {#if attachment.caption && attachment.caption !== attachment.filename}
51+ <span>{attachment.caption}</span>
52+ {:else}
53+ <span>{attachment.filename}</span>
54+ {/if}
55+ {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}
56+ </figcaption>
57+ </figure>
58+{:else if previewable && fullFailed}
59+ <div class="media-file">
60+ <span class="file-icon" aria-hidden="true">🖼</span>
61+ <span>{attachment.caption || attachment.filename}</span>
62+ <span class="media-size">preview unavailable</span>
63+ </div>
64+{:else if attachment.kind === 'Video'}
65+ <div class="media-block">
66+ <!-- svelte-ignore a11y-media-has-caption -->
67+ <video class="media-video" controls preload="metadata" poster={attachment.has_thumbnail ? thumbUrl : undefined}>
68+ <source src={fullUrl} type={attachment.mimetype ?? undefined} />
69+ Your browser cannot play this video.
70+ </video>
71+ <div class="media-caption">
72+ <a href={fullUrl} target="_blank" rel="noopener noreferrer">{attachment.caption || attachment.filename}</a>
73+ {#if durationLabel()}<span class="media-size"> · {durationLabel()}</span>{/if}
74+ {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}
75+ </div>
76+ </div>
77+{:else if attachment.kind === 'Audio'}
78+ <div class="media-block">
79+ <!-- svelte-ignore a11y-media-has-caption -->
80+ <audio class="media-audio" controls preload="metadata" src={fullUrl}></audio>
81+ <div class="media-caption">
82+ <span>{attachment.caption || attachment.filename}</span>
83+ {#if durationLabel()}<span class="media-size"> · {durationLabel()}</span>{/if}
84+ {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}
85+ </div>
86+ </div>
87+{:else}
88+ <div class="media-file">
89+ <span class="file-icon" aria-hidden="true">📎</span>
90+ <a href={fullUrl} target="_blank" rel="noopener noreferrer" download={attachment.filename}>
91+ {attachment.caption || attachment.filename}
92+ </a>
93+ {#if sizeLabel()}<span class="media-size">{sizeLabel()}</span>{/if}
94+ </div>
95+{/if}
96+
97+<style>
98+ .media-figure { margin: 6px 0 2px; max-width: 420px; }
99+ .media-image {
100+ display: block;
101+ max-width: 100%;
102+ max-height: 320px;
103+ border-radius: 8px;
104+ border: 1px solid var(--border);
105+ background: var(--bg-soft);
106+ }
107+ .media-video { display: block; max-width: 100%; max-height: 320px; border-radius: 8px; background: #000; }
108+ .media-audio { display: block; width: 100%; max-width: 420px; margin-top: 6px; }
109+ .media-block { margin: 6px 0 2px; max-width: 420px; }
110+ .media-caption { font-size: 12px; color: var(--muted); margin-top: 4px; display: flex; gap: 4px; flex-wrap: wrap; }
111+ .media-caption a { color: var(--text); }
112+ .media-size { opacity: 0.85; }
113+ .media-file {
114+ display: inline-flex;
115+ align-items: center;
116+ gap: 8px;
117+ margin: 6px 0 2px;
118+ padding: 6px 12px;
119+ border: 1px solid var(--border);
120+ border-radius: 8px;
121+ background: var(--bg-soft);
122+ max-width: 100%;
123+ }
124+ .media-file a { color: var(--text); overflow-wrap: anywhere; }
125+ .file-icon { font-size: 15px; }
126+</style>
Added file: web/src/lib/MessageRow.svelte+306 additions
@@ -0,0 +1,306 @@
1+<script lang="ts">
2+ import MediaAttachmentView from './MediaAttachmentView.svelte';
3+ import MxcAvatar from './MxcAvatar.svelte';
4+ import type { SendState, TimelineContent, TimelineEvent } from './types';
5+ import { senderLabel } from './types';
6+ import { formatClock, formatDay, initialsOf, senderHue } from './format';
7+ import { findUrls } from './urls';
8+
9+ export let itemId: string;
10+ export let event: TimelineEvent;
11+ export let showSender: boolean;
12+ export let reacting: boolean;
13+ export let onReact: (eventId: string, key: string) => void;
14+ export let senderAvatar: string | null = null;
15+
16+ const QUICK_KEYS = ['👍', '❤️', '😂', '🎉', '😮', '😢'];
17+
18+ const MEMBERSHIP_LABELS: Record<string, string> = {
19+ Joined: 'joined the room',
20+ Left: 'left the room',
21+ Banned: 'was banned',
22+ Unbanned: 'was unbanned',
23+ Kicked: 'was kicked',
24+ KickedAndBanned: 'was kicked and banned',
25+ Invited: 'was invited',
26+ InvitationAccepted: 'accepted the invite',
27+ InvitationRejected: 'rejected the invite',
28+ InvitationRevoked: 'had the invite revoked',
29+ Knocked: 'requested to join',
30+ KnockAccepted: 'was let in',
31+ KnockRetracted: 'withdrew the request to join',
32+ KnockDenied: 'had the request to join denied'
33+ };
34+
35+ $: name = senderLabel(event.sender);
36+ $: displayName = event.sender.name_ambiguous ? `${name} (${event.sender.user_id})` : name;
37+ $: interactive = event.content.kind === 'Message' || event.content.kind === 'Sticker' || event.content.kind === 'Poll';
38+ $: previews = event.content.kind === 'Message' ? findUrls(event.content.message.body) : [];
39+
40+ function sendLabel(state: SendState): string {
41+ if (state.kind === 'Failed') return state.will_retry ? 'failed — will retry' : 'failed — not delivered';
42+ if (state.kind === 'Sending') return 'sending…';
43+ if (event.own && state.kind === 'Sent' && event.event_id) return '✓ sent';
44+ return '';
45+ }
46+
47+ function actorLabel(userId: string, name: string | null): string {
48+ return name ?? userId;
49+ }
50+
51+ function replyPreview(content: TimelineContent): string {
52+ if (content.kind === 'Message') return content.message.body;
53+ if (content.kind === 'Sticker') return content.body || 'Sticker';
54+ if (content.kind === 'Poll') return `Poll: ${content.question}`;
55+ if (content.kind === 'Redacted') return 'Message deleted';
56+ if (content.kind === 'UnableToDecrypt') return 'Unable to decrypt';
57+ return systemLine(content);
58+ }
59+
60+ function systemLine(content: TimelineContent): string {
61+ switch (content.kind) {
62+ case 'Redacted':
63+ return 'Message deleted';
64+ case 'UnableToDecrypt':
65+ return `Message from ${displayName} could not be decrypted`;
66+ case 'Membership': {
67+ const who = actorLabel(content.user_id, content.display_name);
68+ return `${who} ${MEMBERSHIP_LABELS[content.change] ?? 'updated their membership'}`;
69+ }
70+ case 'Profile': {
71+ const who = actorLabel(content.user_id, null);
72+ const parts: string[] = [];
73+ if (content.display_name) {
74+ parts.push(
75+ content.display_name.new
76+ ? `changed their display name to "${content.display_name.new}"`
77+ : 'removed their display name'
78+ );
79+ }
80+ if (content.avatar) parts.push('changed their avatar');
81+ return `${who} ${parts.length > 0 ? parts.join(' and ') : 'updated their profile'}`;
82+ }
83+ case 'RoomName':
84+ return content.name
85+ ? `${displayName} changed the room name to "${content.name}"`
86+ : `${displayName} removed the room name`;
87+ case 'RoomTopic':
88+ return content.topic
89+ ? `${displayName} changed the topic to "${content.topic}"`
90+ : `${displayName} removed the topic`;
91+ case 'RoomAvatar':
92+ return content.avatar ? `${displayName} changed the room avatar` : `${displayName} removed the room avatar`;
93+ case 'EncryptionEnabled':
94+ return `${displayName} enabled encryption in this room`;
95+ case 'RoomCreated':
96+ return `${displayName} created the room`;
97+ case 'RoomUpgraded':
98+ return `${displayName} upgraded the room to a new version`;
99+ case 'OtherState':
100+ return `${displayName} sent a state event (${content.event_type})`;
101+ case 'Other':
102+ return `${displayName} sent an event (${content.event_type})`;
103+ default:
104+ return displayName;
105+ }
106+ }
107+</script>
108+
109+<div class="msg" class:own={event.own} class:system={!interactive} data-item={itemId}>
110+ {#if showSender}
111+ {#if senderAvatar}
112+ <MxcAvatar mxc={senderAvatar} label={name} size={34} />
113+ {:else}
114+ <div
115+ class="avatar"
116+ aria-hidden="true"
117+ style={`background:hsl(${senderHue(event.sender.user_id)} 35% 32%)`}
118+ >
119+ {initialsOf(name)}
120+ </div>
121+ {/if}
122+ {:else}
123+ <div class="avatar-spacer" aria-hidden="true">
124+ <span class="hover-time">{formatClock(event.timestamp_ms)}</span>
125+ </div>
126+ {/if}
127+ <div class="msg-body">
128+ {#if showSender}
129+ <div class="msg-head">
130+ <span class="sender" style={`color:hsl(${senderHue(event.sender.user_id)} 70% 72%)`}>
131+ {displayName}
132+ </span>
133+ <time class="time" datetime={new Date(event.timestamp_ms).toISOString()}>
134+ {formatDay(event.timestamp_ms)} {formatClock(event.timestamp_ms)}
135+ </time>
136+ {#if event.content.kind === 'Message' && event.content.message.edited}
137+ <span class="edited" title="Edited">(edited)</span>
138+ {/if}
139+ </div>
140+ {/if}
141+ {#if event.reply_to}
142+ <div class="reply-quote">
143+ {#if event.reply_to.details.kind === 'Ready'}
144+ Replying to {senderLabel(event.reply_to.details.sender)}: {replyPreview(event.reply_to.details.content)}
145+ {:else if event.reply_to.details.kind === 'Pending'}
146+ Loading reply…
147+ {:else}
148+ Reply unavailable
149+ {/if}
150+ </div>
151+ {/if}
152+
153+ {#if event.content.kind === 'Message'}
154+ {@const m = event.content.message}
155+ <p class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>
156+ {#if m.kind === 'Emote'}* {displayName} {/if}{m.body}
157+ </p>
158+ {#if m.media}
159+ <MediaAttachmentView attachment={m.media} />
160+ {/if}
161+ {#if previews.length > 0}
162+ <div class="link-previews">
163+ {#each previews as preview (preview.href)}
164+ <a class="link-card" href={preview.href} target="_blank" rel="noopener noreferrer">
165+ <span class="link-host">{preview.hostname}</span>
166+ {#if preview.path}<span class="link-path">{preview.path}</span>{/if}
167+ <span class="link-url">{preview.display}</span>
168+ </a>
169+ {/each}
170+ </div>
171+ {/if}
172+ {:else if event.content.kind === 'Sticker'}
173+ {#if event.content.body}<p class="text">{event.content.body}</p>{/if}
174+ {#if event.content.media}
175+ <MediaAttachmentView attachment={event.content.media} />
176+ {/if}
177+ {:else if event.content.kind === 'Poll'}
178+ <p class="text">📊 {event.content.question}</p>
179+ {:else}
180+ <p class="system-line">{systemLine(event.content)}</p>
181+ {/if}
182+
183+ {#if interactive && event.reactions.length > 0}
184+ <div class="reactions" aria-label="Reactions">
185+ {#each event.reactions as r (r.key)}
186+ <button
187+ type="button"
188+ class="reaction"
189+ class:mine={r.own}
190+ title={r.own ? 'You reacted — activate to remove' : `${r.count} reaction${r.count === 1 ? '' : 's'}`}
191+ disabled={!event.event_id || reacting}
192+ on:click={() => event.event_id && onReact(event.event_id, r.key)}
193+ >
194+ <span aria-hidden="true">{r.key}</span>
195+ <span class="count">{r.count}</span>
196+ </button>
197+ {/each}
198+ </div>
199+ {/if}
200+ {#if interactive}
201+ <div class="msg-foot">
202+ {#if sendLabel(event.send_state)}
203+ <span class="sendstate" class:failed={event.send_state.kind === 'Failed'}>{sendLabel(event.send_state)}</span>
204+ {/if}
205+ {#if event.read_by.length > 0}
206+ <span class="readby" title={event.read_by.join(', ')}>✓✓ {event.read_by.length}</span>
207+ {/if}
208+ {#if event.event_id}
209+ <span class="quick-react" role="group" aria-label={`React to message from ${displayName}`}>
210+ {#each QUICK_KEYS as key (key)}
211+ <button
212+ type="button"
213+ title={`React ${key}`}
214+ aria-label={`React ${key} to message from ${displayName}`}
215+ disabled={reacting}
216+ on:click={() => onReact(event.event_id ?? '', key)}
217+ >
218+ {key}
219+ </button>
220+ {/each}
221+ </span>
222+ {/if}
223+ </div>
224+ {/if}
225+ </div>
226+</div>
227+
228+<style>
229+ .msg { display: flex; gap: 10px; padding: 3px 0; }
230+ .msg:hover { background: rgba(255, 255, 255, 0.018); }
231+ .msg:hover .hover-time { opacity: 1; }
232+ .msg-foot { display: flex; align-items: center; gap: 8px; min-height: 18px; }
233+ .sendstate { font-size: 11px; color: var(--muted); }
234+ .sendstate.failed { color: var(--danger); }
235+ .link-previews { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; max-width: 420px; }
236+ .link-card {
237+ display: flex; flex-direction: column; gap: 1px;
238+ border: 1px solid var(--border); border-radius: 8px;
239+ background: var(--bg-soft); padding: 8px 10px;
240+ text-decoration: none; color: var(--text);
241+ }
242+ .link-card:hover { border-color: var(--accent); }
243+ .link-host { font-size: 12.5px; font-weight: 650; }
244+ .link-path { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
245+ .link-url { font-size: 11.5px; color: var(--accent); overflow-wrap: anywhere; }
246+ .avatar {
247+ flex: none;
248+ width: 34px; height: 34px;
249+ border-radius: 50%;
250+ display: grid; place-items: center;
251+ font-size: 12px; font-weight: 700; color: #fff;
252+ }
253+ .avatar-spacer { flex: none; width: 34px; padding-top: 2px; text-align: center; }
254+ .hover-time { opacity: 0; font-size: 10.5px; color: var(--muted); }
255+ .msg-body { min-width: 0; flex: 1; }
256+ .msg-head { display: flex; align-items: baseline; gap: 8px; }
257+ .sender { font-weight: 650; font-size: 13.5px; }
258+ .time, .edited { color: var(--muted); font-size: 11.5px; }
259+ .reply-quote {
260+ border-left: 2px solid var(--accent);
261+ padding: 2px 8px;
262+ margin: 2px 0 4px;
263+ color: var(--muted);
264+ font-size: 12.5px;
265+ background: var(--bg-soft);
266+ border-radius: 0 6px 6px 0;
267+ }
268+ .text {
269+ margin: 1px 0 2px;
270+ font-size: 14px;
271+ line-height: 1.5;
272+ white-space: pre-wrap;
273+ overflow-wrap: anywhere;
274+ }
275+ .text.emote { font-style: italic; color: var(--muted); }
276+ .text.notice { color: var(--muted); }
277+ .system-line { color: var(--muted); font-size: 12.5px; font-style: italic; margin: 1px 0; }
278+ .msg.system { opacity: 0.85; }
279+ .reactions { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
280+ .reaction {
281+ display: inline-flex; align-items: center; gap: 4px;
282+ background: var(--bg-soft);
283+ border: 1px solid var(--border);
284+ border-radius: 999px;
285+ padding: 1px 8px;
286+ font-size: 13px;
287+ color: var(--text);
288+ cursor: pointer;
289+ }
290+ .reaction.mine { border-color: var(--accent); background: rgba(88, 101, 242, 0.16); }
291+ .reaction:disabled { cursor: default; opacity: 0.85; }
292+ .msg-foot { display: flex; align-items: center; gap: 8px; min-height: 18px; }
293+ .readby { font-size: 11px; color: var(--muted); }
294+ .msg:hover .quick-react, .msg:focus-within .quick-react { display: inline-flex; }
295+ .quick-react button {
296+ background: transparent;
297+ border: 1px solid transparent;
298+ border-radius: 6px;
299+ cursor: pointer;
300+ font-size: 13px;
301+ padding: 0 3px;
302+ opacity: 0.75;
303+ }
304+ .quick-react button:hover:not(:disabled) { opacity: 1; border-color: var(--border); background: var(--bg-soft); }
305+ .quick-react button:disabled { opacity: 0.4; cursor: default; }
306+</style>
Added file: web/src/lib/MxcAvatar.svelte+56 additions
@@ -0,0 +1,56 @@
1+<script lang="ts">
2+ import { mediaUrl } from './api';
3+ import { initialsOf } from './format';
4+
5+ export let mxc: string | null;
6+ export let label: string;
7+ export let size = 34;
8+ /** 'circle' for people, 'rounded' for rooms/spaces */
9+ export let shape: 'circle' | 'rounded' = 'circle';
10+
11+ $: src = mediaUrl(mxc);
12+
13+ let failed = false;
14+ $: if (mxc) failed = false;
15+ $: px = `${size}px`;
16+ $: fontPx = `${Math.max(10, Math.round(size * 0.36))}px`;
17+</script>
18+
19+{#if src && !failed}
20+ <img
21+ {src}
22+ alt=""
23+ aria-hidden="true"
24+ width={size}
25+ height={size}
26+ loading="lazy"
27+ class="mxc-avatar"
28+ class:circle={shape === 'circle'}
29+ style={`width:${px};height:${px};`}
30+ on:error={() => (failed = true)}
31+ />
32+{:else}
33+ <span class="mxc-fallback" class:circle={shape === 'circle'} aria-hidden="true" style={`width:${px};height:${px};font-size:${fontPx};`}>
34+ {initialsOf(label || '#')}
35+ </span>
36+{/if}
37+
38+<style>
39+ .mxc-avatar {
40+ flex: none;
41+ object-fit: cover;
42+ border-radius: 8px;
43+ background: var(--bg-soft);
44+ }
45+ .mxc-avatar.circle { border-radius: 50%; }
46+ .mxc-fallback {
47+ flex: none;
48+ display: inline-grid;
49+ place-items: center;
50+ border-radius: 8px;
51+ background: var(--panel-2);
52+ color: var(--text);
53+ font-weight: 700;
54+ }
55+ .mxc-fallback.circle { border-radius: 50%; }
56+</style>
Added file: web/src/lib/SecurityPanel.svelte+223 additions
@@ -0,0 +1,223 @@
1+<script lang="ts">
2+ import { createEventDispatcher } from 'svelte';
3+ import {
4+ recoverEncryption,
5+ startVerification,
6+ verifyEncryption,
7+ type VerifyAction
8+ } from './api';
9+ import { pushBanner } from './store';
10+ import type { EncryptionState, VerificationState } from './types';
11+
12+ const dispatch = createEventDispatcher<{ close: void }>();
13+
14+ export let encryptionState: EncryptionState | null;
15+ export let verificationState: VerificationState | null;
16+ export let onAction: () => void = () => undefined;
17+
18+ let recoveryKey = '';
19+ let busy: VerifyAction | 'recover' | 'start' | null = null;
20+
21+ $: recoveryNeeded = encryptionState !== null && encryptionState.recovery !== 'Enabled';
22+
23+ function verificationTitle(state: VerificationState): string {
24+ if (state.kind === 'Waiting') return 'Verification started — waiting for the other device.';
25+ if (state.kind === 'Incoming') return 'Another device wants to verify this session.';
26+ if (state.kind === 'Comparison') return 'Compare the emojis and numbers with the other device.';
27+ if (state.kind === 'Confirmed') return 'Codes confirmed — finishing verification…';
28+ return 'Verification';
29+ }
30+
31+ async function runAction(action: VerifyAction, options?: { silentDone?: boolean }): Promise<void> {
32+ if (busy) return;
33+ busy = action;
34+ try {
35+ await verifyEncryption(action);
36+ onAction();
37+ if (!options?.silentDone && (action === 'Confirm' || action === 'Accept')) {
38+ pushBanner('Confirmation sent — waiting for the verification result.');
39+ }
40+ } catch (e) {
41+ pushBanner(e instanceof Error ? e.message : 'Verification action failed');
42+ } finally {
43+ busy = null;
44+ }
45+ }
46+
47+ async function recover(): Promise<void> {
48+ const key = recoveryKey.trim();
49+ if (!key || busy) return;
50+ busy = 'recover';
51+ try {
52+ await recoverEncryption(key);
53+ onAction();
54+ pushBanner('Recovery key sent — waiting for the server to confirm recovery.');
55+ recoveryKey = '';
56+ } catch (e) {
57+ pushBanner(e instanceof Error ? e.message : 'Recovery failed');
58+ } finally {
59+ busy = null;
60+ }
61+ }
62+
63+ async function start(): Promise<void> {
64+ if (busy) return;
65+ busy = 'start';
66+ try {
67+ await startVerification();
68+ onAction();
69+ } catch (e) {
70+ pushBanner(e instanceof Error ? e.message : 'Could not start verification');
71+ } finally {
72+ busy = null;
73+ }
74+ }
75+</script>
76+
77+<div class="modal" role="dialog" aria-modal="true" aria-label="Encryption and verification">
78+ <div class="modal-tabs" role="tablist" aria-label="Security">
79+ <button type="button" role="tab" aria-selected="true" class="active">Security</button>
80+ <button type="button" class="icon-btn modal-x" aria-label="Close security dialog" on:click={() => dispatch('close')}>
81+ <span aria-hidden="true">✕</span>
82+ </button>
83+ </div>
84+
85+ <section aria-label="Device status">
86+ <h3 class="sec-h">This device</h3>
87+ {#if encryptionState}
88+ <p class="sec-line">
89+ Device <code>{encryptionState.device_id}</code>
90+ {#if encryptionState.device_verified}
91+ <span class="pill ok">verified</span>
92+ {:else}
93+ <span class="pill warn">not verified</span>
94+ {/if}
95+ </p>
96+ <p class="sec-line muted">
97+ Recovery: {encryptionState.recovery} · Key backup: {encryptionState.key_backup}
98+ </p>
99+ {:else}
100+ <p class="sec-line muted">Encryption status not loaded yet — it arrives with the next sync.</p>
101+ {/if}
102+ </section>
103+
104+ <section aria-label="Recovery">
105+ <h3 class="sec-h">Recovery</h3>
106+ {#if recoveryNeeded}
107+ <p class="sec-line muted">Enter your recovery key to unlock message history on this device.</p>
108+ {:else}
109+ <p class="sec-line muted">Recovery looks enabled. Re-entering the key is only needed after a reset.</p>
110+ {/if}
111+ <form on:submit|preventDefault={recover}>
112+ <label class="field">
113+ <span>Recovery key</span>
114+ <input
115+ type="password"
116+ autocomplete="off"
117+ spellcheck="false"
118+ placeholder="EsT4 …"
119+ bind:value={recoveryKey}
120+ disabled={busy !== null}
121+ />
122+ </label>
123+ <button type="submit" class="primary-btn wide" disabled={busy !== null || !recoveryKey.trim()}>
124+ {busy === 'recover' ? 'Sending…' : 'Recover with key'}
125+ </button>
126+ </form>
127+ <p class="sec-note">Success is confirmed when the status above changes.</p>
128+ </section>
129+
130+ <section aria-label="Verification">
131+ <h3 class="sec-h">Verification</h3>
132+ {#if verificationState}
133+ <p class="sec-line"><strong>{verificationTitle(verificationState)}</strong></p>
134+ {#if verificationState.kind === 'Incoming'}
135+ <p class="sec-line muted">
136+ {verificationState.user_id ?? 'Another session'} wants to verify this device.
137+ </p>
138+ {#if verificationState.device_id}
139+ <p class="sec-line muted">Device: <code>{verificationState.device_id}</code></p>
140+ {/if}
141+ <div class="row">
142+ <button type="button" class="primary-btn" disabled={busy !== null} on:click={() => void runAction('Accept')}>
143+ {busy === 'Accept' ? 'Accepting…' : 'Accept request'}
144+ </button>
145+ <button type="button" class="ghost-btn danger" disabled={busy !== null} on:click={() => void runAction('Cancel')}>
146+ {busy === 'Cancel' ? 'Cancelling…' : 'Cancel'}
147+ </button>
148+ </div>
149+ {:else if verificationState.kind === 'Waiting'}
150+ <p class="sec-line muted">Approve the prompt on the other device, then compare codes here.</p>
151+ <div class="row">
152+ <button type="button" class="ghost-btn danger" disabled={busy !== null} on:click={() => void runAction('Cancel')}>
153+ {busy === 'Cancel' ? 'Cancelling…' : 'Cancel verification'}
154+ </button>
155+ </div>
156+ {:else if verificationState.kind === 'Comparison'}
157+ <ol class="emoji-grid" aria-label="Verification emojis">
158+ {#each verificationState.emojis as item (item.name + item.emoji)}
159+ <li><span class="emoji" aria-hidden="true">{item.emoji}</span><span class="emoji-name">{item.name}</span></li>
160+ {/each}
161+ </ol>
162+ <p class="sec-line decimals" aria-label="Verification numbers">
163+ {verificationState.decimals[0]} – {verificationState.decimals[1]} – {verificationState.decimals[2]}
164+ </p>
165+ <div class="row">
166+ <button type="button" class="primary-btn" disabled={busy !== null} on:click={() => void runAction('Confirm')}>
167+ {busy === 'Confirm' ? 'Confirming…' : 'They match'}
168+ </button>
169+ <button type="button" class="ghost-btn danger" disabled={busy !== null} on:click={() => void runAction('Reject')}>
170+ {busy === 'Reject' ? 'Rejecting…' : "They don't match"}
171+ </button>
172+ </div>
173+ {:else if verificationState.kind === 'Confirmed'}
174+ <p class="sec-line muted">Confirmed — waiting for the other side to finish.</p>
175+ {:else if verificationState.kind === 'Done'}
176+ <p class="sec-line"><span class="pill ok">verified</span></p>
177+ <div class="row">
178+ <button type="button" class="ghost-btn" on:click={start} disabled={busy !== null}>Verify again</button>
179+ </div>
180+ {:else if verificationState.kind === 'Cancelled'}
181+ <p class="sec-line muted">Cancelled: {verificationState.reason}</p>
182+ <div class="row">
183+ <button type="button" class="ghost-btn" on:click={start} disabled={busy !== null}>Start again</button>
184+ </div>
185+ {:else if verificationState.kind === 'Failed'}
186+ <p class="sec-line muted">Failed: {verificationState.message}</p>
187+ <div class="row">
188+ <button type="button" class="ghost-btn" on:click={start} disabled={busy !== null}>Try again</button>
189+ </div>
190+ {/if}
191+ {:else}
192+ <p class="sec-line muted">No active verification. Start one to prove this device to your other sessions.</p>
193+ <div class="row">
194+ <button type="button" class="primary-btn" on:click={start} disabled={busy !== null}>
195+ {busy === 'start' ? 'Starting…' : 'Start verification'}
196+ </button>
197+ </div>
198+ {/if}
199+ </section>
200+</div>
201+
202+<style>
203+ .sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 16px 0 6px; }
204+ .sec-h:first-child { margin-top: 0; }
205+ .sec-line { font-size: 13px; margin: 0 0 8px; overflow-wrap: anywhere; }
206+ .sec-line.muted, .sec-note { color: var(--muted); font-size: 12.5px; }
207+ .sec-note { margin: 8px 0 0; }
208+ .pill.ok { background: rgba(63, 185, 127, 0.18); border-color: #3fb97f; color: #9fe3c3; }
209+ .pill.warn { background: rgba(232, 178, 90, 0.15); border-color: var(--warn); color: var(--warn); }
210+ code { font-size: 12px; background: var(--bg-soft); padding: 1px 6px; border-radius: 6px; }
211+ .row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
212+ .emoji-grid {
213+ list-style: none; margin: 8px 0; padding: 0;
214+ display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 6px;
215+ }
216+ .emoji-grid li {
217+ display: flex; flex-direction: column; align-items: center; gap: 2px;
218+ background: var(--bg-soft); border: 1px solid var(--border); border-radius: 8px; padding: 8px 4px;
219+ }
220+ .emoji { font-size: 24px; }
221+ .emoji-name { font-size: 11px; color: var(--muted); text-align: center; }
222+ .decimals { font-size: 17px; font-weight: 700; letter-spacing: 0.06em; }
223+</style>
Added file: web/src/lib/api.ts+97 additions
@@ -0,0 +1,97 @@
1+// Backend-agnostic API surface. Every call forwards to whichever transport
2+// this build was compiled for, hosted (fetch + SSE) or on-device (worker).
3+import type { MemberSummary, RoomSummary } from './types';
4+import { HttpTransport } from './transport-http';
5+import type { SessionInfo, Transport, VerifyAction } from './transport';
6+import { createDeviceTransport } from 'malice:device-transport';
7+
8+export { ApiError } from './transport';
9+export type { SessionInfo, SoftLogout, VerifyAction } from './transport';
10+
11+function createTransport(): Transport {
12+ if (import.meta.env.VITE_MALICE_TARGET === 'device') return createDeviceTransport();
13+ return new HttpTransport();
14+}
15+
16+const transport: Transport = createTransport();
17+
18+export function getSession(): Promise<SessionInfo> {
19+ return transport.getSession();
20+}
21+
22+export function login(homeserver: string, username: string, password: string): Promise<{ user_id: string }> {
23+ return transport.login(homeserver, username, password);
24+}
25+
26+export function logout(): Promise<void> {
27+ return transport.logout();
28+}
29+
30+export function getRooms(): Promise<{ rooms: RoomSummary[] }> {
31+ return transport.getRooms();
32+}
33+
34+export const openRoom = (room_id: string) => transport.openRoom(room_id);
35+export const closeRoom = (room_id: string) => transport.closeRoom(room_id);
36+export const sendMessage = (room_id: string, body: string) => transport.sendMessage(room_id, body);
37+export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);
38+export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
39+
40+export function joinRoom(room: string): Promise<{ room_id: string }> {
41+ return transport.joinRoom(room);
42+}
43+
44+export function createRoom(name: string, topic?: string): Promise<{ room_id: string }> {
45+ return transport.createRoom(name, topic);
46+}
47+
48+export const leaveRoom = (room_id: string) => transport.leaveRoom(room_id);
49+export const reactToMessage = (room_id: string, event_id: string, key: string) =>
50+ transport.reactToMessage(room_id, event_id, key);
51+export const markRead = (room_id: string, event_id: string) => transport.markRead(room_id, event_id);
52+
53+export function getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {
54+ return transport.getMembers(room_id);
55+}
56+
57+/** Same URL shape in both builds: the hosted server and the device service worker both answer it. */
58+export function mediaUrl(mxc: string | null): string | null {
59+ if (!mxc) return null;
60+ return `/api/media?mxc=${encodeURIComponent(mxc)}`;
61+}
62+
63+export function messageMediaUrl(mediaId: string, size: 'Thumbnail' | 'Full'): string {
64+ return `/api/message-media?media_id=${encodeURIComponent(mediaId)}&size=${size}`;
65+}
66+
67+export function getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }> {
68+ return transport.getProfile();
69+}
70+
71+export function getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }> {
72+ return transport.getSpaceChildren(room_id);
73+}
74+
75+export function getEncryption(): Promise<{ state: unknown; verification: unknown }> {
76+ return transport.getEncryption();
77+}
78+
79+export function recoverEncryption(recovery_key: string): Promise<void> {
80+ return transport.recoverEncryption(recovery_key);
81+}
82+
83+export function startVerification(): Promise<void> {
84+ return transport.startVerification();
85+}
86+
87+export function verifyEncryption(action: VerifyAction): Promise<void> {
88+ return transport.verifyEncryption(action);
89+}
90+
91+export function uploadAttachment(room_id: string, file: File): Promise<void> {
92+ return transport.uploadAttachment(room_id, file);
93+}
94+
95+export function subscribeEvents(onEvent: (data: string) => void, onDisconnect: () => void): () => void {
96+ return transport.subscribeEvents(onEvent, onDisconnect);
97+}
Added file: web/src/lib/device-transport.stub.ts+8 additions
@@ -0,0 +1,8 @@
1+import type { Transport } from './transport';
2+
3+// vite.config.ts aliases 'malice:device-transport' to this file for hosted
4+// builds, so transport-worker.ts (and its SharedWorker/wasm import chain)
5+// never enters the hosted module graph at all, not even as dead code.
6+export function createDeviceTransport(): Transport {
7+ throw new Error('the device transport is unavailable in this build');
8+}
Added file: web/src/lib/device-transport.ts+6 additions
@@ -0,0 +1,6 @@
1+import { WorkerTransport } from './transport-worker';
2+import type { Transport } from './transport';
3+
4+export function createDeviceTransport(): Transport {
5+ return new WorkerTransport();
6+}
Added file: web/src/lib/format.ts+40 additions
@@ -0,0 +1,40 @@
1+export function formatClock(timestampMs: number): string {
2+ if (!timestampMs) return '';
3+ const d = new Date(timestampMs);
4+ const h = d.getHours().toString().padStart(2, '0');
5+ const m = d.getMinutes().toString().padStart(2, '0');
6+ return `${h}:${m}`;
7+}
8+
9+export function formatDay(timestampMs: number): string {
10+ if (!timestampMs) return 'Unknown date';
11+ const d = new Date(timestampMs);
12+ const today = new Date();
13+ const yesterday = new Date(today);
14+ yesterday.setDate(today.getDate() - 1);
15+ const sameDay = (a: Date, b: Date) =>
16+ a.getFullYear() === b.getFullYear() &&
17+ a.getMonth() === b.getMonth() &&
18+ a.getDate() === b.getDate();
19+ if (sameDay(d, today)) return 'Today';
20+ if (sameDay(d, yesterday)) return 'Yesterday';
21+ return d.toLocaleDateString(undefined, {
22+ weekday: 'short',
23+ year: 'numeric',
24+ month: 'short',
25+ day: 'numeric'
26+ });
27+}
28+
29+export function initialsOf(name: string): string {
30+ const clean = name.replace(/^[@#!]/, '').split(/[:\s._-]+/).filter(Boolean);
31+ const first = clean[0]?.[0] ?? '#';
32+ const second = clean.length > 1 ? (clean[clean.length - 1][0] ?? '') : '';
33+ return (first + second).toUpperCase();
34+}
35+
36+export function senderHue(senderId: string): number {
37+ let h = 0;
38+ for (let i = 0; i < senderId.length; i += 1) h = (h * 31 + senderId.charCodeAt(i)) % 360;
39+ return h;
40+}
Added file: web/src/lib/guards.ts+3 additions
@@ -0,0 +1,3 @@
1+export function isRecord(value: unknown): value is Record<string, unknown> {
2+ return typeof value === 'object' && value !== null;
3+}
Added file: web/src/lib/malice-device-transport.d.ts+4 additions
@@ -0,0 +1,4 @@
1+declare module 'malice:device-transport' {
2+ import type { Transport } from './transport';
3+ export function createDeviceTransport(): Transport;
4+}
Added file: web/src/lib/media-decrypt.ts+87 additions
@@ -0,0 +1,87 @@
1+// AES-256-CTR range decryption for Matrix attachments (the only cipher Matrix
2+// has, per vendor/macltok's AttachmentEncryption). WebCrypto's AES-CTR handles
3+// a partial final block within one call but not a resumed one, so a block that
4+// isn't a multiple of 16 bytes has to wait for more ciphertext before it can be
5+// decrypted; see RangeDecryptor.push.
6+
7+const BLOCK_SIZE = 16;
8+
9+function decodeBase64(value: string): Uint8Array {
10+ const normalised = value.replace(/-/g, '+').replace(/_/g, '/');
11+ const padded = normalised + '='.repeat((4 - (normalised.length % 4)) % 4);
12+ const binary = atob(padded);
13+ const bytes = new Uint8Array(binary.length);
14+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
15+ return bytes;
16+}
17+
18+export async function importAttachmentKey(k: string): Promise<CryptoKey> {
19+ return crypto.subtle.importKey('raw', decodeBase64(k), { name: 'AES-CTR' }, false, ['decrypt']);
20+}
21+
22+export function decodeAttachmentIv(iv: string): Uint8Array {
23+ const bytes = decodeBase64(iv);
24+ if (bytes.length >= BLOCK_SIZE) return bytes.subarray(0, BLOCK_SIZE);
25+ const padded = new Uint8Array(BLOCK_SIZE);
26+ padded.set(bytes);
27+ return padded;
28+}
29+
30+/** iv + blocks, added as a big-endian 64-bit value into the low 8 bytes only, per the Matrix attachment convention. */
31+export function addBlockOffset(iv: Uint8Array, blocks: number): Uint8Array {
32+ const counter = iv.slice();
33+ const view = new DataView(counter.buffer, counter.byteOffset, counter.byteLength);
34+ const low = view.getBigUint64(8);
35+ view.setBigUint64(8, (low + BigInt(blocks)) & 0xffffffffffffffffn);
36+ return counter;
37+}
38+
39+function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
40+ if (a.length === 0) return b;
41+ if (b.length === 0) return a;
42+ const out = new Uint8Array(a.length + b.length);
43+ out.set(a, 0);
44+ out.set(b, a.length);
45+ return out;
46+}
47+
48+/**
49+ * Decrypts a ciphertext range that starts `startBlock` 16-byte blocks into the
50+ * attachment. Feed it ciphertext in arrival order via `push`, then `finish` once
51+ * the source is exhausted to flush the trailing partial block.
52+ */
53+export class RangeDecryptor {
54+ private buffered = new Uint8Array(0);
55+ private blocksConsumed = 0;
56+
57+ constructor(
58+ private readonly key: CryptoKey,
59+ private readonly iv: Uint8Array,
60+ private readonly startBlock: number
61+ ) {}
62+
63+ async push(chunk: Uint8Array): Promise<Uint8Array> {
64+ const data = concat(this.buffered, chunk);
65+ const wholeLen = data.length - (data.length % BLOCK_SIZE);
66+ if (wholeLen === 0) {
67+ this.buffered = data;
68+ return new Uint8Array(0);
69+ }
70+ this.buffered = data.subarray(wholeLen);
71+ return this.decrypt(data.subarray(0, wholeLen));
72+ }
73+
74+ async finish(): Promise<Uint8Array> {
75+ if (this.buffered.length === 0) return new Uint8Array(0);
76+ const plain = await this.decrypt(this.buffered);
77+ this.buffered = new Uint8Array(0);
78+ return plain;
79+ }
80+
81+ private async decrypt(ciphertext: Uint8Array): Promise<Uint8Array> {
82+ const counter = addBlockOffset(this.iv, this.startBlock + this.blocksConsumed);
83+ this.blocksConsumed += Math.ceil(ciphertext.length / BLOCK_SIZE);
84+ const plain = await crypto.subtle.decrypt({ name: 'AES-CTR', counter, length: 64 }, this.key, ciphertext);
85+ return new Uint8Array(plain);
86+ }
87+}
Added file: web/src/lib/media-sniff.ts+51 additions
@@ -0,0 +1,51 @@
1+// Ported from crates/malice-core/src/media.rs's attachment_content_type, so the
2+// service worker can sniff decrypted bytes without a round trip to the wasm host.
3+
4+function startsWith(bytes: Uint8Array, prefix: number[]): boolean {
5+ if (bytes.length < prefix.length) return false;
6+ for (let i = 0; i < prefix.length; i++) {
7+ if (bytes[i] !== prefix[i]) return false;
8+ }
9+ return true;
10+}
11+
12+function ascii(text: string): number[] {
13+ return Array.from(text, (ch) => ch.charCodeAt(0));
14+}
15+
16+function bytesEqual(bytes: Uint8Array, offset: number, text: string): boolean {
17+ const wanted = ascii(text);
18+ if (bytes.length < offset + wanted.length) return false;
19+ for (let i = 0; i < wanted.length; i++) {
20+ if (bytes[offset + i] !== wanted[i]) return false;
21+ }
22+ return true;
23+}
24+
25+export function imageContentType(bytes: Uint8Array): string | null {
26+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png';
27+ if (startsWith(bytes, [0xff, 0xd8, 0xff])) return 'image/jpeg';
28+ if (bytesEqual(bytes, 0, 'GIF87a') || bytesEqual(bytes, 0, 'GIF89a')) return 'image/gif';
29+ if (bytes.length >= 12 && bytesEqual(bytes, 0, 'RIFF') && bytesEqual(bytes, 8, 'WEBP')) return 'image/webp';
30+ if (bytes.length >= 12 && bytesEqual(bytes, 4, 'ftyp') && (bytesEqual(bytes, 8, 'avif') || bytesEqual(bytes, 8, 'avis'))) {
31+ return 'image/avif';
32+ }
33+ return null;
34+}
35+
36+export function attachmentContentType(bytes: Uint8Array): string | null {
37+ const image = imageContentType(bytes);
38+ if (image) return image;
39+ if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return 'video/webm';
40+ if (bytes.length >= 12 && bytesEqual(bytes, 0, 'RIFF') && bytesEqual(bytes, 8, 'WAVE')) return 'audio/wav';
41+ if (bytesEqual(bytes, 0, 'OggS')) return 'audio/ogg';
42+ if (bytesEqual(bytes, 0, 'ID3') || startsWith(bytes, [0xff, 0xfb])) return 'audio/mpeg';
43+ if (
44+ bytes.length >= 12 &&
45+ bytesEqual(bytes, 4, 'ftyp') &&
46+ ['isom', 'iso2', 'mp41', 'mp42', 'avc1'].some((brand) => bytesEqual(bytes, 8, brand))
47+ ) {
48+ return 'video/mp4';
49+ }
50+ return null;
51+}
Added file: web/src/lib/pending.ts+145 additions
@@ -0,0 +1,145 @@
1+import { get, writable } from 'svelte/store';
2+import { sendMessage } from './api';
3+import { timelines } from './store';
4+import type { TimelineItem } from './types';
5+
6+export interface PendingMessage {
7+ id: string;
8+ body: string;
9+ status: 'sending' | 'failed';
10+ error: string | null;
11+ createdAt: number;
12+}
13+
14+export const pendingByRoom = writable<Record<string, PendingMessage[]>>({});
15+
16+interface PendingRecord extends PendingMessage {
17+ roomId: string;
18+ knownIds: Set<string>;
19+}
20+
21+const records = new Map<string, PendingRecord>();
22+const queues = new Map<string, Promise<void>>();
23+let nextSeq = 1;
24+
25+function nextId(): string {
26+ return `pending-${nextSeq++}`;
27+}
28+
29+function ownIdentities(items: TimelineItem[]): Set<string> {
30+ const out = new Set<string>();
31+ for (const item of items) {
32+ if (item.kind !== 'event' || !item.event.own) continue;
33+ const id = item.event.transaction_id ?? item.event.event_id;
34+ if (id) out.add(id);
35+ }
36+ return out;
37+}
38+
39+function toPublic(record: PendingRecord): PendingMessage {
40+ return { id: record.id, body: record.body, status: record.status, error: record.error, createdAt: record.createdAt };
41+}
42+
43+function roomList(roomId: string): PendingMessage[] {
44+ return get(pendingByRoom)[roomId] ?? [];
45+}
46+
47+function setRoom(roomId: string, list: PendingMessage[]): void {
48+ pendingByRoom.update((all) => ({ ...all, [roomId]: list }));
49+}
50+
51+function addRecord(record: PendingRecord): void {
52+ records.set(record.id, record);
53+ setRoom(record.roomId, [...roomList(record.roomId), toPublic(record)]);
54+}
55+
56+function updateRecord(id: string, patch: Partial<Pick<PendingRecord, 'status' | 'error'>>): void {
57+ const record = records.get(id);
58+ if (!record) return;
59+ Object.assign(record, patch);
60+ setRoom(record.roomId, roomList(record.roomId).map((p) => (p.id === id ? toPublic(record) : p)));
61+}
62+
63+function removeRecord(id: string): void {
64+ const record = records.get(id);
65+ if (!record) return;
66+ records.delete(id);
67+ setRoom(record.roomId, roomList(record.roomId).filter((p) => p.id !== id));
68+}
69+
70+async function attemptSend(record: PendingRecord): Promise<void> {
71+ if (!records.has(record.id)) return;
72+ updateRecord(record.id, { status: 'sending', error: null });
73+ try {
74+ await sendMessage(record.roomId, record.body);
75+ } catch (e) {
76+ updateRecord(record.id, { status: 'failed', error: e instanceof Error ? e.message : 'Message could not be sent' });
77+ }
78+}
79+
80+function enqueue(record: PendingRecord): void {
81+ const previous = queues.get(record.roomId) ?? Promise.resolve();
82+ const next = previous.then(() => attemptSend(record));
83+ queues.set(record.roomId, next.catch(() => undefined));
84+}
85+
86+export function queueSend(roomId: string, body: string): void {
87+ const record: PendingRecord = {
88+ id: nextId(),
89+ roomId,
90+ body,
91+ status: 'sending',
92+ error: null,
93+ createdAt: Date.now(),
94+ knownIds: ownIdentities(get(timelines)[roomId] ?? [])
95+ };
96+ addRecord(record);
97+ enqueue(record);
98+}
99+
100+export function retryPending(id: string): void {
101+ const record = records.get(id);
102+ if (!record) return;
103+ record.knownIds = ownIdentities(get(timelines)[record.roomId] ?? []);
104+ enqueue(record);
105+}
106+
107+export function discardPending(id: string): void {
108+ removeRecord(id);
109+}
110+
111+export function resetPending(): void {
112+ records.clear();
113+ queues.clear();
114+ pendingByRoom.set({});
115+}
116+
117+function reconcileRoom(roomId: string, items: TimelineItem[]): void {
118+ const list = roomList(roomId);
119+ if (list.length === 0) return;
120+ const ownItems = items.filter((item) => item.kind === 'event' && item.event.own) as Extract<
121+ TimelineItem,
122+ { kind: 'event' }
123+ >[];
124+ const claimed = new Set<string>();
125+ for (const pending of list) {
126+ const record = records.get(pending.id);
127+ if (!record) continue;
128+ const match = ownItems.find((item) => {
129+ if (claimed.has(item.event.item_id)) return false;
130+ const id = item.event.transaction_id ?? item.event.event_id;
131+ if (id && record.knownIds.has(id)) return false;
132+ return item.event.content.kind === 'Message' && item.event.content.message.body === record.body;
133+ });
134+ if (match) {
135+ claimed.add(match.event.item_id);
136+ removeRecord(record.id);
137+ }
138+ }
139+}
140+
141+timelines.subscribe((all) => {
142+ for (const roomId of Object.keys(get(pendingByRoom))) {
143+ reconcileRoom(roomId, all[roomId] ?? []);
144+ }
145+});
Added file: web/src/lib/store.ts+177 additions
@@ -0,0 +1,177 @@
1+import { writable } from 'svelte/store';
2+import { getSession, subscribeEvents } from './api';
3+import type { SoftLogout } from './api';
4+import { isRecord } from './guards';
5+import {
6+ parseEncryptionState,
7+ parseRoomList,
8+ parseTimelineList,
9+ parseVerificationState
10+} from './types';
11+import type {
12+ EncryptionState,
13+ MemberSummary,
14+ RoomSummary,
15+ TimelineItem,
16+ VerificationState
17+} from './types';
18+
19+export const sessionUser = writable<string | null | undefined>(undefined);
20+export const softLogout = writable<SoftLogout | null>(null);
21+/** Device build only: this tab lost the store lock to another tab that has malice open. */
22+export const blocked = writable<boolean>(false);
23+export const rooms = writable<RoomSummary[]>([]);
24+export const timelines = writable<Record<string, TimelineItem[]>>({});
25+export const connection = writable<string>('connecting');
26+export const selectedRoomId = writable<string | null>(null);
27+export const membersByRoom = writable<Record<string, MemberSummary[]>>({});
28+export const membersLoading = writable<boolean>(false);
29+export const encryption = writable<EncryptionState | null>(null);
30+export const verification = writable<VerificationState | null>(null);
31+
32+export interface Banner {
33+ id: number;
34+ message: string;
35+}
36+
37+export const banners = writable<Banner[]>([]);
38+let nextBannerId = 1;
39+
40+export function pushBanner(message: string): void {
41+ const id = nextBannerId++;
42+ banners.update((list) => [...list.slice(-4), { id, message }]);
43+}
44+
45+export function dismissBanner(id: number): void {
46+ banners.update((list) => list.filter((b) => b.id !== id));
47+}
48+
49+export function resetClientState(): void {
50+ rooms.set([]);
51+ timelines.set({});
52+ connection.set('connecting');
53+ selectedRoomId.set(null);
54+ membersByRoom.set({});
55+ membersLoading.set(false);
56+ encryption.set(null);
57+ verification.set(null);
58+ banners.set([]);
59+}
60+
61+function applyTimelinePayload(roomId: string, payload: Record<string, unknown>): void {
62+ if (!Array.isArray(payload['items'])) return;
63+ const items = parseTimelineList(payload['items']);
64+ timelines.update((all) => ({ ...all, [roomId]: items }));
65+}
66+
67+function setRooms(raw: unknown): void {
68+ rooms.set(parseRoomList(raw));
69+}
70+
71+function handlePayload(text: string): void {
72+ let data: unknown;
73+ try {
74+ data = JSON.parse(text);
75+ } catch {
76+ return;
77+ }
78+ if (!isRecord(data)) return;
79+ const kind = data['type'];
80+ if (kind === 'snapshot') {
81+ setRooms(data['rooms']);
82+ const rawTimelines = data['timelines'];
83+ if (isRecord(rawTimelines)) {
84+ const next: Record<string, TimelineItem[]> = {};
85+ for (const [roomId, list] of Object.entries(rawTimelines)) {
86+ next[roomId] = parseTimelineList(list);
87+ }
88+ timelines.set(next);
89+ }
90+ if (typeof data['connection'] === 'string') connection.set(data['connection']);
91+ const snapshotEncryption = parseEncryptionState(data['encryption']);
92+ if (snapshotEncryption) encryption.set(snapshotEncryption);
93+ const snapshotVerification = parseVerificationState(data['verification']);
94+ if (snapshotVerification) verification.set(snapshotVerification);
95+ return;
96+ }
97+ if (kind === 'rooms') {
98+ setRooms(data['rooms']);
99+ return;
100+ }
101+ if (kind === 'timeline') {
102+ const roomId = data['room_id'];
103+ if (typeof roomId === 'string') applyTimelinePayload(roomId, data);
104+ return;
105+ }
106+ if (kind === 'timeline_closed') {
107+ const roomId = data['room_id'];
108+ if (typeof roomId === 'string') {
109+ timelines.update((all) => {
110+ const next = { ...all };
111+ delete next[roomId];
112+ return next;
113+ });
114+ }
115+ return;
116+ }
117+ if (kind === 'connection') {
118+ if (typeof data['state'] === 'string') connection.set(data['state']);
119+ return;
120+ }
121+ if (kind === 'encryption') {
122+ const nextEncryption = parseEncryptionState(data['state']);
123+ if (nextEncryption) encryption.set(nextEncryption);
124+ return;
125+ }
126+ if (kind === 'verification') {
127+ const nextVerification = parseVerificationState(data['state']);
128+ if (nextVerification) verification.set(nextVerification);
129+ return;
130+ }
131+ if (kind === 'session_ended') {
132+ disconnectEvents();
133+ resetClientState();
134+ void getSession()
135+ .then((session) => {
136+ softLogout.set(session.soft_logout ?? null);
137+ blocked.set(session.blocked ?? false);
138+ sessionUser.set(session.user_id);
139+ })
140+ .catch(() => sessionUser.set(null));
141+ return;
142+ }
143+ if (kind === 'error') {
144+ const message = data['message'];
145+ pushBanner(typeof message === 'string' && message ? message : 'Sync error from server');
146+ }
147+}
148+
149+let unsubscribeEvents: (() => void) | null = null;
150+
151+function handleStreamDisconnect(): void {
152+ connection.set('reconnecting');
153+ void getSession()
154+ .then((session) => {
155+ if (session.user_id === null) {
156+ disconnectEvents();
157+ resetClientState();
158+ softLogout.set(session.soft_logout ?? null);
159+ blocked.set(session.blocked ?? false);
160+ sessionUser.set(null);
161+ }
162+ })
163+ .catch(() => undefined);
164+}
165+
166+/** Open the event stream; safe to call again (replaces any live subscription). */
167+export function connectEvents(): void {
168+ disconnectEvents();
169+ unsubscribeEvents = subscribeEvents(handlePayload, handleStreamDisconnect);
170+}
171+
172+export function disconnectEvents(): void {
173+ if (unsubscribeEvents) {
174+ unsubscribeEvents();
175+ unsubscribeEvents = null;
176+ }
177+}
Added file: web/src/lib/transport-http.ts+250 additions
@@ -0,0 +1,250 @@
1+import type { MemberSummary, RoomSummary } from './types';
2+import { ApiError, type SessionInfo, type Transport, type VerifyAction } from './transport';
3+
4+async function parseFailure(res: Response, fallback: string): Promise<never> {
5+ let message = fallback;
6+ try {
7+ const text = await res.text();
8+ if (text) {
9+ try {
10+ const data = JSON.parse(text) as unknown;
11+ if (typeof data === 'object' && data !== null) {
12+ const m = (data as Record<string, unknown>)['message'] ?? (data as Record<string, unknown>)['error'];
13+ if (typeof m === 'string' && m) message = m;
14+ else message = text.slice(0, 300);
15+ } else if (typeof data === 'string' && data) {
16+ message = data;
17+ } else {
18+ message = text.slice(0, 300);
19+ }
20+ } catch {
21+ message = text.slice(0, 300);
22+ }
23+ }
24+ } catch {
25+ // keep fallback
26+ }
27+ throw new ApiError(res.status, `${res.status}: ${message}`);
28+}
29+
30+async function json<T>(res: Response, fallback: string): Promise<T> {
31+ if (!res.ok) await parseFailure(res, fallback);
32+ if (res.status === 204) return undefined as T;
33+ return (await res.json()) as T;
34+}
35+
36+const ROOM_COMMAND_PATHS = {
37+ open: '/api/rooms/open',
38+ close: '/api/rooms/close',
39+ send: '/api/rooms/send',
40+ paginate: '/api/rooms/paginate',
41+ leave: '/api/rooms/leave',
42+ react: '/api/rooms/react',
43+ read: '/api/rooms/read',
44+ typing: '/api/rooms/typing'
45+} as const;
46+
47+export class HttpTransport implements Transport {
48+ async getSession(): Promise<SessionInfo> {
49+ const res = await fetch('/api/session', { credentials: 'same-origin' });
50+ return json(res, 'Could not load session');
51+ }
52+
53+ async login(homeserver: string, username: string, password: string): Promise<{ user_id: string }> {
54+ const res = await fetch('/api/login', {
55+ method: 'POST',
56+ credentials: 'same-origin',
57+ headers: { 'content-type': 'application/json' },
58+ body: JSON.stringify({ homeserver, username, password })
59+ });
60+ return json(res, 'Login failed');
61+ }
62+
63+ async logout(): Promise<void> {
64+ const res = await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
65+ if (!res.ok && res.status !== 204) await parseFailure(res, 'Logout failed');
66+ }
67+
68+ async getRooms(): Promise<{ rooms: RoomSummary[] }> {
69+ const res = await fetch('/api/rooms', { credentials: 'same-origin' });
70+ return json(res, 'Could not load rooms');
71+ }
72+
73+ private async roomCommand(
74+ path: (typeof ROOM_COMMAND_PATHS)[keyof typeof ROOM_COMMAND_PATHS],
75+ payload: Record<string, string | boolean>
76+ ): Promise<void> {
77+ const res = await fetch(path, {
78+ method: 'POST',
79+ credentials: 'same-origin',
80+ headers: { 'content-type': 'application/json' },
81+ body: JSON.stringify(payload)
82+ });
83+ return json(res, `Request to ${path} failed`);
84+ }
85+
86+ openRoom(room_id: string): Promise<void> {
87+ return this.roomCommand(ROOM_COMMAND_PATHS.open, { room_id });
88+ }
89+ closeRoom(room_id: string): Promise<void> {
90+ return this.roomCommand(ROOM_COMMAND_PATHS.close, { room_id });
91+ }
92+ sendMessage(room_id: string, body: string): Promise<void> {
93+ return this.roomCommand(ROOM_COMMAND_PATHS.send, { room_id, body });
94+ }
95+ paginateRoom(room_id: string): Promise<void> {
96+ return this.roomCommand(ROOM_COMMAND_PATHS.paginate, { room_id });
97+ }
98+ setTyping(room_id: string, typing: boolean): Promise<void> {
99+ return this.roomCommand(ROOM_COMMAND_PATHS.typing, { room_id, typing });
100+ }
101+ leaveRoom(room_id: string): Promise<void> {
102+ return this.roomCommand(ROOM_COMMAND_PATHS.leave, { room_id });
103+ }
104+ reactToMessage(room_id: string, event_id: string, key: string): Promise<void> {
105+ return this.roomCommand(ROOM_COMMAND_PATHS.react, { room_id, event_id, key });
106+ }
107+ markRead(room_id: string, event_id: string): Promise<void> {
108+ return this.roomCommand(ROOM_COMMAND_PATHS.read, { room_id, event_id });
109+ }
110+
111+ async joinRoom(room: string): Promise<{ room_id: string }> {
112+ const res = await fetch('/api/rooms/join', {
113+ method: 'POST',
114+ credentials: 'same-origin',
115+ headers: { 'content-type': 'application/json' },
116+ body: JSON.stringify({ room })
117+ });
118+ return json(res, 'Join failed');
119+ }
120+
121+ async createRoom(name: string, topic?: string): Promise<{ room_id: string }> {
122+ const res = await fetch('/api/rooms/create', {
123+ method: 'POST',
124+ credentials: 'same-origin',
125+ headers: { 'content-type': 'application/json' },
126+ body: JSON.stringify(topic ? { name, topic } : { name })
127+ });
128+ return json(res, 'Create room failed');
129+ }
130+
131+ async getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {
132+ const res = await fetch(`/api/rooms/members?room_id=${encodeURIComponent(room_id)}`, {
133+ credentials: 'same-origin'
134+ });
135+ return json(res, 'Could not load members');
136+ }
137+
138+ async getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }> {
139+ const res = await fetch('/api/profile', { credentials: 'same-origin' });
140+ return json(res, 'Could not load profile');
141+ }
142+
143+ async getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }> {
144+ const res = await fetch(`/api/spaces/children?room_id=${encodeURIComponent(room_id)}`, {
145+ credentials: 'same-origin'
146+ });
147+ return json(res, 'Could not load rooms in this space');
148+ }
149+
150+ async getEncryption(): Promise<{ state: unknown; verification: unknown }> {
151+ const res = await fetch('/api/encryption', { credentials: 'same-origin' });
152+ return json(res, 'Could not load encryption status');
153+ }
154+
155+ async recoverEncryption(recovery_key: string): Promise<void> {
156+ const res = await fetch('/api/encryption/recover', {
157+ method: 'POST',
158+ credentials: 'same-origin',
159+ headers: { 'content-type': 'application/json' },
160+ body: JSON.stringify({ recovery_key })
161+ });
162+ return json(res, 'Recovery failed');
163+ }
164+
165+ async startVerification(): Promise<void> {
166+ const res = await fetch('/api/encryption/start', {
167+ method: 'POST',
168+ credentials: 'same-origin',
169+ headers: { 'content-type': 'application/json' },
170+ body: JSON.stringify({})
171+ });
172+ return json(res, 'Could not start verification');
173+ }
174+
175+ async verifyEncryption(action: VerifyAction): Promise<void> {
176+ const res = await fetch('/api/encryption/verify', {
177+ method: 'POST',
178+ credentials: 'same-origin',
179+ headers: { 'content-type': 'application/json' },
180+ body: JSON.stringify({ action })
181+ });
182+ return json(res, 'Verification action failed');
183+ }
184+
185+ async uploadAttachment(room_id: string, file: File): Promise<void> {
186+ const form = new FormData();
187+ form.append('room_id', room_id);
188+ form.append('file', file, file.name || 'attachment');
189+ const res = await fetch('/api/rooms/upload', {
190+ method: 'POST',
191+ credentials: 'same-origin',
192+ body: form
193+ });
194+ return json(res, 'Upload failed');
195+ }
196+
197+ /**
198+ * Keeps its own EventSource alive with backoff for as long as the caller
199+ * hasn't unsubscribed; onDisconnect is just a signal, not a request to stop.
200+ */
201+ subscribeEvents(onEvent: (data: string) => void, onDisconnect: () => void): () => void {
202+ let active = true;
203+ let source: EventSource | null = null;
204+ let retryTimer: number | null = null;
205+ let retryMs = 1000;
206+
207+ const scheduleReconnect = (): void => {
208+ if (!active || retryTimer !== null) return;
209+ const delay = retryMs;
210+ retryMs = Math.min(retryMs * 2, 15000);
211+ retryTimer = window.setTimeout(() => {
212+ retryTimer = null;
213+ if (active) open();
214+ }, delay);
215+ };
216+
217+ const open = (): void => {
218+ const next = new EventSource('/api/events');
219+ source = next;
220+ next.onmessage = (ev: MessageEvent) => {
221+ retryMs = 1000;
222+ if (typeof ev.data === 'string') onEvent(ev.data);
223+ };
224+ next.onerror = () => {
225+ if (source !== next) return;
226+ next.close();
227+ source = null;
228+ onDisconnect();
229+ scheduleReconnect();
230+ };
231+ };
232+ open();
233+
234+ return () => {
235+ active = false;
236+ if (retryTimer !== null) {
237+ window.clearTimeout(retryTimer);
238+ retryTimer = null;
239+ }
240+ if (source) {
241+ try {
242+ source.close();
243+ } catch {
244+ // already gone
245+ }
246+ source = null;
247+ }
248+ };
249+ }
250+}
Added file: web/src/lib/transport-worker.ts+291 additions
@@ -0,0 +1,291 @@
1+import type { MemberSummary, RoomSummary } from './types';
2+import { ApiError, type SessionInfo, type SoftLogout, type Transport, type VerifyAction } from './transport';
3+import { statusForKind } from './wire-status';
4+
5+function toApiError(error: { kind: string; message: string }): ApiError {
6+ return new ApiError(statusForKind(error.kind), error.message);
7+}
8+
9+const SW_RELOAD_GUARD = 'malice-sw-reload';
10+
11+/** Registers the service worker and recovers from a shift-reload leaving the page uncontrolled. */
12+function registerServiceWorker(): void {
13+ if (!('serviceWorker' in navigator)) return;
14+ void navigator.serviceWorker
15+ .register('/sw.js', { type: 'module' })
16+ .then(() => navigator.serviceWorker.ready)
17+ .then(() => {
18+ if (navigator.serviceWorker.controller) {
19+ sessionStorage.removeItem(SW_RELOAD_GUARD);
20+ return;
21+ }
22+ if (sessionStorage.getItem(SW_RELOAD_GUARD)) return;
23+ sessionStorage.setItem(SW_RELOAD_GUARD, '1');
24+ window.location.reload();
25+ })
26+ .catch(() => undefined);
27+}
28+
29+type Pending = { resolve: (value: unknown) => void; reject: (error: unknown) => void };
30+
31+/**
32+ * Owns the SharedWorker (or, where unavailable, a dedicated Worker) and the
33+ * request/response and event-fanout protocol described in the on-device
34+ * design doc. A single instance is shared by every WorkerTransport, since
35+ * the UI only ever drives one account at a time.
36+ */
37+class WorkerClient {
38+ private port: MessagePort | Worker | null = null;
39+ private nextId = 1;
40+ private pending = new Map<number, Pending>();
41+ private account: string | null = null;
42+ private listeners = new Set<(account: string, event: string) => void>();
43+ private fatalHandlers = new Set<() => void>();
44+ private subscribed = new Set<string>();
45+
46+ constructor() {
47+ registerServiceWorker();
48+ window.addEventListener('pagehide', () => {
49+ if (this.port) this.post({ kind: 'bye' });
50+ });
51+ }
52+
53+ get accountId(): string | null {
54+ return this.account;
55+ }
56+
57+ private connect(): void {
58+ if (typeof SharedWorker !== 'undefined') {
59+ const worker = new SharedWorker(new URL('../worker/worker.ts', import.meta.url), {
60+ type: 'module',
61+ name: 'malice'
62+ });
63+ worker.port.onmessage = (ev: MessageEvent) => this.handleMessage(ev.data);
64+ worker.port.start();
65+ this.port = worker.port;
66+ } else {
67+ const worker = new Worker(new URL('../worker/worker.ts', import.meta.url), { type: 'module' });
68+ worker.onmessage = (ev: MessageEvent) => this.handleMessage(ev.data);
69+ this.port = worker;
70+ }
71+ for (const account of this.subscribed) this.post({ kind: 'subscribe', account });
72+ }
73+
74+ private post(message: Record<string, unknown>, transfer?: Transferable[]): void {
75+ if (!this.port) this.connect();
76+ if (!this.port) return;
77+ if (transfer && transfer.length > 0) this.port.postMessage(message, transfer);
78+ else this.port.postMessage(message);
79+ }
80+
81+ private rpc<T>(message: Record<string, unknown>, transfer?: Transferable[]): Promise<T> {
82+ const id = this.nextId++;
83+ return new Promise<T>((resolve, reject) => {
84+ this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
85+ this.post({ ...message, id }, transfer);
86+ });
87+ }
88+
89+ private handleMessage(data: unknown): void {
90+ if (typeof data !== 'object' || data === null) return;
91+ const msg = data as Record<string, unknown>;
92+ if (msg['kind'] === 'reply') {
93+ const id = msg['id'];
94+ if (typeof id !== 'number') return;
95+ const pending = this.pending.get(id);
96+ if (!pending) return;
97+ this.pending.delete(id);
98+ if (msg['error']) pending.reject(toApiError(msg['error'] as { kind: string; message: string }));
99+ else pending.resolve(msg['ok']);
100+ return;
101+ }
102+ if (msg['kind'] === 'event') {
103+ const account = msg['account'];
104+ const event = msg['event'];
105+ if (typeof account === 'string' && typeof event === 'string') {
106+ for (const listener of this.listeners) listener(account, event);
107+ }
108+ return;
109+ }
110+ if (msg['kind'] === 'fatal') this.handleFatal();
111+ }
112+
113+ /** The worker crashed and closed every port; every tab respawns it after a beat. */
114+ private handleFatal(): void {
115+ this.port = null;
116+ for (const pending of this.pending.values()) {
117+ pending.reject(new ApiError(502, 'the worker stopped; reconnecting'));
118+ }
119+ this.pending.clear();
120+ for (const handler of this.fatalHandlers) handler();
121+ window.setTimeout(() => this.connect(), 1000);
122+ }
123+
124+ async getSession(): Promise<SessionInfo> {
125+ const reply = await this.rpc<{
126+ user_id: string | null;
127+ soft_logout?: SoftLogout;
128+ account?: string;
129+ blocked?: true;
130+ }>({ kind: 'session' });
131+ if (reply.blocked) this.account = null;
132+ else if (reply.account) this.account = reply.account;
133+ return { user_id: reply.user_id, soft_logout: reply.soft_logout, blocked: reply.blocked };
134+ }
135+
136+ async login(homeserver: string, username: string, password: string): Promise<{ user_id: string }> {
137+ const reply = await this.rpc<{ user_id: string; account: string }>({
138+ kind: 'login',
139+ homeserver,
140+ username,
141+ password
142+ });
143+ this.account = reply.account;
144+ return { user_id: reply.user_id };
145+ }
146+
147+ async logout(): Promise<void> {
148+ const account = this.account;
149+ if (!account) return;
150+ await this.rpc<Record<string, never>>({ kind: 'logout', account });
151+ this.subscribed.delete(account);
152+ this.account = null;
153+ }
154+
155+ async call<T>(op: string, fields: Record<string, unknown> = {}, bytes?: ArrayBuffer): Promise<T> {
156+ if (!this.account) throw new ApiError(401, 'not signed in');
157+ const reply = await this.rpc<{ json?: T }>(
158+ { kind: 'call', account: this.account, request: { op, ...fields }, bytes },
159+ bytes ? [bytes] : undefined
160+ );
161+ return reply.json as T;
162+ }
163+
164+ addListener(listener: (account: string, event: string) => void): void {
165+ this.listeners.add(listener);
166+ }
167+
168+ removeListener(listener: (account: string, event: string) => void): void {
169+ this.listeners.delete(listener);
170+ }
171+
172+ onFatal(handler: () => void): () => void {
173+ this.fatalHandlers.add(handler);
174+ return () => this.fatalHandlers.delete(handler);
175+ }
176+
177+ subscribe(account: string): void {
178+ this.subscribed.add(account);
179+ this.post({ kind: 'subscribe', account });
180+ }
181+
182+ unsubscribe(account: string): void {
183+ this.subscribed.delete(account);
184+ this.post({ kind: 'unsubscribe', account });
185+ }
186+}
187+
188+let sharedClient: WorkerClient | null = null;
189+function getClient(): WorkerClient {
190+ if (!sharedClient) sharedClient = new WorkerClient();
191+ return sharedClient;
192+}
193+
194+export class WorkerTransport implements Transport {
195+ private client = getClient();
196+
197+ getSession(): Promise<SessionInfo> {
198+ return this.client.getSession();
199+ }
200+
201+ login(homeserver: string, username: string, password: string): Promise<{ user_id: string }> {
202+ return this.client.login(homeserver, username, password);
203+ }
204+
205+ logout(): Promise<void> {
206+ return this.client.logout();
207+ }
208+
209+ getRooms(): Promise<{ rooms: RoomSummary[] }> {
210+ return this.client.call('rooms');
211+ }
212+ openRoom(room_id: string): Promise<void> {
213+ return this.client.call('open', { room_id });
214+ }
215+ closeRoom(room_id: string): Promise<void> {
216+ return this.client.call('close', { room_id });
217+ }
218+ sendMessage(room_id: string, body: string): Promise<void> {
219+ return this.client.call('send', { room_id, body });
220+ }
221+ paginateRoom(room_id: string): Promise<void> {
222+ return this.client.call('paginate', { room_id });
223+ }
224+ setTyping(room_id: string, typing: boolean): Promise<void> {
225+ return this.client.call('typing', { room_id, typing });
226+ }
227+ joinRoom(room: string): Promise<{ room_id: string }> {
228+ return this.client.call('join', { room });
229+ }
230+ createRoom(name: string, topic?: string): Promise<{ room_id: string }> {
231+ return this.client.call('create', topic ? { name, topic } : { name });
232+ }
233+ leaveRoom(room_id: string): Promise<void> {
234+ return this.client.call('leave', { room_id });
235+ }
236+ reactToMessage(room_id: string, event_id: string, key: string): Promise<void> {
237+ return this.client.call('react', { room_id, event_id, key });
238+ }
239+ markRead(room_id: string, event_id: string): Promise<void> {
240+ return this.client.call('read', { room_id, event_id });
241+ }
242+ getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {
243+ return this.client.call('members', { room_id });
244+ }
245+ getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }> {
246+ return this.client.call('profile');
247+ }
248+ getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }> {
249+ return this.client.call('space_children', { room_id });
250+ }
251+ getEncryption(): Promise<{ state: unknown; verification: unknown }> {
252+ return this.client.call('encryption');
253+ }
254+ recoverEncryption(recovery_key: string): Promise<void> {
255+ return this.client.call('recover', { recovery_key });
256+ }
257+ startVerification(): Promise<void> {
258+ return this.client.call('start_verification');
259+ }
260+ verifyEncryption(action: VerifyAction): Promise<void> {
261+ return this.client.call('verify', { action });
262+ }
263+
264+ async uploadAttachment(room_id: string, file: File): Promise<void> {
265+ const bytes = await file.arrayBuffer();
266+ await this.client.call(
267+ 'upload',
268+ { room_id, filename: file.name || 'attachment', mimetype: file.type || 'application/octet-stream' },
269+ bytes
270+ );
271+ }
272+
273+ subscribeEvents(onEvent: (data: string) => void, onDisconnect: () => void): () => void {
274+ let stopped = false;
275+ const account = this.client.accountId;
276+ const listener = (eventAccount: string, event: string) => {
277+ if (eventAccount === account) onEvent(event);
278+ };
279+ const removeFatal = this.client.onFatal(() => {
280+ if (!stopped) onDisconnect();
281+ });
282+ this.client.addListener(listener);
283+ if (account) this.client.subscribe(account);
284+ return () => {
285+ stopped = true;
286+ this.client.removeListener(listener);
287+ removeFatal();
288+ if (account) this.client.unsubscribe(account);
289+ };
290+ }
291+}
Added file: web/src/lib/transport.ts+61 additions
@@ -0,0 +1,61 @@
1+import type { MemberSummary, RoomSummary } from './types';
2+
3+export class ApiError extends Error {
4+ status: number;
5+ constructor(status: number, message: string) {
6+ super(message);
7+ this.name = 'ApiError';
8+ this.status = status;
9+ }
10+}
11+
12+export interface SoftLogout {
13+ user_id: string;
14+ homeserver: string;
15+}
16+
17+export interface SessionInfo {
18+ user_id: string | null;
19+ soft_logout?: SoftLogout;
20+ /** This tab lost the store lock to another tab; see `blocked` in App state. */
21+ blocked?: boolean;
22+}
23+
24+export type VerifyAction = 'Accept' | 'Confirm' | 'Reject' | 'Cancel';
25+
26+/**
27+ * Everything the app needs from the backend, hosted (fetch + SSE) or on-device
28+ * (SharedWorker running macltok on wasm). Both implementations throw ApiError
29+ * for failures and keep the same method shapes as the old api.ts.
30+ */
31+export interface Transport {
32+ getSession(): Promise<SessionInfo>;
33+ login(homeserver: string, username: string, password: string): Promise<{ user_id: string }>;
34+ logout(): Promise<void>;
35+ getRooms(): Promise<{ rooms: RoomSummary[] }>;
36+ openRoom(room_id: string): Promise<void>;
37+ closeRoom(room_id: string): Promise<void>;
38+ sendMessage(room_id: string, body: string): Promise<void>;
39+ paginateRoom(room_id: string): Promise<void>;
40+ setTyping(room_id: string, typing: boolean): Promise<void>;
41+ joinRoom(room: string): Promise<{ room_id: string }>;
42+ createRoom(name: string, topic?: string): Promise<{ room_id: string }>;
43+ leaveRoom(room_id: string): Promise<void>;
44+ reactToMessage(room_id: string, event_id: string, key: string): Promise<void>;
45+ markRead(room_id: string, event_id: string): Promise<void>;
46+ getMembers(room_id: string): Promise<{ members: MemberSummary[] }>;
47+ getProfile(): Promise<{ display_name: string | null; avatar_url: string | null }>;
48+ getSpaceChildren(room_id: string): Promise<{ room_ids: string[] }>;
49+ getEncryption(): Promise<{ state: unknown; verification: unknown }>;
50+ recoverEncryption(recovery_key: string): Promise<void>;
51+ startVerification(): Promise<void>;
52+ verifyEncryption(action: VerifyAction): Promise<void>;
53+ uploadAttachment(room_id: string, file: File): Promise<void>;
54+ /**
55+ * Wire events as raw JSON text, snapshot first. Returns an unsubscribe
56+ * function. onDisconnect fires whenever the underlying connection drops;
57+ * the transport keeps retrying on its own, callers only need onDisconnect
58+ * to notice a possible session change.
59+ */
60+ subscribeEvents(onEvent: (data: string) => void, onDisconnect: () => void): () => void;
61+}
Added file: web/src/lib/types.ts+649 additions
@@ -0,0 +1,649 @@
1+import { isRecord } from './guards';
2+
3+export interface RoomHero {
4+ user_id: string;
5+ display_name: string | null;
6+ avatar: string | null;
7+}
8+
9+export interface LatestEvent {
10+ sender: string | null;
11+ kind: MessageKind | null;
12+ body: string | null;
13+ timestamp_ms: number | null;
14+ send_state: SendState;
15+}
16+
17+export interface RoomSummary {
18+ room_id: string;
19+ name: string;
20+ membership: string;
21+ unread_messages: number;
22+ unread_mentions: number;
23+ unread_notifications: number;
24+ encrypted: boolean;
25+ space: boolean;
26+ direct: boolean;
27+ favourite: boolean;
28+ low_priority: boolean;
29+ avatar: string | null;
30+ latest_event_timestamp_ms: number | null;
31+ latest_event: LatestEvent | null;
32+ notification_mode: string | null;
33+ notification_mode_custom: boolean;
34+ topic: string | null;
35+ canonical_alias: string | null;
36+ joined_members: number;
37+ invited_members: number;
38+ heroes: RoomHero[];
39+ successor: string | null;
40+ inviter: string | null;
41+ parent_spaces: string[];
42+ children: string[];
43+}
44+
45+export interface MemberSummary {
46+ user_id: string;
47+ name: string;
48+ display_name: string | null;
49+ avatar: string | null;
50+ membership: string | { Custom: string };
51+ own: boolean;
52+ name_ambiguous: boolean;
53+ ignored: boolean;
54+ service: boolean;
55+}
56+
57+export type SendState =
58+ | { kind: 'Sent' }
59+ | { kind: 'Sending' }
60+ | { kind: 'Failed'; will_retry: boolean };
61+
62+function stringField(o: Record<string, unknown>, key: string, fallback = ''): string {
63+ const v = o[key];
64+ return typeof v === 'string' ? v : fallback;
65+}
66+
67+function numberField(o: Record<string, unknown>, key: string, fallback = 0): number {
68+ const v = o[key];
69+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
70+}
71+
72+function optionalString(o: Record<string, unknown>, key: string): string | null {
73+ const v = o[key];
74+ return typeof v === 'string' ? v : null;
75+}
76+
77+function optionalNumber(o: Record<string, unknown>, key: string): number | null {
78+ const v = o[key];
79+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
80+}
81+
82+function stringList(v: unknown): string[] {
83+ if (!Array.isArray(v)) return [];
84+ const out: string[] = [];
85+ for (const entry of v) if (typeof entry === 'string') out.push(entry);
86+ return out;
87+}
88+
89+export function parseSendState(raw: unknown): SendState {
90+ if (raw === 'Sent') return { kind: 'Sent' };
91+ if (raw === 'Sending') return { kind: 'Sending' };
92+ if (isRecord(raw)) {
93+ if (isRecord(raw['Failed'])) {
94+ return { kind: 'Failed', will_retry: raw['Failed']['will_retry'] === true };
95+ }
96+ const kind = raw['kind'];
97+ if (kind === 'Sent' || kind === 'Sending') return { kind };
98+ if (kind === 'Failed') return { kind: 'Failed', will_retry: raw['will_retry'] === true };
99+ }
100+ return { kind: 'Sent' };
101+}
102+
103+export interface MediaAttachment {
104+ id: string;
105+ kind: string;
106+ filename: string;
107+ caption: string | null;
108+ mimetype: string | null;
109+ size_bytes: number | null;
110+ width: number | null;
111+ height: number | null;
112+ duration_ms: number | null;
113+ blurhash: string | null;
114+ has_thumbnail: boolean;
115+}
116+
117+export function parseMediaAttachment(raw: unknown): MediaAttachment | null {
118+ if (!isRecord(raw)) return null;
119+ const idRaw = raw['id'];
120+ if (typeof idRaw !== 'string' || !idRaw) return null;
121+ const kindRaw = raw['kind'];
122+ return {
123+ id: idRaw,
124+ kind: typeof kindRaw === 'string' ? kindRaw : 'File',
125+ filename: stringField(raw, 'filename', 'attachment'),
126+ caption: optionalString(raw, 'caption'),
127+ mimetype: optionalString(raw, 'mimetype'),
128+ size_bytes: optionalNumber(raw, 'size_bytes'),
129+ width: optionalNumber(raw, 'width'),
130+ height: optionalNumber(raw, 'height'),
131+ duration_ms: optionalNumber(raw, 'duration_ms'),
132+ blurhash: optionalString(raw, 'blurhash'),
133+ has_thumbnail: raw['has_thumbnail'] === true
134+ };
135+}
136+
137+export type RecoveryState = 'Unknown' | 'Disabled' | 'Incomplete' | 'Enabled';
138+
139+export interface EncryptionState {
140+ device_id: string;
141+ device_verified: boolean;
142+ recovery: RecoveryState;
143+ key_backup: string;
144+}
145+
146+function parseRecoveryState(raw: unknown): RecoveryState {
147+ return raw === 'Enabled' || raw === 'Disabled' || raw === 'Incomplete' ? raw : 'Unknown';
148+}
149+
150+export function parseEncryptionState(raw: unknown): EncryptionState | null {
151+ if (!isRecord(raw)) return null;
152+ return {
153+ device_id: stringField(raw, 'device_id'),
154+ device_verified: raw['device_verified'] === true,
155+ recovery: parseRecoveryState(raw['recovery']),
156+ key_backup: typeof raw['key_backup'] === 'string' ? raw['key_backup'] : 'Unknown'
157+ };
158+}
159+
160+export interface SasEmoji {
161+ emoji: string;
162+ name: string;
163+}
164+
165+export type VerificationState =
166+ | { kind: 'Waiting' }
167+ | { kind: 'Incoming'; user_id: string | null; device_id: string | null }
168+ | { kind: 'Comparison'; emojis: SasEmoji[]; decimals: [number, number, number] }
169+ | { kind: 'Confirmed' }
170+ | { kind: 'Done' }
171+ | { kind: 'Cancelled'; reason: string }
172+ | { kind: 'Failed'; message: string };
173+
174+function parseEmojis(raw: unknown): SasEmoji[] {
175+ if (!Array.isArray(raw)) return [];
176+ const out: SasEmoji[] = [];
177+ for (const entry of raw) {
178+ if (
179+ Array.isArray(entry) &&
180+ entry.length >= 2 &&
181+ typeof entry[0] === 'string' &&
182+ typeof entry[1] === 'string'
183+ ) {
184+ out.push({ emoji: entry[0], name: entry[1] });
185+ } else if (isRecord(entry)) {
186+ const emoji = entry['emoji'];
187+ const name = entry['name'];
188+ if (typeof emoji === 'string') {
189+ out.push({ emoji, name: typeof name === 'string' ? name : '' });
190+ }
191+ }
192+ }
193+ return out;
194+}
195+
196+function parseDecimals(raw: unknown): [number, number, number] {
197+ const fallback: [number, number, number] = [0, 0, 0];
198+ if (!Array.isArray(raw)) return fallback;
199+ const nums = raw.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
200+ if (nums.length < 3) return fallback;
201+ return [nums[0], nums[1], nums[2]];
202+}
203+
204+function verificationFailureMessage(raw: unknown): string {
205+ if (typeof raw === 'string') return raw || 'verification failed';
206+ if (isRecord(raw)) {
207+ const keys = Object.keys(raw);
208+ if (keys.length === 0) return 'verification failed';
209+ const first = raw[keys[0]];
210+ if (typeof first === 'string' && first) return first;
211+ return keys[0];
212+ }
213+ return 'verification failed';
214+}
215+
216+/** Normalize the backend VerificationState (serde externally-tagged enum). */
217+export function parseVerificationState(raw: unknown): VerificationState | null {
218+ if (raw === null || raw === undefined) return null;
219+ if (raw === 'Waiting') return { kind: 'Waiting' };
220+ if (raw === 'Confirmed') return { kind: 'Confirmed' };
221+ if (raw === 'Done') return { kind: 'Done' };
222+ if (typeof raw === 'string') return null;
223+ if (!isRecord(raw)) return null;
224+ if (isRecord(raw['Comparison'])) {
225+ const inner = raw['Comparison'];
226+ return {
227+ kind: 'Comparison',
228+ emojis: parseEmojis(inner['emojis']),
229+ decimals: parseDecimals(inner['decimals'])
230+ };
231+ }
232+ if (isRecord(raw['Incoming'])) {
233+ const incoming = raw['Incoming'];
234+ const userId = incoming['user_id'];
235+ const deviceId = incoming['device_id'];
236+ return {
237+ kind: 'Incoming',
238+ user_id: typeof userId === 'string' ? userId : null,
239+ device_id: typeof deviceId === 'string' ? deviceId : null
240+ };
241+ }
242+ if ('Cancelled' in raw) {
243+ const reason = raw['Cancelled'];
244+ return { kind: 'Cancelled', reason: typeof reason === 'string' ? reason : 'cancelled' };
245+ }
246+ if ('Failed' in raw) return { kind: 'Failed', message: verificationFailureMessage(raw['Failed']) };
247+ return null;
248+}
249+
250+export interface OwnProfile {
251+ display_name: string | null;
252+ avatar_url: string | null;
253+}
254+
255+export function parseOwnProfile(raw: unknown): OwnProfile {
256+ if (!isRecord(raw)) return { display_name: null, avatar_url: null };
257+ const displayName = raw['display_name'];
258+ const avatarUrl = raw['avatar_url'];
259+ return {
260+ display_name: typeof displayName === 'string' ? displayName : null,
261+ avatar_url: typeof avatarUrl === 'string' ? avatarUrl : null
262+ };
263+}
264+
265+export function parseRoomIdList(raw: unknown): string[] {
266+ if (!isRecord(raw) || !Array.isArray(raw['room_ids'])) return [];
267+ return raw['room_ids'].filter((entry): entry is string => typeof entry === 'string');
268+}
269+
270+function parseHeroes(raw: unknown): RoomHero[] {
271+ if (!Array.isArray(raw)) return [];
272+ const out: RoomHero[] = [];
273+ for (const entry of raw) {
274+ if (!isRecord(entry)) continue;
275+ const userId = entry['user_id'];
276+ if (typeof userId !== 'string') continue;
277+ out.push({
278+ user_id: userId,
279+ display_name: optionalString(entry, 'display_name'),
280+ avatar: optionalString(entry, 'avatar')
281+ });
282+ }
283+ return out;
284+}
285+
286+export type MessageKind =
287+ | 'Text'
288+ | 'Emote'
289+ | 'Notice'
290+ | 'ServerNotice'
291+ | 'Image'
292+ | 'Video'
293+ | 'Audio'
294+ | 'File'
295+ | 'Location'
296+ | 'VerificationRequest'
297+ | { Other: string };
298+
299+const PLAIN_MESSAGE_KINDS = new Set([
300+ 'Text',
301+ 'Emote',
302+ 'Notice',
303+ 'ServerNotice',
304+ 'Image',
305+ 'Video',
306+ 'Audio',
307+ 'File',
308+ 'Location',
309+ 'VerificationRequest'
310+]);
311+
312+function parseMessageKind(raw: unknown): MessageKind {
313+ if (typeof raw === 'string' && PLAIN_MESSAGE_KINDS.has(raw)) return raw as MessageKind;
314+ if (isRecord(raw) && typeof raw['Other'] === 'string') return { Other: raw['Other'] };
315+ return 'Text';
316+}
317+
318+function parseOptionalMessageKind(raw: unknown): MessageKind | null {
319+ if (raw === null || raw === undefined) return null;
320+ return parseMessageKind(raw);
321+}
322+
323+function parseLatestEvent(raw: unknown): LatestEvent | null {
324+ if (!isRecord(raw)) return null;
325+ return {
326+ sender: optionalString(raw, 'sender'),
327+ kind: parseOptionalMessageKind(raw['kind']),
328+ body: optionalString(raw, 'body'),
329+ timestamp_ms: optionalNumber(raw, 'timestamp_ms'),
330+ send_state: parseSendState(raw['send_state'])
331+ };
332+}
333+
334+export function parseRoomSummary(raw: unknown): RoomSummary | null {
335+ if (!isRecord(raw)) return null;
336+ const roomId = raw['room_id'];
337+ if (typeof roomId !== 'string') return null;
338+ return {
339+ room_id: roomId,
340+ name: stringField(raw, 'name'),
341+ membership: stringField(raw, 'membership'),
342+ unread_messages: numberField(raw, 'unread_messages'),
343+ unread_mentions: numberField(raw, 'unread_mentions'),
344+ unread_notifications: numberField(raw, 'unread_notifications'),
345+ encrypted: raw['encrypted'] === true,
346+ space: raw['space'] === true,
347+ direct: raw['direct'] === true,
348+ favourite: raw['favourite'] === true,
349+ low_priority: raw['low_priority'] === true,
350+ avatar: optionalString(raw, 'avatar'),
351+ latest_event_timestamp_ms: optionalNumber(raw, 'latest_event_timestamp_ms'),
352+ latest_event: parseLatestEvent(raw['latest_event']),
353+ notification_mode: optionalString(raw, 'notification_mode'),
354+ notification_mode_custom: raw['notification_mode_custom'] === true,
355+ topic: optionalString(raw, 'topic'),
356+ canonical_alias: optionalString(raw, 'canonical_alias'),
357+ joined_members: numberField(raw, 'joined_members'),
358+ invited_members: numberField(raw, 'invited_members'),
359+ heroes: parseHeroes(raw['heroes']),
360+ successor: optionalString(raw, 'successor'),
361+ inviter: optionalString(raw, 'inviter'),
362+ parent_spaces: stringList(raw['parent_spaces']),
363+ children: stringList(raw['children'])
364+ };
365+}
366+
367+export function parseRoomList(raw: unknown): RoomSummary[] {
368+ if (!Array.isArray(raw)) return [];
369+ const out: RoomSummary[] = [];
370+ for (const entry of raw) {
371+ const room = parseRoomSummary(entry);
372+ if (room) out.push(room);
373+ }
374+ return out;
375+}
376+
377+export interface Reaction {
378+ key: string;
379+ count: number;
380+ own: boolean;
381+ senders: string[];
382+}
383+
384+export interface Sender {
385+ user_id: string;
386+ display_name: string | null;
387+ avatar: string | null;
388+ name_ambiguous: boolean;
389+}
390+
391+/** display_name if set, else the user id; callers add the id back on when name_ambiguous. */
392+export function senderLabel(sender: Sender): string {
393+ return sender.display_name ?? sender.user_id;
394+}
395+
396+function parseSender(raw: unknown): Sender {
397+ if (!isRecord(raw)) return { user_id: 'unknown', display_name: null, avatar: null, name_ambiguous: false };
398+ return {
399+ user_id: stringField(raw, 'user_id', 'unknown'),
400+ display_name: optionalString(raw, 'display_name'),
401+ avatar: optionalString(raw, 'avatar'),
402+ name_ambiguous: raw['name_ambiguous'] === true
403+ };
404+}
405+
406+export interface ThreadSummary {
407+ reply_count: number;
408+ latest_event_id: string | null;
409+ latest_event_timestamp_ms: number | null;
410+}
411+
412+function parseThreadSummary(raw: unknown): ThreadSummary | null {
413+ if (!isRecord(raw)) return null;
414+ return {
415+ reply_count: numberField(raw, 'reply_count'),
416+ latest_event_id: optionalString(raw, 'latest_event_id'),
417+ latest_event_timestamp_ms: optionalNumber(raw, 'latest_event_timestamp_ms')
418+ };
419+}
420+
421+export interface MessageBody {
422+ kind: MessageKind;
423+ body: string;
424+ geo_uri: string | null;
425+ formatted_body: string | null;
426+ edited: boolean;
427+ media: MediaAttachment | null;
428+}
429+
430+function parseMessageBody(raw: unknown): MessageBody {
431+ const o = isRecord(raw) ? raw : {};
432+ return {
433+ kind: parseMessageKind(o['kind']),
434+ body: stringField(o, 'body'),
435+ geo_uri: optionalString(o, 'geo_uri'),
436+ formatted_body: optionalString(o, 'formatted_body'),
437+ edited: o['edited'] === true,
438+ media: parseMediaAttachment(o['media'])
439+ };
440+}
441+
442+export interface Change<T> {
443+ old: T;
444+ new: T;
445+}
446+
447+function parseChange(raw: unknown): Change<string | null> | null {
448+ if (!isRecord(raw)) return null;
449+ return {
450+ old: typeof raw['old'] === 'string' ? raw['old'] : null,
451+ new: typeof raw['new'] === 'string' ? raw['new'] : null
452+ };
453+}
454+
455+export type TimelineContent =
456+ | { kind: 'Message'; message: MessageBody }
457+ | { kind: 'Sticker'; body: string; media: MediaAttachment | null }
458+ | { kind: 'Poll'; question: string }
459+ | { kind: 'Redacted' }
460+ | { kind: 'UnableToDecrypt'; cause: string }
461+ | { kind: 'Membership'; user_id: string; display_name: string | null; change: string }
462+ | { kind: 'Profile'; user_id: string; display_name: Change<string | null> | null; avatar: Change<string | null> | null }
463+ | { kind: 'RoomName'; name: string | null }
464+ | { kind: 'RoomTopic'; topic: string | null }
465+ | { kind: 'RoomAvatar'; avatar: string | null }
466+ | { kind: 'EncryptionEnabled' }
467+ | { kind: 'RoomCreated' }
468+ | { kind: 'RoomUpgraded'; successor: string }
469+ | { kind: 'OtherState'; event_type: string; state_key: string }
470+ | { kind: 'Other'; event_type: string };
471+
472+const REDACTED_CONTENT: TimelineContent = { kind: 'Redacted' };
473+const ENCRYPTION_ENABLED_CONTENT: TimelineContent = { kind: 'EncryptionEnabled' };
474+const ROOM_CREATED_CONTENT: TimelineContent = { kind: 'RoomCreated' };
475+
476+export function parseTimelineContent(raw: unknown): TimelineContent {
477+ if (raw === 'Redacted') return REDACTED_CONTENT;
478+ if (raw === 'EncryptionEnabled') return ENCRYPTION_ENABLED_CONTENT;
479+ if (raw === 'RoomCreated') return ROOM_CREATED_CONTENT;
480+ if (!isRecord(raw)) return REDACTED_CONTENT;
481+ if (isRecord(raw['Message'])) return { kind: 'Message', message: parseMessageBody(raw['Message']) };
482+ if (isRecord(raw['Sticker'])) {
483+ const inner = raw['Sticker'];
484+ return { kind: 'Sticker', body: stringField(inner, 'body'), media: parseMediaAttachment(inner['media']) };
485+ }
486+ if (isRecord(raw['Poll'])) {
487+ return { kind: 'Poll', question: stringField(raw['Poll'], 'question') };
488+ }
489+ if (typeof raw['UnableToDecrypt'] === 'string') {
490+ return { kind: 'UnableToDecrypt', cause: raw['UnableToDecrypt'] };
491+ }
492+ if (isRecord(raw['Membership'])) {
493+ const inner = raw['Membership'];
494+ return {
495+ kind: 'Membership',
496+ user_id: stringField(inner, 'user_id', 'unknown'),
497+ display_name: optionalString(inner, 'display_name'),
498+ change: stringField(inner, 'change', 'Other')
499+ };
500+ }
501+ if (isRecord(raw['Profile'])) {
502+ const inner = raw['Profile'];
503+ return {
504+ kind: 'Profile',
505+ user_id: stringField(inner, 'user_id', 'unknown'),
506+ display_name: parseChange(inner['display_name']),
507+ avatar: parseChange(inner['avatar'])
508+ };
509+ }
510+ if ('RoomName' in raw) {
511+ const name = raw['RoomName'];
512+ return { kind: 'RoomName', name: typeof name === 'string' ? name : null };
513+ }
514+ if ('RoomTopic' in raw) {
515+ const topic = raw['RoomTopic'];
516+ return { kind: 'RoomTopic', topic: typeof topic === 'string' ? topic : null };
517+ }
518+ if ('RoomAvatar' in raw) {
519+ const avatar = raw['RoomAvatar'];
520+ return { kind: 'RoomAvatar', avatar: typeof avatar === 'string' ? avatar : null };
521+ }
522+ if (isRecord(raw['RoomUpgraded'])) {
523+ return { kind: 'RoomUpgraded', successor: stringField(raw['RoomUpgraded'], 'successor') };
524+ }
525+ if (isRecord(raw['OtherState'])) {
526+ const inner = raw['OtherState'];
527+ return { kind: 'OtherState', event_type: stringField(inner, 'event_type'), state_key: stringField(inner, 'state_key') };
528+ }
529+ if (isRecord(raw['Other'])) {
530+ return { kind: 'Other', event_type: stringField(raw['Other'], 'event_type') };
531+ }
532+ return REDACTED_CONTENT;
533+}
534+
535+export type ReplyDetails =
536+ | { kind: 'Pending' }
537+ | { kind: 'Ready'; sender: Sender; content: TimelineContent; timestamp_ms: number }
538+ | { kind: 'Unavailable' };
539+
540+export interface ReplyReference {
541+ event_id: string;
542+ details: ReplyDetails;
543+}
544+
545+function parseReplyDetails(raw: unknown): ReplyDetails {
546+ if (raw === 'Pending') return { kind: 'Pending' };
547+ if (raw === 'Unavailable') return { kind: 'Unavailable' };
548+ if (isRecord(raw) && isRecord(raw['Ready'])) {
549+ const inner = raw['Ready'];
550+ return {
551+ kind: 'Ready',
552+ sender: parseSender(inner['sender']),
553+ content: parseTimelineContent(inner['content']),
554+ timestamp_ms: numberField(inner, 'timestamp_ms')
555+ };
556+ }
557+ return { kind: 'Unavailable' };
558+}
559+
560+function parseReplyReference(raw: unknown): ReplyReference | null {
561+ if (!isRecord(raw)) return null;
562+ const eventId = raw['event_id'];
563+ if (typeof eventId !== 'string') return null;
564+ return { event_id: eventId, details: parseReplyDetails(raw['details']) };
565+}
566+
567+export interface TimelineEvent {
568+ item_id: string;
569+ event_id: string | null;
570+ transaction_id: string | null;
571+ sender: Sender;
572+ timestamp_ms: number;
573+ own: boolean;
574+ send_state: SendState;
575+ content: TimelineContent;
576+ reply_to: ReplyReference | null;
577+ thread_root: string | null;
578+ thread_summary: ThreadSummary | null;
579+ reactions: Reaction[];
580+ read_by: string[];
581+ editable: boolean;
582+}
583+
584+function parseReactions(raw: unknown): Reaction[] {
585+ if (!Array.isArray(raw)) return [];
586+ const out: Reaction[] = [];
587+ for (const entry of raw) {
588+ if (!isRecord(entry)) continue;
589+ out.push({
590+ key: stringField(entry, 'key'),
591+ count: numberField(entry, 'count'),
592+ own: entry['own'] === true,
593+ senders: stringList(entry['senders'])
594+ });
595+ }
596+ return out;
597+}
598+
599+function parseTimelineEvent(raw: Record<string, unknown>): TimelineEvent {
600+ return {
601+ item_id: stringField(raw, 'item_id'),
602+ event_id: optionalString(raw, 'event_id'),
603+ transaction_id: optionalString(raw, 'transaction_id'),
604+ sender: parseSender(raw['sender']),
605+ timestamp_ms: numberField(raw, 'timestamp_ms'),
606+ own: raw['own'] === true,
607+ send_state: parseSendState(raw['send_state']),
608+ content: parseTimelineContent(raw['content']),
609+ reply_to: parseReplyReference(raw['reply_to']),
610+ thread_root: optionalString(raw, 'thread_root'),
611+ thread_summary: parseThreadSummary(raw['thread_summary']),
612+ reactions: parseReactions(raw['reactions']),
613+ read_by: stringList(raw['read_by']),
614+ editable: raw['editable'] === true
615+ };
616+}
617+
618+export type TimelineItem =
619+ | { kind: 'event'; event: TimelineEvent }
620+ | { kind: 'day'; timestamp_ms: number }
621+ | { kind: 'readMarker' }
622+ | { kind: 'start' }
623+ | { kind: 'unknown'; label: string };
624+
625+/** Normalize one TimelineItem from external-tag serde form. */
626+export function parseTimelineItem(raw: unknown): TimelineItem | null {
627+ if (raw === 'ReadMarker') return { kind: 'readMarker' };
628+ if (raw === 'TimelineStart') return { kind: 'start' };
629+ if (!isRecord(raw)) return null;
630+ if (isRecord(raw['Event'])) return { kind: 'event', event: parseTimelineEvent(raw['Event']) };
631+ if ('DayDivider' in raw) {
632+ const inner = raw['DayDivider'];
633+ const ts = isRecord(inner) && typeof inner['timestamp_ms'] === 'number' ? inner['timestamp_ms'] : 0;
634+ return { kind: 'day', timestamp_ms: ts };
635+ }
636+ if ('ReadMarker' in raw) return { kind: 'readMarker' };
637+ if ('TimelineStart' in raw) return { kind: 'start' };
638+ return { kind: 'unknown', label: Object.keys(raw).join(', ') || 'item' };
639+}
640+
641+export function parseTimelineList(raw: unknown): TimelineItem[] {
642+ if (!Array.isArray(raw)) return [];
643+ const out: TimelineItem[] = [];
644+ for (const entry of raw) {
645+ const item = parseTimelineItem(entry);
646+ if (item) out.push(item);
647+ }
648+ return out;
649+}
Added file: web/src/lib/typing.ts+41 additions
@@ -0,0 +1,41 @@
1+import { setTyping } from './api';
2+
3+const REPEAT_MS = 20000;
4+const IDLE_MS = 25000;
5+
6+let currentRoom: string | null = null;
7+let typingActive = false;
8+let lastSentAt = 0;
9+let idleTimer: number | null = null;
10+
11+function clearIdle(): void {
12+ if (idleTimer !== null) {
13+ window.clearTimeout(idleTimer);
14+ idleTimer = null;
15+ }
16+}
17+
18+function fire(roomId: string, typing: boolean): void {
19+ setTyping(roomId, typing).catch(() => undefined);
20+}
21+
22+export function notifyTyping(roomId: string): void {
23+ if (currentRoom && currentRoom !== roomId) stopTyping();
24+ currentRoom = roomId;
25+ const now = Date.now();
26+ if (!typingActive || now - lastSentAt >= REPEAT_MS) {
27+ typingActive = true;
28+ lastSentAt = now;
29+ fire(roomId, true);
30+ }
31+ clearIdle();
32+ idleTimer = window.setTimeout(() => stopTyping(), IDLE_MS);
33+}
34+
35+export function stopTyping(): void {
36+ clearIdle();
37+ const roomId = currentRoom;
38+ if (typingActive && roomId) fire(roomId, false);
39+ typingActive = false;
40+ lastSentAt = 0;
41+}
Added file: web/src/lib/urls.ts+85 additions
@@ -0,0 +1,85 @@
1+export interface UrlPreview {
2+ href: string;
3+ display: string;
4+ hostname: string;
5+ path: string;
6+}
7+
8+const MAX_LINKS = 4;
9+const MAX_URL_LENGTH = 2048;
10+const TRAILING_PUNCT = /[.,;:!?'"~…—–-]+$/u;
11+
12+function countChar(text: string, char: string): number {
13+ let count = 0;
14+ for (const c of text) if (c === char) count += 1;
15+ return count;
16+}
17+
18+function trimUrl(raw: string): string {
19+ let out = raw;
20+ let changed = true;
21+ while (changed && out.length > 0) {
22+ changed = false;
23+ const stripped = out.replace(TRAILING_PUNCT, '');
24+ if (stripped !== out) {
25+ out = stripped;
26+ changed = true;
27+ }
28+ // Strip a trailing closer only when it has no matching opener left in
29+ // the URL itself, so "(https://x/a_(b))" keeps the inner pair and drops
30+ // the outer markdown/paren closer.
31+ const pairs: Array<[string, string]> = [
32+ ['(', ')'],
33+ ['[', ']'],
34+ ['{', '}']
35+ ];
36+ for (const [open, close] of pairs) {
37+ if (out.endsWith(close) && countChar(out, close) > countChar(out, open)) {
38+ out = out.slice(0, -1);
39+ changed = true;
40+ }
41+ }
42+ if (out.endsWith('>')) {
43+ out = out.slice(0, -1);
44+ changed = true;
45+ }
46+ }
47+ return out;
48+}
49+
50+/**
51+ * Find http(s) URLs in message text without fetching anything: rendering is
52+ * purely local (hostname/path/link card) so no third-party request can leak
53+ * reader IPs. Callers MUST NOT auto-fetch link targets.
54+ */
55+export function findUrls(text: string): UrlPreview[] {
56+ if (!text || !text.includes('http')) return [];
57+ const out: UrlPreview[] = [];
58+ const seen = new Set<string>();
59+ const pattern = /https?:\/\/[^\s<>"'`\]]+/gi;
60+ let match: RegExpExecArray | null;
61+ while (out.length < MAX_LINKS && (match = pattern.exec(text)) !== null) {
62+ let candidate = match[0];
63+ if (candidate.length > MAX_URL_LENGTH) candidate = candidate.slice(0, MAX_URL_LENGTH);
64+ const trimmed = trimUrl(candidate);
65+ if (trimmed.length < 8) continue;
66+ let parsed: URL | null = null;
67+ try {
68+ parsed = new URL(trimmed);
69+ } catch {
70+ continue;
71+ }
72+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
73+ const href = parsed.toString();
74+ if (seen.has(href)) continue;
75+ seen.add(href);
76+ const rest = `${parsed.pathname}${parsed.search}${parsed.hash}`;
77+ out.push({
78+ href,
79+ display: trimmed.length > 72 ? `${trimmed.slice(0, 69)}…` : trimmed,
80+ hostname: parsed.hostname,
81+ path: rest && rest !== '/' ? (rest.length > 48 ? `${rest.slice(0, 45)}…` : rest) : ''
82+ });
83+ }
84+ return out;
85+}
Added file: web/src/lib/wire-status.ts+16 additions
@@ -0,0 +1,16 @@
1+export const STATUS_BY_KIND: Record<string, number> = {
2+ bad_request: 400,
3+ not_found: 404,
4+ conflict: 409,
5+ busy: 429,
6+ too_large: 413,
7+ unsupported: 415,
8+ engine_stopped: 502,
9+ upstream: 502,
10+ unauthorized: 401,
11+ store_in_use: 409
12+};
13+
14+export function statusForKind(kind: string): number {
15+ return STATUS_BY_KIND[kind] ?? 502;
16+}
Added file: web/src/main.ts+8 additions
@@ -0,0 +1,8 @@
1+import './app.css';
2+import App from './App.svelte';
3+
4+const target = document.getElementById('app');
5+if (!target) throw new Error('#app element missing');
6+const app = new App({ target });
7+
8+export default app;
Added file: web/src/sw.ts+474 additions
@@ -0,0 +1,474 @@
1+import { statusForKind } from './lib/wire-status';
2+import { decodeAttachmentIv, importAttachmentKey, RangeDecryptor } from './lib/media-decrypt';
3+import { attachmentContentType } from './lib/media-sniff';
4+
5+// Prepended by vite.config.ts's precacheManifestPlugin: every emitted chunk,
6+// asset, and the wasm, as root-relative URLs.
7+const sw = self as unknown as ServiceWorkerGlobalScope & { __MALICE_PRECACHE__: string[] };
8+
9+const CACHE_NAME = 'malice-shell-v1';
10+
11+sw.addEventListener('install', (event) => {
12+ event.waitUntil(
13+ (async () => {
14+ const cache = await caches.open(CACHE_NAME);
15+ await cache.addAll(['/', '/index.html', ...sw.__MALICE_PRECACHE__]);
16+ })()
17+ );
18+ void sw.skipWaiting();
19+});
20+
21+sw.addEventListener('activate', (event) => {
22+ event.waitUntil(
23+ (async () => {
24+ const keys = await caches.keys();
25+ await Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)));
26+ await sw.clients.claim();
27+ })()
28+ );
29+});
30+
31+interface MediaReply {
32+ bytes?: Uint8Array;
33+ content_type?: string | null;
34+ error?: { kind: string; message: string };
35+}
36+
37+interface AttachmentEncryptionWire {
38+ key: string;
39+ iv: string;
40+ sha256: string;
41+}
42+
43+interface MediaFileWire {
44+ url: string;
45+ mimetype: string | null;
46+ declared_size: number | null;
47+ encryption: AttachmentEncryptionWire | null;
48+}
49+
50+type MediaLocationWire = { File: MediaFileWire } | { ServerThumbnail: { url: string; fallback: MediaFileWire } } | 'Local';
51+
52+interface LocateReply {
53+ location?: MediaLocationWire;
54+ token?: string | null;
55+ error?: { kind: string; message: string };
56+}
57+
58+const MEDIA_TIMEOUT_MS = 30000;
59+const mediaChannel = new BroadcastChannel('malice-media');
60+const pendingMedia = new Map<string, (reply: MediaReply & LocateReply) => void>();
61+let nextMediaRequestId = 0;
62+
63+mediaChannel.onmessage = (ev: MessageEvent) => {
64+ const msg = ev.data as MediaReply & LocateReply & { id: string };
65+ const resolve = pendingMedia.get(msg.id);
66+ if (!resolve) return;
67+ pendingMedia.delete(msg.id);
68+ resolve(msg);
69+};
70+
71+function requestMedia(kind: 'avatar' | 'media', fields: Record<string, string>): Promise<MediaReply | null> {
72+ const id = `${Date.now()}-${nextMediaRequestId++}`;
73+ return new Promise((resolve) => {
74+ const timer = setTimeout(() => {
75+ pendingMedia.delete(id);
76+ resolve(null);
77+ }, MEDIA_TIMEOUT_MS);
78+ pendingMedia.set(id, (reply) => {
79+ clearTimeout(timer);
80+ resolve(reply);
81+ });
82+ mediaChannel.postMessage({ id, kind, ...fields });
83+ });
84+}
85+
86+interface LocateEntry {
87+ location: MediaLocationWire;
88+ token: string | null;
89+ expiresAt: number;
90+}
91+
92+const LOCATE_TTL_MS = 5 * 60 * 1000;
93+const locateCache = new Map<string, LocateEntry>();
94+const sniffCache = new Map<string, { contentType: string | null; expiresAt: number }>();
95+
96+function requestLocate(mediaId: string, size: string): Promise<{ location: MediaLocationWire; token: string | null } | null> {
97+ const id = `${Date.now()}-${nextMediaRequestId++}`;
98+ return new Promise((resolve) => {
99+ const timer = setTimeout(() => {
100+ pendingMedia.delete(id);
101+ resolve(null);
102+ }, MEDIA_TIMEOUT_MS);
103+ pendingMedia.set(id, (reply) => {
104+ clearTimeout(timer);
105+ resolve(reply.error || !reply.location ? null : { location: reply.location, token: reply.token ?? null });
106+ });
107+ mediaChannel.postMessage({ id, kind: 'locate', media_id: mediaId, size });
108+ });
109+}
110+
111+/** In-memory only: the access token never touches Cache Storage or IndexedDB, and expires with the entry. */
112+async function locate(mediaId: string, size: string, force: boolean): Promise<LocateEntry | null> {
113+ if (!force) {
114+ const cached = locateCache.get(mediaId);
115+ if (cached && cached.expiresAt > Date.now()) return cached;
116+ }
117+ const reply = await requestLocate(mediaId, size);
118+ if (!reply) return null;
119+ const entry: LocateEntry = { ...reply, expiresAt: Date.now() + LOCATE_TTL_MS };
120+ locateCache.set(mediaId, entry);
121+ return entry;
122+}
123+
124+interface RangeSlice {
125+ body: Uint8Array;
126+ status: number;
127+ contentRange?: string;
128+}
129+
130+/** The engine hands back the whole attachment at once, so seeking in <video>/<audio> needs this SW to slice it. */
131+function sliceForRange(bytes: Uint8Array, rangeHeader: string | null): RangeSlice {
132+ const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;
133+ if (!match) return { body: bytes, status: 200 };
134+ const total = bytes.byteLength;
135+ let start = match[1] ? parseInt(match[1], 10) : undefined;
136+ let end = match[2] ? parseInt(match[2], 10) : undefined;
137+ if (start === undefined && end !== undefined) {
138+ start = Math.max(total - end, 0);
139+ end = total - 1;
140+ } else if (end === undefined) {
141+ end = total - 1;
142+ }
143+ if (start === undefined || start > end || start >= total) return { body: bytes, status: 200 };
144+ end = Math.min(end, total - 1);
145+ return { body: bytes.subarray(start, end + 1), status: 206, contentRange: `bytes ${start}-${end}/${total}` };
146+}
147+
148+function mediaResponse(reply: MediaReply | null, rangeHeader: string | null, attachment: boolean): Response {
149+ if (!reply) return new Response(null, { status: 504 });
150+ if (reply.error) return new Response(reply.error.message, { status: statusForKind(reply.error.kind) });
151+ const bytes = reply.bytes ?? new Uint8Array();
152+ const { body, status, contentRange } = sliceForRange(bytes, rangeHeader);
153+ const headers = new Headers({
154+ 'Content-Type': reply.content_type ?? 'application/octet-stream',
155+ 'Cache-Control': 'private, max-age=3600',
156+ 'X-Content-Type-Options': 'nosniff'
157+ });
158+ if (attachment) {
159+ headers.set('Content-Disposition', reply.content_type ? 'inline' : 'attachment');
160+ headers.set('Content-Security-Policy', 'sandbox');
161+ }
162+ if (contentRange) {
163+ headers.set('Content-Range', contentRange);
164+ headers.set('Accept-Ranges', 'bytes');
165+ }
166+ return new Response(body, { status, headers });
167+}
168+
169+interface ByteRange {
170+ start: number;
171+ end: number;
172+}
173+
174+/** Unlike sliceForRange, the total comes from the server's declared_size, not a buffer already in hand. */
175+function parseByteRange(rangeHeader: string | null, total: number): ByteRange | null {
176+ const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;
177+ if (!match) return null;
178+ let start = match[1] ? parseInt(match[1], 10) : undefined;
179+ let end = match[2] ? parseInt(match[2], 10) : undefined;
180+ if (start === undefined && end !== undefined) {
181+ start = Math.max(total - end, 0);
182+ end = total - 1;
183+ } else if (end === undefined) {
184+ start = start ?? 0;
185+ end = total - 1;
186+ }
187+ if (start === undefined || start > end || start >= total) return null;
188+ return { start, end: Math.min(end, total - 1) };
189+}
190+
191+function attachmentHeaders(contentType: string | null): Headers {
192+ return new Headers({
193+ 'Content-Type': contentType ?? 'application/octet-stream',
194+ 'Cache-Control': 'private, max-age=3600',
195+ 'X-Content-Type-Options': 'nosniff',
196+ 'Content-Disposition': contentType ? 'inline' : 'attachment',
197+ 'Content-Security-Policy': 'sandbox'
198+ });
199+}
200+
201+/**
202+ * The Authorization header carries the account's access token, so it's only worth the risk on the
203+ * exact authenticated media endpoints, and only when the URL being fetched is the one `locate` named
204+ * (never a redirect target, should a future caller pass one through).
205+ */
206+function shouldAuthorize(fetchUrl: string, locatedUrl: string): boolean {
207+ try {
208+ const target = new URL(fetchUrl);
209+ const located = new URL(locatedUrl);
210+ if (target.origin !== located.origin) return false;
211+ return target.pathname.startsWith('/_matrix/client/v1/media/') || target.pathname.startsWith('/_matrix/media/');
212+ } catch {
213+ return false;
214+ }
215+}
216+
217+function concatChunks(parts: Uint8Array[]): Uint8Array {
218+ const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
219+ const out = new Uint8Array(total);
220+ let offset = 0;
221+ for (const part of parts) {
222+ out.set(part, offset);
223+ offset += part.byteLength;
224+ }
225+ return out;
226+}
227+
228+async function readUpTo(reader: ReadableStreamDefaultReader<Uint8Array>, limit: number): Promise<Uint8Array> {
229+ const chunks: Uint8Array[] = [];
230+ let total = 0;
231+ while (total < limit) {
232+ const { value, done } = await reader.read();
233+ if (done) break;
234+ chunks.push(value);
235+ total += value.byteLength;
236+ }
237+ reader.cancel().catch(() => undefined);
238+ return concatChunks(chunks).subarray(0, Math.min(limit, total));
239+}
240+
241+/**
242+ * Ciphertext in, plaintext out: skips the bytes the upstream sent but the caller didn't ask for
243+ * (only nonzero when the upstream ignored the Range request), decrypts, drops the sub-16-byte
244+ * alignment prefix from the very first output, and stops once `limit` bytes have been produced.
245+ */
246+async function* decryptedChunks(
247+ reader: ReadableStreamDefaultReader<Uint8Array>,
248+ decryptor: RangeDecryptor | null,
249+ skipBytes: number,
250+ dropPrefix: number,
251+ limit: number
252+): AsyncGenerator<Uint8Array> {
253+ let toSkip = skipBytes;
254+ let toDrop = dropPrefix;
255+ let remaining = limit;
256+ while (remaining > 0) {
257+ const { value, done } = await reader.read();
258+ if (done) break;
259+ let chunk = value;
260+ if (toSkip > 0) {
261+ if (chunk.byteLength <= toSkip) {
262+ toSkip -= chunk.byteLength;
263+ continue;
264+ }
265+ chunk = chunk.subarray(toSkip);
266+ toSkip = 0;
267+ }
268+ const plain = decryptor ? await decryptor.push(chunk) : chunk;
269+ if (plain.byteLength === 0) continue;
270+ const emitted = emit(plain);
271+ if (emitted) yield emitted;
272+ }
273+ if (remaining > 0 && decryptor) {
274+ const emitted = emit(await decryptor.finish());
275+ if (emitted) yield emitted;
276+ }
277+ reader.cancel().catch(() => undefined);
278+
279+ function emit(chunk: Uint8Array): Uint8Array | null {
280+ let out = chunk;
281+ if (toDrop > 0) {
282+ if (out.byteLength <= toDrop) {
283+ toDrop -= out.byteLength;
284+ return null;
285+ }
286+ out = out.subarray(toDrop);
287+ toDrop = 0;
288+ }
289+ if (out.byteLength > remaining) out = out.subarray(0, remaining);
290+ remaining -= out.byteLength;
291+ return out.byteLength > 0 ? out : null;
292+ }
293+}
294+
295+async function headSniff(mediaId: string, file: MediaFileWire, token: string | null, retried: boolean): Promise<string | null> {
296+ const cached = sniffCache.get(mediaId);
297+ if (cached && cached.expiresAt > Date.now()) return cached.contentType;
298+ const headers = new Headers({ Range: 'bytes=0-79' });
299+ if (shouldAuthorize(file.url, file.url) && token) headers.set('Authorization', `Bearer ${token}`);
300+ let upstream: Response;
301+ try {
302+ upstream = await fetch(file.url, { headers });
303+ } catch {
304+ return null;
305+ }
306+ if (upstream.status === 401 && !retried) {
307+ const fresh = await locate(mediaId, 'Full', true);
308+ if (fresh && typeof fresh.location === 'object' && 'File' in fresh.location) {
309+ return headSniff(mediaId, fresh.location.File, fresh.token, true);
310+ }
311+ }
312+ if (!upstream.body || (!upstream.ok && upstream.status !== 206)) return null;
313+ const head = await readUpTo(upstream.body.getReader(), 80);
314+ let contentType: string | null;
315+ if (file.encryption) {
316+ const key = await importAttachmentKey(file.encryption.key);
317+ const iv = decodeAttachmentIv(file.encryption.iv);
318+ const decryptor = new RangeDecryptor(key, iv, 0);
319+ contentType = attachmentContentType(concatChunks([await decryptor.push(head), await decryptor.finish()]).subarray(0, 64));
320+ } else {
321+ contentType = attachmentContentType(head.subarray(0, 64));
322+ }
323+ sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });
324+ return contentType;
325+}
326+
327+function wholeFileFallback(mediaId: string, rangeHeader: string | null): Promise<Response> {
328+ return requestMedia('media', { media_id: mediaId, size: 'Full' }).then((reply) => mediaResponse(reply, rangeHeader, true));
329+}
330+
331+async function streamFullMedia(mediaId: string, rangeHeader: string | null, entry?: LocateEntry, retried = false): Promise<Response> {
332+ const located = entry ?? (await locate(mediaId, 'Full', false));
333+ if (!located) return wholeFileFallback(mediaId, rangeHeader);
334+ if (typeof located.location !== 'object' || !('File' in located.location)) return wholeFileFallback(mediaId, rangeHeader);
335+ const file = located.location.File;
336+ const total = file.declared_size;
337+ if (total == null) return wholeFileFallback(mediaId, rangeHeader);
338+
339+ const requested = parseByteRange(rangeHeader, total);
340+ const start = requested?.start ?? 0;
341+ const end = requested?.end ?? total - 1;
342+ const alignedStart = start - (start % 16);
343+
344+ const headers = new Headers({ Range: `bytes=${alignedStart}-${end}` });
345+ if (shouldAuthorize(file.url, file.url) && located.token) headers.set('Authorization', `Bearer ${located.token}`);
346+
347+ let upstream: Response;
348+ try {
349+ upstream = await fetch(file.url, { headers });
350+ } catch {
351+ return new Response('media upstream unreachable', { status: 502 });
352+ }
353+ if (upstream.status === 401 && !retried) {
354+ const fresh = await locate(mediaId, 'Full', true);
355+ if (fresh) return streamFullMedia(mediaId, rangeHeader, fresh, true);
356+ }
357+ if (!upstream.body || (!upstream.ok && upstream.status !== 206)) {
358+ return new Response('media upstream error', { status: 502 });
359+ }
360+
361+ const decryptor = file.encryption
362+ ? new RangeDecryptor(await importAttachmentKey(file.encryption.key), decodeAttachmentIv(file.encryption.iv), alignedStart / 16)
363+ : null;
364+ const skipBytes = upstream.status === 206 ? 0 : alignedStart;
365+ const dropPrefix = start - alignedStart;
366+ const limit = end - start + 1;
367+
368+ const reader = upstream.body.getReader();
369+ const chunks = decryptedChunks(reader, decryptor, skipBytes, dropPrefix, limit);
370+
371+ const prefetched: Uint8Array[] = [];
372+ let prefetchedLen = 0;
373+ let chunksDone = false;
374+ let contentType: string | null;
375+
376+ if (alignedStart === 0) {
377+ while (prefetchedLen < 64) {
378+ const step = await chunks.next();
379+ if (step.done) {
380+ chunksDone = true;
381+ break;
382+ }
383+ prefetched.push(step.value);
384+ prefetchedLen += step.value.byteLength;
385+ }
386+ contentType = attachmentContentType(concatChunks(prefetched).subarray(0, Math.min(64, prefetchedLen)));
387+ sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });
388+ } else {
389+ contentType = await headSniff(mediaId, file, located.token, retried);
390+ }
391+
392+ const body = new ReadableStream<Uint8Array>({
393+ async pull(controller) {
394+ const next = prefetched.shift();
395+ if (next) {
396+ controller.enqueue(next);
397+ return;
398+ }
399+ if (chunksDone) {
400+ controller.close();
401+ return;
402+ }
403+ const step = await chunks.next();
404+ if (step.done) controller.close();
405+ else controller.enqueue(step.value);
406+ },
407+ cancel() {
408+ reader.cancel().catch(() => undefined);
409+ }
410+ });
411+
412+ const responseHeaders = attachmentHeaders(contentType);
413+ responseHeaders.set('Accept-Ranges', 'bytes');
414+ if (requested) {
415+ responseHeaders.set('Content-Range', `bytes ${start}-${end}/${total}`);
416+ return new Response(body, { status: 206, headers: responseHeaders });
417+ }
418+ return new Response(body, { status: 200, headers: responseHeaders });
419+}
420+
421+async function cacheFirst(request: Request): Promise<Response> {
422+ const cached = await caches.match(request);
423+ if (cached) return cached;
424+ const response = await fetch(request);
425+ if (response.ok) {
426+ const cache = await caches.open(CACHE_NAME);
427+ void cache.put(request, response.clone());
428+ }
429+ return response;
430+}
431+
432+async function networkFirstNavigation(request: Request): Promise<Response> {
433+ try {
434+ return await fetch(request);
435+ } catch {
436+ const cached = await caches.match('/index.html');
437+ if (cached) return cached;
438+ throw new Error('offline and no cached shell');
439+ }
440+}
441+
442+sw.addEventListener('fetch', (event) => {
443+ const url = new URL(event.request.url);
444+ const range = event.request.headers.get('Range');
445+
446+ if (url.pathname === '/api/media') {
447+ const mxc = url.searchParams.get('mxc');
448+ if (mxc) {
449+ event.respondWith(requestMedia('avatar', { mxc }).then((reply) => mediaResponse(reply, range, false)));
450+ return;
451+ }
452+ }
453+ if (url.pathname === '/api/message-media') {
454+ const mediaId = url.searchParams.get('media_id');
455+ const size = url.searchParams.get('size');
456+ if (mediaId && size === 'Full') {
457+ event.respondWith(streamFullMedia(mediaId, range));
458+ return;
459+ }
460+ if (mediaId && size) {
461+ event.respondWith(
462+ requestMedia('media', { media_id: mediaId, size }).then((reply) => mediaResponse(reply, range, true))
463+ );
464+ return;
465+ }
466+ }
467+ if (url.pathname.startsWith('/assets/')) {
468+ event.respondWith(cacheFirst(event.request));
469+ return;
470+ }
471+ if (event.request.mode === 'navigate') {
472+ event.respondWith(networkFirstNavigation(event.request));
473+ }
474+});
Added file: web/src/vite-env.d.ts+1 additions
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
Added file: web/src/worker/accounts.ts+131 additions
@@ -0,0 +1,131 @@
1+// IndexedDB account registry. Store passphrases are 32 random bytes, kept
2+// only encrypted with a non-extractable AES-GCM key that never leaves this
3+// database, so a compromised script here still can't read a persisted
4+// passphrase back out in the clear.
5+const DB_NAME = 'malice-accounts';
6+const DB_VERSION = 1;
7+const ACCOUNTS_STORE = 'accounts';
8+const KEYS_STORE = 'keys';
9+const WRAP_KEY_ID = 'wrap-key';
10+
11+interface AccountRow {
12+ key: string;
13+ user_id: string | null;
14+ homeserver: string | null;
15+ wrapped_passphrase: ArrayBuffer;
16+ iv: ArrayBuffer;
17+}
18+
19+export interface AccountInfo {
20+ key: string;
21+ user_id: string | null;
22+ homeserver: string | null;
23+}
24+
25+export interface NewAccount {
26+ key: string;
27+ passphrase: string;
28+}
29+
30+function openDb(): Promise<IDBDatabase> {
31+ return new Promise((resolve, reject) => {
32+ const req = indexedDB.open(DB_NAME, DB_VERSION);
33+ req.onupgradeneeded = () => {
34+ const db = req.result;
35+ if (!db.objectStoreNames.contains(ACCOUNTS_STORE)) db.createObjectStore(ACCOUNTS_STORE, { keyPath: 'key' });
36+ if (!db.objectStoreNames.contains(KEYS_STORE)) db.createObjectStore(KEYS_STORE);
37+ };
38+ req.onsuccess = () => resolve(req.result);
39+ req.onerror = () => reject(req.error);
40+ });
41+}
42+
43+function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
44+ return new Promise((resolve, reject) => {
45+ request.onsuccess = () => resolve(request.result);
46+ request.onerror = () => reject(request.error);
47+ });
48+}
49+
50+function runTx<T>(
51+ db: IDBDatabase,
52+ stores: string[],
53+ mode: IDBTransactionMode,
54+ fn: (tx: IDBTransaction) => Promise<T> | T
55+): Promise<T> {
56+ return new Promise((resolve, reject) => {
57+ const tx = db.transaction(stores, mode);
58+ let result: T;
59+ Promise.resolve(fn(tx))
60+ .then((value) => {
61+ result = value;
62+ })
63+ .catch(reject);
64+ tx.oncomplete = () => resolve(result);
65+ tx.onerror = () => reject(tx.error);
66+ tx.onabort = () => reject(tx.error ?? new Error('account registry transaction aborted'));
67+ });
68+}
69+
70+async function getWrapKey(db: IDBDatabase): Promise<CryptoKey> {
71+ const existing = await runTx(db, [KEYS_STORE], 'readonly', (tx) =>
72+ requestToPromise(tx.objectStore(KEYS_STORE).get(WRAP_KEY_ID))
73+ );
74+ if (existing) return existing as CryptoKey;
75+ const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
76+ await runTx(db, [KEYS_STORE], 'readwrite', (tx) => requestToPromise(tx.objectStore(KEYS_STORE).put(key, WRAP_KEY_ID)));
77+ return key;
78+}
79+
80+function randomHex(byteLength: number): string {
81+ const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
82+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
83+}
84+
85+export async function listAccounts(): Promise<AccountInfo[]> {
86+ const db = await openDb();
87+ const rows = (await runTx(db, [ACCOUNTS_STORE], 'readonly', (tx) =>
88+ requestToPromise(tx.objectStore(ACCOUNTS_STORE).getAll())
89+ )) as AccountRow[];
90+ return rows.map((row) => ({ key: row.key, user_id: row.user_id, homeserver: row.homeserver }));
91+}
92+
93+export async function createAccount(): Promise<NewAccount> {
94+ const db = await openDb();
95+ const wrapKey = await getWrapKey(db);
96+ const key = randomHex(16);
97+ const passphrase = randomHex(32);
98+ const iv = crypto.getRandomValues(new Uint8Array(12));
99+ const wrapped = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapKey, new TextEncoder().encode(passphrase));
100+ const row: AccountRow = { key, user_id: null, homeserver: null, wrapped_passphrase: wrapped, iv: iv.buffer };
101+ await runTx(db, [ACCOUNTS_STORE], 'readwrite', (tx) => requestToPromise(tx.objectStore(ACCOUNTS_STORE).put(row)));
102+ return { key, passphrase };
103+}
104+
105+export async function getPassphrase(accountKey: string): Promise<string> {
106+ const db = await openDb();
107+ const wrapKey = await getWrapKey(db);
108+ const row = (await runTx(db, [ACCOUNTS_STORE], 'readonly', (tx) =>
109+ requestToPromise(tx.objectStore(ACCOUNTS_STORE).get(accountKey))
110+ )) as AccountRow | undefined;
111+ if (!row) throw new Error(`unknown account ${accountKey}`);
112+ const bytes = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: row.iv }, wrapKey, row.wrapped_passphrase);
113+ return new TextDecoder().decode(bytes);
114+}
115+
116+export async function setIdentity(accountKey: string, userId: string, homeserver: string): Promise<void> {
117+ const db = await openDb();
118+ await runTx(db, [ACCOUNTS_STORE], 'readwrite', async (tx) => {
119+ const store = tx.objectStore(ACCOUNTS_STORE);
120+ const row = (await requestToPromise(store.get(accountKey))) as AccountRow | undefined;
121+ if (!row) return;
122+ row.user_id = userId;
123+ row.homeserver = homeserver;
124+ store.put(row);
125+ });
126+}
127+
128+export async function deleteAccount(accountKey: string): Promise<void> {
129+ const db = await openDb();
130+ await runTx(db, [ACCOUNTS_STORE], 'readwrite', (tx) => requestToPromise(tx.objectStore(ACCOUNTS_STORE).delete(accountKey)));
131+}
Added file: web/src/worker/worker.ts+314 additions
@@ -0,0 +1,314 @@
1+import init, { Host } from './pkg/malice_worker.js';
2+import { isRecord } from '../lib/guards';
3+import {
4+ createAccount,
5+ deleteAccount,
6+ getPassphrase,
7+ listAccounts,
8+ setIdentity,
9+ type AccountInfo
10+} from './accounts';
11+
12+interface PortLike {
13+ postMessage(message: unknown, transfer?: Transferable[]): void;
14+}
15+
16+interface WireError {
17+ kind: string;
18+ message: string;
19+}
20+
21+// Host.call()'s wasm-bindgen signature is untyped (Promise<any>); this is the
22+// JSON shape the malice-worker contract actually returns.
23+interface CallOk {
24+ json?: unknown;
25+ bytes?: Uint8Array;
26+ content_type?: string | null;
27+}
28+
29+interface Identity {
30+ user_id: string;
31+ homeserver: string;
32+}
33+
34+const ports = new Set<PortLike>();
35+const subscriptions = new Map<string, Set<PortLike>>();
36+const liveAccounts = new Set<string>();
37+const softAccounts = new Map<string, Identity>();
38+const blockedAccounts = new Set<string>();
39+const accountIdentity = new Map<string, Identity>();
40+
41+let host: Host;
42+
43+function postTo(port: PortLike, message: Record<string, unknown>): void {
44+ port.postMessage(message);
45+}
46+
47+function broadcastEvent(account: string, event: string): void {
48+ for (const port of subscriptions.get(account) ?? []) postTo(port, { kind: 'event', account, event });
49+}
50+
51+function toWireError(error: unknown): WireError {
52+ if (isRecord(error) && typeof error['kind'] === 'string') {
53+ return { kind: error['kind'], message: typeof error['message'] === 'string' ? error['message'] : 'unknown error' };
54+ }
55+ return { kind: 'upstream', message: error instanceof Error ? error.message : 'unknown error' };
56+}
57+
58+function usernameMatches(submitted: string, userId: string): boolean {
59+ const trimmed = submitted.trim();
60+ if (trimmed === userId) return true;
61+ const localpart = userId.startsWith('@') ? userId.slice(1).split(':')[0] : userId;
62+ return trimmed === localpart;
63+}
64+
65+function currentAccountKey(): string | null {
66+ for (const key of liveAccounts) return key;
67+ return null;
68+}
69+
70+function onSessionEnded(account: string, retain: unknown): void {
71+ liveAccounts.delete(account);
72+ const identity = accountIdentity.get(account);
73+ if (retain === 'nothing') {
74+ accountIdentity.delete(account);
75+ void deleteAccount(account).catch(() => undefined);
76+ } else if (identity) {
77+ softAccounts.set(account, identity);
78+ }
79+ broadcastEvent(account, JSON.stringify({ type: 'session_ended', retain }));
80+}
81+
82+function onHostEvent(account: string, eventJson: string): void {
83+ let parsed: unknown;
84+ try {
85+ parsed = JSON.parse(eventJson);
86+ } catch {
87+ return;
88+ }
89+ if (isRecord(parsed) && parsed['type'] === 'session_ended') {
90+ onSessionEnded(account, parsed['retain']);
91+ return;
92+ }
93+ broadcastEvent(account, eventJson);
94+}
95+
96+function onHostFatal(message: string): void {
97+ for (const port of ports) postTo(port, { kind: 'fatal', message });
98+ close();
99+}
100+
101+async function restoreAccount(account: AccountInfo): Promise<void> {
102+ try {
103+ const passphrase = await getPassphrase(account.key);
104+ const result = await host.restore(account.key, passphrase);
105+ if (result.status === 'resumed' && result.user_id) {
106+ accountIdentity.set(account.key, { user_id: result.user_id, homeserver: account.homeserver ?? '' });
107+ liveAccounts.add(account.key);
108+ } else if (result.status === 'soft_logged_out' && result.user_id && result.homeserver) {
109+ softAccounts.set(account.key, { user_id: result.user_id, homeserver: result.homeserver });
110+ }
111+ } catch (error) {
112+ const wire = toWireError(error);
113+ if (wire.kind === 'store_in_use') blockedAccounts.add(account.key);
114+ else await deleteAccount(account.key).catch(() => undefined);
115+ }
116+}
117+
118+const ready = (async () => {
119+ await init();
120+ host = new Host(onHostEvent, onHostFatal);
121+ for (const account of await listAccounts()) await restoreAccount(account);
122+})();
123+
124+interface SessionReply {
125+ user_id: string | null;
126+ soft_logout?: Identity;
127+ account?: string;
128+ blocked?: true;
129+}
130+
131+function handleSession(): SessionReply {
132+ const liveKey = currentAccountKey();
133+ if (liveKey) {
134+ const identity = accountIdentity.get(liveKey);
135+ return { user_id: identity?.user_id ?? null, account: liveKey };
136+ }
137+ if (blockedAccounts.size > 0) return { user_id: null, blocked: true };
138+ const soft = softAccounts.values().next();
139+ if (!soft.done) return { user_id: null, soft_logout: soft.value };
140+ return { user_id: null };
141+}
142+
143+async function handleLogin(
144+ homeserver: string,
145+ username: string,
146+ password: string
147+): Promise<{ user_id: string; account: string }> {
148+ let targetKey: string | null = null;
149+ for (const [key, identity] of softAccounts) {
150+ if (usernameMatches(username, identity.user_id)) {
151+ targetKey = key;
152+ break;
153+ }
154+ }
155+ const reused = targetKey !== null;
156+ let passphrase: string;
157+ if (targetKey) {
158+ passphrase = await getPassphrase(targetKey);
159+ } else {
160+ const created = await createAccount();
161+ targetKey = created.key;
162+ passphrase = created.passphrase;
163+ }
164+ try {
165+ const result = await host.login(targetKey, passphrase, homeserver, username, password);
166+ await setIdentity(targetKey, result.user_id, homeserver);
167+ accountIdentity.set(targetKey, { user_id: result.user_id, homeserver });
168+ softAccounts.delete(targetKey);
169+ liveAccounts.add(targetKey);
170+ return { user_id: result.user_id, account: targetKey };
171+ } catch (error) {
172+ if (!reused) await deleteAccount(targetKey).catch(() => undefined);
173+ throw error;
174+ }
175+}
176+
177+async function handleLogout(account: string): Promise<void> {
178+ await host.logout(account);
179+}
180+
181+async function handleCall(account: string, request: unknown, bytes?: ArrayBuffer): Promise<CallOk> {
182+ return host.call(account, JSON.stringify(request), bytes ? new Uint8Array(bytes) : undefined);
183+}
184+
185+function handleSubscribe(port: PortLike, account: string): void {
186+ let set = subscriptions.get(account);
187+ if (!set) {
188+ set = new Set();
189+ subscriptions.set(account, set);
190+ }
191+ set.add(port);
192+ if (liveAccounts.has(account)) {
193+ const snapshot = host.snapshot(account);
194+ if (snapshot) postTo(port, { kind: 'event', account, event: snapshot });
195+ }
196+}
197+
198+function handleUnsubscribe(port: PortLike, account: string): void {
199+ subscriptions.get(account)?.delete(port);
200+}
201+
202+function dropPort(port: PortLike): void {
203+ ports.delete(port);
204+ for (const set of subscriptions.values()) set.delete(port);
205+}
206+
207+async function dispatch(port: PortLike, data: unknown): Promise<void> {
208+ await ready;
209+ if (!isRecord(data)) return;
210+ const kind = data['kind'];
211+ const id = data['id'];
212+ const reply = (ok: unknown) => {
213+ if (typeof id === 'number') postTo(port, { kind: 'reply', id, ok });
214+ };
215+ const fail = (error: unknown) => {
216+ if (typeof id === 'number') postTo(port, { kind: 'reply', id, error: toWireError(error) });
217+ };
218+
219+ switch (kind) {
220+ case 'session':
221+ reply(handleSession());
222+ return;
223+ case 'login': {
224+ const { homeserver, username, password } = data as { homeserver: string; username: string; password: string };
225+ try {
226+ reply(await handleLogin(homeserver, username, password));
227+ } catch (error) {
228+ fail(error);
229+ }
230+ return;
231+ }
232+ case 'logout': {
233+ const { account } = data as { account: string };
234+ try {
235+ await handleLogout(account);
236+ reply({});
237+ } catch (error) {
238+ fail(error);
239+ }
240+ return;
241+ }
242+ case 'call': {
243+ const { account, request, bytes } = data as { account: string; request: unknown; bytes?: ArrayBuffer };
244+ try {
245+ reply(await handleCall(account, request, bytes));
246+ } catch (error) {
247+ fail(error);
248+ }
249+ return;
250+ }
251+ case 'subscribe':
252+ handleSubscribe(port, (data as { account: string }).account);
253+ return;
254+ case 'unsubscribe':
255+ handleUnsubscribe(port, (data as { account: string }).account);
256+ return;
257+ case 'bye':
258+ dropPort(port);
259+ return;
260+ }
261+}
262+
263+type WorkerScope = DedicatedWorkerGlobalScope & Partial<SharedWorkerGlobalScope>;
264+const scope = self as unknown as WorkerScope;
265+
266+if (typeof scope.onconnect !== 'undefined') {
267+ scope.onconnect = (ev: MessageEvent) => {
268+ const port = ev.ports[0];
269+ ports.add(port);
270+ port.onmessage = (msg: MessageEvent) => void dispatch(port, msg.data);
271+ port.start();
272+ };
273+} else {
274+ ports.add(scope);
275+ scope.onmessage = (msg: MessageEvent) => void dispatch(scope, msg.data);
276+}
277+
278+const mediaChannel = new BroadcastChannel('malice-media');
279+mediaChannel.onmessage = (ev: MessageEvent) => void handleMediaRequest(ev.data);
280+
281+async function handleMediaRequest(msg: unknown): Promise<void> {
282+ await ready;
283+ if (!isRecord(msg) || typeof msg['id'] !== 'string') return;
284+ const id = msg['id'];
285+ const account = currentAccountKey();
286+ if (!account) {
287+ mediaChannel.postMessage({ id, error: { kind: 'not_found', message: 'no account signed in' } });
288+ return;
289+ }
290+ if (msg['kind'] === 'locate' && typeof msg['media_id'] === 'string' && typeof msg['size'] === 'string') {
291+ try {
292+ const result = await host.locate(account, msg['media_id'], msg['size']);
293+ mediaChannel.postMessage({ id, location: result.location, token: result.token ?? null });
294+ } catch (error) {
295+ mediaChannel.postMessage({ id, error: toWireError(error) });
296+ }
297+ return;
298+ }
299+ let request: Record<string, unknown>;
300+ if (msg['kind'] === 'avatar' && typeof msg['mxc'] === 'string') {
301+ request = { op: 'avatar', mxc: msg['mxc'] };
302+ } else if (msg['kind'] === 'media' && typeof msg['media_id'] === 'string' && typeof msg['size'] === 'string') {
303+ request = { op: 'media', media_id: msg['media_id'], size: msg['size'] };
304+ } else {
305+ mediaChannel.postMessage({ id, error: { kind: 'bad_request', message: 'invalid media request' } });
306+ return;
307+ }
308+ try {
309+ const result = await host.call(account, JSON.stringify(request));
310+ mediaChannel.postMessage({ id, bytes: result.bytes, content_type: result.content_type ?? null });
311+ } catch (error) {
312+ mediaChannel.postMessage({ id, error: toWireError(error) });
313+ }
314+}
Added file: web/test/media.test.ts+110 additions
@@ -0,0 +1,110 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { addBlockOffset, importAttachmentKey, RangeDecryptor } from '../src/lib/media-decrypt.js';
4+import { attachmentContentType, imageContentType } from '../src/lib/media-sniff.js';
5+
6+function randomBytes(length: number): Uint8Array {
7+ const bytes = new Uint8Array(length);
8+ crypto.getRandomValues(bytes);
9+ return bytes;
10+}
11+
12+function textBytes(text: string): Uint8Array {
13+ return new TextEncoder().encode(text);
14+}
15+
16+function concatBytes(parts: Uint8Array[]): Uint8Array {
17+ const total = parts.reduce((sum, part) => sum + part.length, 0);
18+ const out = new Uint8Array(total);
19+ let offset = 0;
20+ for (const part of parts) {
21+ out.set(part, offset);
22+ offset += part.length;
23+ }
24+ return out;
25+}
26+
27+function base64url(bytes: Uint8Array): string {
28+ let binary = '';
29+ for (const byte of bytes) binary += String.fromCharCode(byte);
30+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
31+}
32+
33+async function encryptWhole(rawKey: Uint8Array, iv: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array> {
34+ const encryptKey = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-CTR' }, false, ['encrypt']);
35+ const counter = addBlockOffset(iv, 0);
36+ const cipher = await crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, encryptKey, plaintext);
37+ return new Uint8Array(cipher);
38+}
39+
40+async function decryptRange(
41+ key: CryptoKey,
42+ iv: Uint8Array,
43+ ciphertext: Uint8Array,
44+ start: number,
45+ end: number,
46+ chunkSize: number
47+): Promise<Uint8Array> {
48+ const alignedStart = start - (start % 16);
49+ const slice = ciphertext.subarray(alignedStart, end + 1);
50+ const decryptor = new RangeDecryptor(key, iv, alignedStart / 16);
51+ const parts: Uint8Array[] = [];
52+ for (let offset = 0; offset < slice.length; offset += chunkSize) {
53+ const chunk = slice.subarray(offset, Math.min(offset + chunkSize, slice.length));
54+ const plain = await decryptor.push(chunk);
55+ if (plain.length > 0) parts.push(plain);
56+ }
57+ const last = await decryptor.finish();
58+ if (last.length > 0) parts.push(last);
59+ return concatBytes(parts).subarray(start - alignedStart);
60+}
61+
62+async function setup(plaintextLength: number) {
63+ const rawKey = randomBytes(32);
64+ const iv = randomBytes(16);
65+ const key = await importAttachmentKey(base64url(rawKey));
66+ const plaintext = randomBytes(plaintextLength);
67+ const ciphertext = await encryptWhole(rawKey, iv, plaintext);
68+ return { key, iv, plaintext, ciphertext };
69+}
70+
71+test('range decryptor: whole read matches the plaintext', async () => {
72+ const { key, iv, plaintext, ciphertext } = await setup(1000);
73+ const decryptor = new RangeDecryptor(key, iv, 0);
74+ const decrypted = concatBytes([await decryptor.push(ciphertext), await decryptor.finish()]);
75+ assert.deepEqual(decrypted, plaintext);
76+});
77+
78+test('range decryptor: unaligned start and end', async () => {
79+ const { key, iv, plaintext, ciphertext } = await setup(1000);
80+ const start = 37;
81+ const end = 601;
82+ const got = await decryptRange(key, iv, ciphertext, start, end, 512);
83+ assert.deepEqual(got, plaintext.subarray(start, end + 1));
84+});
85+
86+test('range decryptor: chunk sizes that split blocks', async () => {
87+ const { key, iv, plaintext, ciphertext } = await setup(1000);
88+ const start = 100;
89+ const end = 900;
90+ const got = await decryptRange(key, iv, ciphertext, start, end, 7);
91+ assert.deepEqual(got, plaintext.subarray(start, end + 1));
92+});
93+
94+test('range decryptor: range starting past the first block', async () => {
95+ const { key, iv, plaintext, ciphertext } = await setup(2000);
96+ const start = 1040;
97+ const end = 1500;
98+ const got = await decryptRange(key, iv, ciphertext, start, end, 33);
99+ assert.deepEqual(got, plaintext.subarray(start, end + 1));
100+});
101+
102+test('sniff: recognises a few magic numbers', () => {
103+ assert.equal(imageContentType(Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), 'image/png');
104+ assert.equal(imageContentType(Uint8Array.from([0xff, 0xd8, 0xff, 0xe0])), 'image/jpeg');
105+ assert.equal(attachmentContentType(textBytes('OggS')), 'audio/ogg');
106+ assert.equal(attachmentContentType(Uint8Array.from([0x1a, 0x45, 0xdf, 0xa3])), 'video/webm');
107+ const mp4 = concatBytes([new Uint8Array(4), textBytes('ftyp'), textBytes('isom')]);
108+ assert.equal(attachmentContentType(mp4), 'video/mp4');
109+ assert.equal(attachmentContentType(textBytes('not a media file')), null);
110+});
Added file: web/test/node-shims.d.ts+15 additions
@@ -0,0 +1,15 @@
1+// No @types/node in this project (kept dependency-free), so these are the
2+// minimal ambient declarations node --test and node:assert/strict need to
3+// type-check under tsconfig.test.json's DOM-flavoured lib.
4+declare module 'node:test' {
5+ export function test(name: string, fn: () => void | Promise<void>): void;
6+}
7+
8+declare module 'node:assert/strict' {
9+ interface Assert {
10+ equal(actual: unknown, expected: unknown, message?: string): void;
11+ deepEqual(actual: unknown, expected: unknown, message?: string): void;
12+ }
13+ const assert: Assert;
14+ export default assert;
15+}
Added file: web/tsconfig.json+14 additions
@@ -0,0 +1,14 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "isolatedModules": true,
9+ "resolveJsonModule": true,
10+ "types": ["vite/client"]
11+ },
12+ "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"],
13+ "exclude": ["src/worker/**", "src/sw.ts"]
14+}
Added file: web/tsconfig.test.json+11 additions
@@ -0,0 +1,11 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "NodeNext",
5+ "moduleResolution": "NodeNext",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "outDir": ".test-dist"
9+ },
10+ "include": ["src/lib/media-decrypt.ts", "src/lib/media-sniff.ts", "test/media.test.ts", "test/node-shims.d.ts"]
11+}
Added file: web/tsconfig.worker.json+15 additions
@@ -0,0 +1,15 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "lib": ["ES2022", "WebWorker"],
7+ "strict": true,
8+ "skipLibCheck": true,
9+ "isolatedModules": true,
10+ "resolveJsonModule": true,
11+ "types": ["vite/client"],
12+ "noEmit": true
13+ },
14+ "include": ["src/worker/**/*.ts", "src/sw.ts", "src/lib/wire-status.ts", "src/lib/guards.ts"]
15+}
Added file: web/vite.config.ts+70 additions
@@ -0,0 +1,70 @@
1+import { defineConfig } from 'vite';
2+import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3+
4+/**
5+ * The page isn't SW-controlled on first launch, so the worker chunk and the
6+ * wasm can bypass fetch interception entirely and never get cached. Instead
7+ * of relying on runtime fetches, prepend the full list of emitted files
8+ * (chunks, assets, wasm) to sw.js as a global so install() can precache all
9+ * of it directly, no separate request needed.
10+ */
11+function precacheManifestPlugin(): import('vite').Plugin {
12+ return {
13+ name: 'malice-sw-precache-manifest',
14+ generateBundle(_options, bundle) {
15+ const urls = Object.keys(bundle)
16+ .filter((fileName) => fileName !== 'sw.js' && fileName !== '.vite/manifest.json')
17+ .map((fileName) => `/${fileName}`);
18+ const swChunk = bundle['sw.js'];
19+ if (swChunk && swChunk.type === 'chunk') {
20+ swChunk.code = `self.__MALICE_PRECACHE__=${JSON.stringify(urls)};\n${swChunk.code}`;
21+ }
22+ }
23+ };
24+}
25+
26+export default defineConfig(({ mode }) => {
27+ const device = mode === 'device';
28+ return {
29+ plugins: [svelte({ preprocess: vitePreprocess() }), ...(device ? [precacheManifestPlugin()] : [])],
30+ assetsInclude: ['**/*.wasm'],
31+ resolve: {
32+ alias: {
33+ 'malice:device-transport': device ? '/src/lib/device-transport.ts' : '/src/lib/device-transport.stub.ts'
34+ }
35+ },
36+ server: {
37+ host: '127.0.0.1',
38+ port: 5173,
39+ proxy: {
40+ '/api': 'http://localhost:3000'
41+ }
42+ },
43+ preview: {
44+ host: '127.0.0.1',
45+ port: 4173,
46+ proxy: {
47+ '/api': 'http://localhost:3000'
48+ }
49+ },
50+ build: device
51+ ? {
52+ outDir: 'dist-device',
53+ emptyOutDir: true,
54+ manifest: true,
55+ rollupOptions: {
56+ input: {
57+ main: 'index.html',
58+ sw: 'src/sw.ts'
59+ },
60+ output: {
61+ entryFileNames: (chunk) => (chunk.name === 'sw' ? 'sw.js' : 'assets/[name]-[hash].js')
62+ }
63+ }
64+ }
65+ : {
66+ outDir: 'dist',
67+ emptyOutDir: true
68+ }
69+ };
70+});

show truncated diff