cheogram-www

created pr with cheogram-www/tauri-alpha.1 on 2026-10-29T17:17:18Z · by 3c31bc8c
commented on 2026-10-29T17:17:19Z · by 3c31bc8c
Changes are available at https://tangled.org/mrry.band/cheogram-www on branch tauri-alpha.
added cheogram-www/tauri-alpha.2 on 2026-11-04T17:46:08Z · by 3c31bc8c
1: 45e8ad7 = 1: 45e8ad7 Refactor service worker access
2: fab4022 ! 2: ce66e8f Move notifications code to one file
-: ------- > 3: 10e3574 Refactor notification code
3: d127ee0 ! 4: ba0891f npm run tauri init
4: 9e006bd = 5: 5b3abf9 Request microphone and camera usage on mac
5: 03d4934 = 6: 994a2e9 Tauri file system media store
6: b96216a = 7: 320fa8f Don't register or use serviceworker in tauri
7: faace0a = 8: e76db17 Open links in browser
8: 9c07308 ! 9: 761dd22 Tauri notifications
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:tauri-alpha | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:tauri-alpha [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:tauri-alpha
add comment to patch request:
ssh pr.pico.sh comment cheogram-www:tauri-alpha "lgtm!"

Patchset cheogram-www/tauri-alpha.1 on 2026-10-29T17:17:18Z · commit 03d4934

Tauri file system media store
Eric Roberts 2026-10-16T18:10:16Z
Semantic diff summary
82 added, 8 modified, 0 signature changed, 0 removed across 11 analyzed files (4 files skipped: unsupported file type)
+1 -0 package.json #
......@@ -20,6 +20,7 @@
2020 "@privacyresearch/libsignal-protocol-typescript": "^0.0.16",
2121 "@sentry/browser": "^10.52.0",
2222 "@sqlite.org/sqlite-wasm": "^3.54.1-build1",
23+ "@tauri-apps/api": "^2.12.4",
2324 "@types/react": "<= 18.2.0",
2425 "@types/react-dom": "<= 18.2.0",
2526 "borogove": "^0.0.1",
+660 -121 src-tauri/Cargo.lock #
......@@ -65,15 +65,134 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
6565 checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
6666
6767 [[package]]
68-name = "cheogram-www"
69-version = "0.0.1"
68+name = "async-broadcast"
69+version = "0.7.2"
70+source = "registry+https://github.com/rust-lang/crates.io-index"
71+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
7072 dependencies = [
71- "log",
72- "serde",
73- "serde_json",
74- "tauri",
75- "tauri-build",
76- "tauri-plugin-log",
73+ "event-listener",
74+ "event-listener-strategy",
75+ "futures-core",
76+ "pin-project-lite",
77+]
78+
79+[[package]]
80+name = "async-channel"
81+version = "2.5.0"
82+source = "registry+https://github.com/rust-lang/crates.io-index"
83+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
84+dependencies = [
85+ "concurrent-queue",
86+ "event-listener-strategy",
87+ "futures-core",
88+ "pin-project-lite",
89+]
90+
91+[[package]]
92+name = "async-executor"
93+version = "1.14.0"
94+source = "registry+https://github.com/rust-lang/crates.io-index"
95+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
96+dependencies = [
97+ "async-task",
98+ "concurrent-queue",
99+ "fastrand",
100+ "futures-lite",
101+ "pin-project-lite",
102+ "slab",
103+]
104+
105+[[package]]
106+name = "async-io"
107+version = "2.6.0"
108+source = "registry+https://github.com/rust-lang/crates.io-index"
109+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
110+dependencies = [
111+ "autocfg",
112+ "cfg-if",
113+ "concurrent-queue",
114+ "futures-io",
115+ "futures-lite",
116+ "parking",
117+ "polling",
118+ "rustix",
119+ "slab",
120+ "windows-sys 0.61.2",
121+]
122+
123+[[package]]
124+name = "async-lock"
125+version = "3.4.2"
126+source = "registry+https://github.com/rust-lang/crates.io-index"
127+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
128+dependencies = [
129+ "event-listener",
130+ "event-listener-strategy",
131+ "pin-project-lite",
132+]
133+
134+[[package]]
135+name = "async-process"
136+version = "2.5.0"
137+source = "registry+https://github.com/rust-lang/crates.io-index"
138+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
139+dependencies = [
140+ "async-channel",
141+ "async-io",
142+ "async-lock",
143+ "async-signal",
144+ "async-task",
145+ "blocking",
146+ "cfg-if",
147+ "event-listener",
148+ "futures-lite",
149+ "rustix",
150+]
151+
152+[[package]]
153+name = "async-recursion"
154+version = "1.2.0"
155+source = "registry+https://github.com/rust-lang/crates.io-index"
156+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
157+dependencies = [
158+ "proc-macro2",
159+ "quote",
160+ "syn 3.0.9",
161+]
162+
163+[[package]]
164+name = "async-signal"
165+version = "0.2.14"
166+source = "registry+https://github.com/rust-lang/crates.io-index"
167+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
168+dependencies = [
169+ "async-io",
170+ "async-lock",
171+ "atomic-waker",
172+ "cfg-if",
173+ "futures-core",
174+ "futures-io",
175+ "rustix",
176+ "signal-hook-registry",
177+ "slab",
178+ "windows-sys 0.61.2",
179+]
180+
181+[[package]]
182+name = "async-task"
183+version = "4.7.1"
184+source = "registry+https://github.com/rust-lang/crates.io-index"
185+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
186+
187+[[package]]
188+name = "async-trait"
189+version = "0.1.92"
190+source = "registry+https://github.com/rust-lang/crates.io-index"
191+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
192+dependencies = [
193+ "proc-macro2",
194+ "quote",
195+ "syn 3.0.9",
77196 ]
78197
79198 [[package]]
......@@ -177,11 +296,24 @@ dependencies = [
177296 "objc2",
178297 ]
179298
299+[[package]]
300+name = "blocking"
301+version = "1.7.0"
302+source = "registry+https://github.com/rust-lang/crates.io-index"
303+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
304+dependencies = [
305+ "async-channel",
306+ "async-task",
307+ "futures-io",
308+ "futures-lite",
309+ "piper",
310+]
311+
180312 [[package]]
181313 name = "brotli"
182-version = "9.0.0"
314+version = "9.1.1"
183315 source = "registry+https://github.com/rust-lang/crates.io-index"
184-checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
316+checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
185317 dependencies = [
186318 "alloc-no-stdlib",
187319 "alloc-stdlib",
......@@ -190,9 +322,9 @@ dependencies = [
190322
191323 [[package]]
192324 name = "brotli-decompressor"
193-version = "6.0.1"
325+version = "6.2.0"
194326 source = "registry+https://github.com/rust-lang/crates.io-index"
195-checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
327+checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf"
196328 dependencies = [
197329 "alloc-no-stdlib",
198330 "alloc-stdlib",
......@@ -304,9 +436,9 @@ dependencies = [
304436
305437 [[package]]
306438 name = "cc"
307-version = "1.8.0"
439+version = "1.10.0"
308440 source = "registry+https://github.com/rust-lang/crates.io-index"
309-checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
441+checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
310442 dependencies = [
311443 "find-msvc-tools",
312444 "shlex",
......@@ -345,6 +477,29 @@ version = "1.0.5"
345477 source = "registry+https://github.com/rust-lang/crates.io-index"
346478 checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
347479
480+[[package]]
481+name = "cheogram-www"
482+version = "0.0.1"
483+dependencies = [
484+ "base64 0.22.1",
485+ "hex",
486+ "infer",
487+ "log",
488+ "mime2ext",
489+ "sanitize-filename",
490+ "serde",
491+ "serde_json",
492+ "sha1",
493+ "sha2",
494+ "tauri",
495+ "tauri-build",
496+ "tauri-plugin-log",
497+ "tauri-plugin-opener",
498+ "tempfile",
499+ "tokio",
500+ "uuid",
501+]
502+
348503 [[package]]
349504 name = "chrono"
350505 version = "0.4.45"
......@@ -367,6 +522,15 @@ dependencies = [
367522 "memchr",
368523 ]
369524
525+[[package]]
526+name = "concurrent-queue"
527+version = "2.5.0"
528+source = "registry+https://github.com/rust-lang/crates.io-index"
529+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
530+dependencies = [
531+ "crossbeam-utils",
532+]
533+
370534 [[package]]
371535 name = "cookie"
372536 version = "0.18.2"
......@@ -428,9 +592,9 @@ dependencies = [
428592
429593 [[package]]
430594 name = "crc32fast"
431-version = "1.5.2"
595+version = "1.5.3"
432596 source = "registry+https://github.com/rust-lang/crates.io-index"
433-checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
597+checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
434598 dependencies = [
435599 "cfg-if",
436600 ]
......@@ -743,6 +907,33 @@ version = "1.2.2"
743907 source = "registry+https://github.com/rust-lang/crates.io-index"
744908 checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
745909
910+[[package]]
911+name = "endi"
912+version = "1.1.1"
913+source = "registry+https://github.com/rust-lang/crates.io-index"
914+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
915+
916+[[package]]
917+name = "enumflags2"
918+version = "0.7.12"
919+source = "registry+https://github.com/rust-lang/crates.io-index"
920+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
921+dependencies = [
922+ "enumflags2_derive",
923+ "serde",
924+]
925+
926+[[package]]
927+name = "enumflags2_derive"
928+version = "0.7.12"
929+source = "registry+https://github.com/rust-lang/crates.io-index"
930+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
931+dependencies = [
932+ "proc-macro2",
933+ "quote",
934+ "syn 2.0.119",
935+]
936+
746937 [[package]]
747938 name = "env_filter"
748939 version = "0.1.4"
......@@ -761,15 +952,45 @@ checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
761952
762953 [[package]]
763954 name = "erased-serde"
764-version = "0.4.10"
955+version = "0.4.11"
765956 source = "registry+https://github.com/rust-lang/crates.io-index"
766-checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
957+checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66"
767958 dependencies = [
768959 "serde",
769960 "serde_core",
770961 "typeid",
771962 ]
772963
964+[[package]]
965+name = "errno"
966+version = "0.3.14"
967+source = "registry+https://github.com/rust-lang/crates.io-index"
968+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
969+dependencies = [
970+ "libc",
971+ "windows-sys 0.61.2",
972+]
973+
974+[[package]]
975+name = "event-listener"
976+version = "5.4.2"
977+source = "registry+https://github.com/rust-lang/crates.io-index"
978+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
979+dependencies = [
980+ "parking",
981+ "pin-project-lite",
982+]
983+
984+[[package]]
985+name = "event-listener-strategy"
986+version = "0.5.4"
987+source = "registry+https://github.com/rust-lang/crates.io-index"
988+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
989+dependencies = [
990+ "event-listener",
991+ "pin-project-lite",
992+]
993+
773994 [[package]]
774995 name = "fastrand"
775996 version = "2.5.0"
......@@ -806,9 +1027,9 @@ dependencies = [
8061027
8071028 [[package]]
8081029 name = "find-msvc-tools"
809-version = "0.1.14"
1030+version = "0.1.15"
8101031 source = "registry+https://github.com/rust-lang/crates.io-index"
811-checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
1032+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
8121033
8131034 [[package]]
8141035 name = "flate2"
......@@ -901,6 +1122,19 @@ version = "0.3.34"
9011122 source = "registry+https://github.com/rust-lang/crates.io-index"
9021123 checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
9031124
1125+[[package]]
1126+name = "futures-lite"
1127+version = "2.6.1"
1128+source = "registry+https://github.com/rust-lang/crates.io-index"
1129+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
1130+dependencies = [
1131+ "fastrand",
1132+ "futures-core",
1133+ "futures-io",
1134+ "parking",
1135+ "pin-project-lite",
1136+]
1137+
9041138 [[package]]
9051139 name = "futures-macro"
9061140 version = "0.3.34"
......@@ -1134,7 +1368,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
11341368 checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
11351369 dependencies = [
11361370 "heck 0.4.1",
1137- "proc-macro-crate 2.0.2",
1371+ "proc-macro-crate 2.0.0",
11381372 "proc-macro-error",
11391373 "proc-macro2",
11401374 "quote",
......@@ -1244,6 +1478,12 @@ version = "0.5.0"
12441478 source = "registry+https://github.com/rust-lang/crates.io-index"
12451479 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
12461480
1481+[[package]]
1482+name = "hermit-abi"
1483+version = "0.5.3"
1484+source = "registry+https://github.com/rust-lang/crates.io-index"
1485+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
1486+
12471487 [[package]]
12481488 name = "hex"
12491489 version = "0.4.3"
......@@ -1282,9 +1522,9 @@ dependencies = [
12821522
12831523 [[package]]
12841524 name = "http-body-util"
1285-version = "0.1.5"
1525+version = "0.1.4"
12861526 source = "registry+https://github.com/rust-lang/crates.io-index"
1287-checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
1527+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
12881528 dependencies = [
12891529 "bytes",
12901530 "futures-core",
......@@ -1301,12 +1541,13 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
13011541
13021542 [[package]]
13031543 name = "hyper"
1304-version = "1.12.0"
1544+version = "1.10.1"
13051545 source = "registry+https://github.com/rust-lang/crates.io-index"
1306-checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
1546+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
13071547 dependencies = [
13081548 "atomic-waker",
13091549 "bytes",
1550+ "futures-channel",
13101551 "futures-core",
13111552 "http",
13121553 "http-body",
......@@ -1320,17 +1561,16 @@ dependencies = [
13201561
13211562 [[package]]
13221563 name = "hyper-util"
1323-version = "0.1.21"
1564+version = "0.1.20"
13241565 source = "registry+https://github.com/rust-lang/crates.io-index"
1325-checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
1566+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
13261567 dependencies = [
1327- "base64 0.23.1",
1568+ "base64 0.22.1",
13281569 "bytes",
13291570 "futures-channel",
13301571 "futures-util",
13311572 "http",
13321573 "http-body",
1333- "httparse",
13341574 "hyper",
13351575 "ipnet",
13361576 "libc",
......@@ -1368,19 +1608,19 @@ dependencies = [
13681608
13691609 [[package]]
13701610 name = "ico"
1371-version = "0.5.0"
1611+version = "0.6.0"
13721612 source = "registry+https://github.com/rust-lang/crates.io-index"
1373-checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
1613+checksum = "d75e94d66081e4d8dcd098bd8dc5254800fea14a26389b298410158d834de167"
13741614 dependencies = [
13751615 "byteorder",
1376- "png 0.17.16",
1616+ "png",
13771617 ]
13781618
13791619 [[package]]
13801620 name = "icu_collections"
1381-version = "2.3.0"
1621+version = "2.4.0"
13821622 source = "registry+https://github.com/rust-lang/crates.io-index"
1383-checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
1623+checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
13841624 dependencies = [
13851625 "displaydoc",
13861626 "potential_utf",
......@@ -1392,9 +1632,9 @@ dependencies = [
13921632
13931633 [[package]]
13941634 name = "icu_locale_core"
1395-version = "2.3.0"
1635+version = "2.4.0"
13961636 source = "registry+https://github.com/rust-lang/crates.io-index"
1397-checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
1637+checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
13981638 dependencies = [
13991639 "displaydoc",
14001640 "litemap",
......@@ -1405,9 +1645,9 @@ dependencies = [
14051645
14061646 [[package]]
14071647 name = "icu_normalizer"
1408-version = "2.3.0"
1648+version = "2.4.0"
14091649 source = "registry+https://github.com/rust-lang/crates.io-index"
1410-checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
1650+checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
14111651 dependencies = [
14121652 "icu_collections",
14131653 "icu_normalizer_data",
......@@ -1419,15 +1659,15 @@ dependencies = [
14191659
14201660 [[package]]
14211661 name = "icu_normalizer_data"
1422-version = "2.3.0"
1662+version = "2.4.0"
14231663 source = "registry+https://github.com/rust-lang/crates.io-index"
1424-checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
1664+checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
14251665
14261666 [[package]]
14271667 name = "icu_properties"
1428-version = "2.3.0"
1668+version = "2.4.0"
14291669 source = "registry+https://github.com/rust-lang/crates.io-index"
1430-checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
1670+checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
14311671 dependencies = [
14321672 "displaydoc",
14331673 "icu_collections",
......@@ -1440,15 +1680,15 @@ dependencies = [
14401680
14411681 [[package]]
14421682 name = "icu_properties_data"
1443-version = "2.3.0"
1683+version = "2.4.0"
14441684 source = "registry+https://github.com/rust-lang/crates.io-index"
1445-checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
1685+checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
14461686
14471687 [[package]]
14481688 name = "icu_provider"
1449-version = "2.3.1"
1689+version = "2.4.0"
14501690 source = "registry+https://github.com/rust-lang/crates.io-index"
1451-checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
1691+checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
14521692 dependencies = [
14531693 "displaydoc",
14541694 "icu_locale_core",
......@@ -1520,9 +1760,28 @@ dependencies = [
15201760
15211761 [[package]]
15221762 name = "ipnet"
1523-version = "2.12.2"
1763+version = "2.12.0"
15241764 source = "registry+https://github.com/rust-lang/crates.io-index"
1525-checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
1765+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
1766+
1767+[[package]]
1768+name = "is-docker"
1769+version = "0.2.0"
1770+source = "registry+https://github.com/rust-lang/crates.io-index"
1771+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
1772+dependencies = [
1773+ "once_cell",
1774+]
1775+
1776+[[package]]
1777+name = "is-wsl"
1778+version = "0.4.0"
1779+source = "registry+https://github.com/rust-lang/crates.io-index"
1780+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
1781+dependencies = [
1782+ "is-docker",
1783+ "once_cell",
1784+]
15261785
15271786 [[package]]
15281787 name = "itoa"
......@@ -1720,9 +1979,9 @@ dependencies = [
17201979
17211980 [[package]]
17221981 name = "libc"
1723-version = "0.2.190"
1982+version = "0.2.191"
17241983 source = "registry+https://github.com/rust-lang/crates.io-index"
1725-checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
1984+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
17261985
17271986 [[package]]
17281987 name = "libdbus-sys"
......@@ -1752,6 +2011,12 @@ dependencies = [
17522011 "libc",
17532012 ]
17542013
2014+[[package]]
2015+name = "linux-raw-sys"
2016+version = "0.12.1"
2017+source = "registry+https://github.com/rust-lang/crates.io-index"
2018+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
2019+
17552020 [[package]]
17562021 name = "litemap"
17572022 version = "0.8.3"
......@@ -1805,6 +2070,12 @@ version = "0.3.17"
18052070 source = "registry+https://github.com/rust-lang/crates.io-index"
18062071 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
18072072
2073+[[package]]
2074+name = "mime2ext"
2075+version = "0.1.54"
2076+source = "registry+https://github.com/rust-lang/crates.io-index"
2077+checksum = "cbf6f36070878c42c5233846cd3de24cf9016828fd47bc22957a687298bb21fc"
2078+
18082079 [[package]]
18092080 name = "miniz_oxide"
18102081 version = "0.8.9"
......@@ -1851,7 +2122,7 @@ dependencies = [
18512122 "objc2-core-foundation",
18522123 "objc2-foundation",
18532124 "once_cell",
1854- "png 0.18.1",
2125+ "png",
18552126 "serde",
18562127 "thiserror 2.0.21",
18572128 "windows-sys 0.61.2",
......@@ -1932,9 +2203,9 @@ dependencies = [
19322203
19332204 [[package]]
19342205 name = "num_threads"
1935-version = "0.1.7"
2206+version = "0.1.8"
19362207 source = "registry+https://github.com/rust-lang/crates.io-index"
1937-checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
2208+checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
19382209 dependencies = [
19392210 "libc",
19402211 ]
......@@ -2141,12 +2412,33 @@ version = "1.21.4"
21412412 source = "registry+https://github.com/rust-lang/crates.io-index"
21422413 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
21432414
2415+[[package]]
2416+name = "open"
2417+version = "5.4.5"
2418+source = "registry+https://github.com/rust-lang/crates.io-index"
2419+checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
2420+dependencies = [
2421+ "dunce",
2422+ "is-wsl",
2423+ "libc",
2424+]
2425+
21442426 [[package]]
21452427 name = "option-ext"
21462428 version = "0.2.0"
21472429 source = "registry+https://github.com/rust-lang/crates.io-index"
21482430 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
21492431
2432+[[package]]
2433+name = "ordered-stream"
2434+version = "0.2.0"
2435+source = "registry+https://github.com/rust-lang/crates.io-index"
2436+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
2437+dependencies = [
2438+ "futures-core",
2439+ "pin-project-lite",
2440+]
2441+
21502442 [[package]]
21512443 name = "pango"
21522444 version = "0.18.3"
......@@ -2172,6 +2464,12 @@ dependencies = [
21722464 "system-deps",
21732465 ]
21742466
2467+[[package]]
2468+name = "parking"
2469+version = "2.2.1"
2470+source = "registry+https://github.com/rust-lang/crates.io-index"
2471+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2472+
21752473 [[package]]
21762474 name = "parking_lot"
21772475 version = "0.12.5"
......@@ -2260,6 +2558,17 @@ version = "0.2.17"
22602558 source = "registry+https://github.com/rust-lang/crates.io-index"
22612559 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
22622560
2561+[[package]]
2562+name = "piper"
2563+version = "0.2.5"
2564+source = "registry+https://github.com/rust-lang/crates.io-index"
2565+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
2566+dependencies = [
2567+ "atomic-waker",
2568+ "fastrand",
2569+ "futures-io",
2570+]
2571+
22632572 [[package]]
22642573 name = "pkg-config"
22652574 version = "0.3.34"
......@@ -2268,9 +2577,9 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
22682577
22692578 [[package]]
22702579 name = "plist"
2271-version = "1.10.1"
2580+version = "1.10.3"
22722581 source = "registry+https://github.com/rust-lang/crates.io-index"
2273-checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb"
2582+checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
22742583 dependencies = [
22752584 "base64 0.23.1",
22762585 "indexmap 2.14.2",
......@@ -2281,11 +2590,11 @@ dependencies = [
22812590
22822591 [[package]]
22832592 name = "png"
2284-version = "0.17.16"
2593+version = "0.18.1"
22852594 source = "registry+https://github.com/rust-lang/crates.io-index"
2286-checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
2595+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
22872596 dependencies = [
2288- "bitflags 1.3.2",
2597+ "bitflags 2.13.2",
22892598 "crc32fast",
22902599 "fdeflate",
22912600 "flate2",
......@@ -2293,16 +2602,17 @@ dependencies = [
22932602 ]
22942603
22952604 [[package]]
2296-name = "png"
2297-version = "0.18.1"
2605+name = "polling"
2606+version = "3.11.0"
22982607 source = "registry+https://github.com/rust-lang/crates.io-index"
2299-checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
2608+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
23002609 dependencies = [
2301- "bitflags 2.13.2",
2302- "crc32fast",
2303- "fdeflate",
2304- "flate2",
2305- "miniz_oxide 0.8.9",
2610+ "cfg-if",
2611+ "concurrent-queue",
2612+ "hermit-abi",
2613+ "pin-project-lite",
2614+ "rustix",
2615+ "windows-sys 0.61.2",
23062616 ]
23072617
23082618 [[package]]
......@@ -2353,11 +2663,10 @@ dependencies = [
23532663
23542664 [[package]]
23552665 name = "proc-macro-crate"
2356-version = "2.0.2"
2666+version = "2.0.0"
23572667 source = "registry+https://github.com/rust-lang/crates.io-index"
2358-checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
2668+checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8"
23592669 dependencies = [
2360- "toml_datetime 0.6.3",
23612670 "toml_edit 0.20.2",
23622671 ]
23632672
......@@ -2509,11 +2818,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
25092818
25102819 [[package]]
25112820 name = "reqwest"
2512-version = "0.13.5"
2821+version = "0.13.4"
25132822 source = "registry+https://github.com/rust-lang/crates.io-index"
2514-checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
2823+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
25152824 dependencies = [
2516- "base64 0.23.1",
2825+ "base64 0.22.1",
25172826 "bytes",
25182827 "futures-core",
25192828 "futures-util",
......@@ -2556,6 +2865,19 @@ dependencies = [
25562865 "semver",
25572866 ]
25582867
2868+[[package]]
2869+name = "rustix"
2870+version = "1.1.6"
2871+source = "registry+https://github.com/rust-lang/crates.io-index"
2872+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
2873+dependencies = [
2874+ "bitflags 2.13.2",
2875+ "errno",
2876+ "libc",
2877+ "linux-raw-sys",
2878+ "windows-sys 0.61.2",
2879+]
2880+
25592881 [[package]]
25602882 name = "rustversion"
25612883 version = "1.0.23"
......@@ -2571,6 +2893,15 @@ dependencies = [
25712893 "winapi-util",
25722894 ]
25732895
2896+[[package]]
2897+name = "sanitize-filename"
2898+version = "0.6.0"
2899+source = "registry+https://github.com/rust-lang/crates.io-index"
2900+checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d"
2901+dependencies = [
2902+ "regex",
2903+]
2904+
25742905 [[package]]
25752906 name = "schemars"
25762907 version = "0.8.22"
......@@ -2816,6 +3147,17 @@ dependencies = [
28163147 "stable_deref_trait",
28173148 ]
28183149
3150+[[package]]
3151+name = "sha1"
3152+version = "0.10.7"
3153+source = "registry+https://github.com/rust-lang/crates.io-index"
3154+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
3155+dependencies = [
3156+ "cfg-if",
3157+ "cpufeatures",
3158+ "digest",
3159+]
3160+
28193161 [[package]]
28203162 name = "sha2"
28213163 version = "0.10.9"
......@@ -2833,6 +3175,16 @@ version = "2.0.1"
28333175 source = "registry+https://github.com/rust-lang/crates.io-index"
28343176 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
28353177
3178+[[package]]
3179+name = "signal-hook-registry"
3180+version = "1.4.8"
3181+source = "registry+https://github.com/rust-lang/crates.io-index"
3182+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
3183+dependencies = [
3184+ "errno",
3185+ "libc",
3186+]
3187+
28363188 [[package]]
28373189 name = "simd-adler32"
28383190 version = "0.3.10"
......@@ -2853,9 +3205,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
28533205
28543206 [[package]]
28553207 name = "smallvec"
2856-version = "1.16.3"
3208+version = "1.16.4"
28573209 source = "registry+https://github.com/rust-lang/crates.io-index"
2858-checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
3210+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
28593211
28603212 [[package]]
28613213 name = "socket2"
......@@ -3029,15 +3381,15 @@ dependencies = [
30293381 "cfg-expr",
30303382 "heck 0.5.0",
30313383 "pkg-config",
3032- "toml 0.8.2",
3384+ "toml 0.8.23",
30333385 "version-compare",
30343386 ]
30353387
30363388 [[package]]
30373389 name = "tao"
3038-version = "0.37.1"
3390+version = "0.37.2"
30393391 source = "registry+https://github.com/rust-lang/crates.io-index"
3040-checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
3392+checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548"
30413393 dependencies = [
30423394 "bitflags 2.13.2",
30433395 "block2",
......@@ -3092,9 +3444,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
30923444
30933445 [[package]]
30943446 name = "tauri"
3095-version = "2.12.4"
3447+version = "2.12.5"
30963448 source = "registry+https://github.com/rust-lang/crates.io-index"
3097-checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212"
3449+checksum = "5fb0244332cfb9cee7e2a568bf5da29a36ed8fdbd717f6f47a66b6b7b358c072"
30983450 dependencies = [
30993451 "anyhow",
31003452 "bytes",
......@@ -3143,9 +3495,9 @@ dependencies = [
31433495
31443496 [[package]]
31453497 name = "tauri-build"
3146-version = "2.7.3"
3498+version = "2.7.4"
31473499 source = "registry+https://github.com/rust-lang/crates.io-index"
3148-checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc"
3500+checksum = "adddb0e648752aaac5da9c3c3c6a68e1e87cab55e3b757b18a9441ef8c3ac614"
31493501 dependencies = [
31503502 "anyhow",
31513503 "cargo_toml",
......@@ -3166,16 +3518,16 @@ dependencies = [
31663518
31673519 [[package]]
31683520 name = "tauri-codegen"
3169-version = "2.7.3"
3521+version = "2.7.4"
31703522 source = "registry+https://github.com/rust-lang/crates.io-index"
3171-checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f"
3523+checksum = "fb37631cf71dab5e8ab55e1bdd701e18a52653d90b78e71d0e5598b1e0a61316"
31723524 dependencies = [
31733525 "base64 0.23.1",
31743526 "brotli",
31753527 "ico",
31763528 "json-patch",
31773529 "plist",
3178- "png 0.18.1",
3530+ "png",
31793531 "proc-macro2",
31803532 "quote",
31813533 "semver",
......@@ -3193,9 +3545,9 @@ dependencies = [
31933545
31943546 [[package]]
31953547 name = "tauri-macros"
3196-version = "2.7.3"
3548+version = "2.7.4"
31973549 source = "registry+https://github.com/rust-lang/crates.io-index"
3198-checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f"
3550+checksum = "0cc38db58854c10fb5f91a887ed8bf82d3239bb92b5d8db86eda09e96d9963d1"
31993551 dependencies = [
32003552 "heck 0.5.0",
32013553 "proc-macro2",
......@@ -3242,6 +3594,28 @@ dependencies = [
32423594 "time",
32433595 ]
32443596
3597+[[package]]
3598+name = "tauri-plugin-opener"
3599+version = "2.7.2"
3600+source = "registry+https://github.com/rust-lang/crates.io-index"
3601+checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219"
3602+dependencies = [
3603+ "dunce",
3604+ "glob",
3605+ "objc2-app-kit",
3606+ "objc2-foundation",
3607+ "open",
3608+ "schemars 0.8.22",
3609+ "serde",
3610+ "serde_json",
3611+ "tauri",
3612+ "tauri-plugin",
3613+ "thiserror 2.0.21",
3614+ "url",
3615+ "windows",
3616+ "zbus",
3617+]
3618+
32453619 [[package]]
32463620 name = "tauri-runtime"
32473621 version = "2.12.2"
......@@ -3269,9 +3643,9 @@ dependencies = [
32693643
32703644 [[package]]
32713645 name = "tauri-runtime-wry"
3272-version = "2.12.2"
3646+version = "2.12.3"
32733647 source = "registry+https://github.com/rust-lang/crates.io-index"
3274-checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae"
3648+checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f"
32753649 dependencies = [
32763650 "gtk",
32773651 "http",
......@@ -3279,7 +3653,6 @@ dependencies = [
32793653 "log",
32803654 "objc2",
32813655 "objc2-app-kit",
3282- "once_cell",
32833656 "percent-encoding",
32843657 "raw-window-handle",
32853658 "softbuffer",
......@@ -3333,15 +3706,28 @@ dependencies = [
33333706
33343707 [[package]]
33353708 name = "tauri-winres"
3336-version = "0.3.6"
3709+version = "0.3.7"
33373710 source = "registry+https://github.com/rust-lang/crates.io-index"
3338-checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
3711+checksum = "c52bb6904475a6ca662f15ef787f18ade61d5740a1ee12c45d3b748efdb6f796"
33393712 dependencies = [
33403713 "dunce",
33413714 "embed-resource",
33423715 "toml 1.1.8+spec-1.1.0",
33433716 ]
33443717
3718+[[package]]
3719+name = "tempfile"
3720+version = "3.27.0"
3721+source = "registry+https://github.com/rust-lang/crates.io-index"
3722+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
3723+dependencies = [
3724+ "fastrand",
3725+ "getrandom 0.4.3",
3726+ "once_cell",
3727+ "rustix",
3728+ "windows-sys 0.61.2",
3729+]
3730+
33453731 [[package]]
33463732 name = "tendril"
33473733 version = "0.5.1"
......@@ -3425,9 +3811,9 @@ dependencies = [
34253811
34263812 [[package]]
34273813 name = "tinystr"
3428-version = "0.8.4"
3814+version = "0.8.5"
34293815 source = "registry+https://github.com/rust-lang/crates.io-index"
3430-checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
3816+checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
34313817 dependencies = [
34323818 "displaydoc",
34333819 "zerovec",
......@@ -3441,9 +3827,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
34413827
34423828 [[package]]
34433829 name = "tokio"
3444-version = "1.53.3"
3830+version = "1.54.1"
34453831 source = "registry+https://github.com/rust-lang/crates.io-index"
3446-checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
3832+checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
34473833 dependencies = [
34483834 "bytes",
34493835 "libc",
......@@ -3455,9 +3841,9 @@ dependencies = [
34553841
34563842 [[package]]
34573843 name = "tokio-util"
3458-version = "0.7.20"
3844+version = "0.7.18"
34593845 source = "registry+https://github.com/rust-lang/crates.io-index"
3460-checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
3846+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
34613847 dependencies = [
34623848 "bytes",
34633849 "futures-core",
......@@ -3468,14 +3854,14 @@ dependencies = [
34683854
34693855 [[package]]
34703856 name = "toml"
3471-version = "0.8.2"
3857+version = "0.8.23"
34723858 source = "registry+https://github.com/rust-lang/crates.io-index"
3473-checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
3859+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
34743860 dependencies = [
34753861 "serde",
34763862 "serde_spanned 0.6.9",
3477- "toml_datetime 0.6.3",
3478- "toml_edit 0.20.2",
3863+ "toml_datetime 0.6.11",
3864+ "toml_edit 0.22.27",
34793865 ]
34803866
34813867 [[package]]
......@@ -3495,9 +3881,9 @@ dependencies = [
34953881
34963882 [[package]]
34973883 name = "toml_datetime"
3498-version = "0.6.3"
3884+version = "0.6.11"
34993885 source = "registry+https://github.com/rust-lang/crates.io-index"
3500-checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
3886+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
35013887 dependencies = [
35023888 "serde",
35033889 ]
......@@ -3518,7 +3904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
35183904 checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
35193905 dependencies = [
35203906 "indexmap 2.14.2",
3521- "toml_datetime 0.6.3",
3907+ "toml_datetime 0.6.11",
35223908 "winnow 0.5.40",
35233909 ]
35243910
......@@ -3527,12 +3913,23 @@ name = "toml_edit"
35273913 version = "0.20.2"
35283914 source = "registry+https://github.com/rust-lang/crates.io-index"
35293915 checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
3916+dependencies = [
3917+ "indexmap 2.14.2",
3918+ "toml_datetime 0.6.11",
3919+ "winnow 0.5.40",
3920+]
3921+
3922+[[package]]
3923+name = "toml_edit"
3924+version = "0.22.27"
3925+source = "registry+https://github.com/rust-lang/crates.io-index"
3926+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
35303927 dependencies = [
35313928 "indexmap 2.14.2",
35323929 "serde",
35333930 "serde_spanned 0.6.9",
3534- "toml_datetime 0.6.3",
3535- "winnow 0.5.40",
3931+ "toml_datetime 0.6.11",
3932+ "winnow 0.7.15",
35363933 ]
35373934
35383935 [[package]]
......@@ -3614,9 +4011,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
36144011 checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
36154012 dependencies = [
36164013 "pin-project-lite",
4014+ "tracing-attributes",
36174015 "tracing-core",
36184016 ]
36194017
4018+[[package]]
4019+name = "tracing-attributes"
4020+version = "0.1.31"
4021+source = "registry+https://github.com/rust-lang/crates.io-index"
4022+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
4023+dependencies = [
4024+ "proc-macro2",
4025+ "quote",
4026+ "syn 2.0.119",
4027+]
4028+
36204029 [[package]]
36214030 name = "tracing-core"
36224031 version = "0.1.36"
......@@ -3642,7 +4051,7 @@ dependencies = [
36424051 "objc2-core-graphics",
36434052 "objc2-foundation",
36444053 "once_cell",
3645- "png 0.18.1",
4054+ "png",
36464055 "serde",
36474056 "thiserror 2.0.21",
36484057 "windows-sys 0.61.2",
......@@ -3666,6 +4075,17 @@ version = "1.20.1"
36664075 source = "registry+https://github.com/rust-lang/crates.io-index"
36674076 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
36684077
4078+[[package]]
4079+name = "uds_windows"
4080+version = "1.2.1"
4081+source = "registry+https://github.com/rust-lang/crates.io-index"
4082+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
4083+dependencies = [
4084+ "memoffset",
4085+ "tempfile",
4086+ "windows-sys 0.61.2",
4087+]
4088+
36694089 [[package]]
36704090 name = "unicode-ident"
36714091 version = "1.0.27"
......@@ -3711,9 +4131,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
37114131
37124132 [[package]]
37134133 name = "uuid"
3714-version = "1.28.0"
4134+version = "1.29.0"
37154135 source = "registry+https://github.com/rust-lang/crates.io-index"
3716-checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
4136+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
37174137 dependencies = [
37184138 "getrandom 0.4.3",
37194139 "js-sys",
......@@ -3765,9 +4185,9 @@ dependencies = [
37654185
37664186 [[package]]
37674187 name = "want"
3768-version = "0.3.2"
4188+version = "0.3.1"
37694189 source = "registry+https://github.com/rust-lang/crates.io-index"
3770-checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
4190+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
37714191 dependencies = [
37724192 "try-lock",
37734193 ]
......@@ -4216,6 +4636,15 @@ dependencies = [
42164636 "memchr",
42174637 ]
42184638
4639+[[package]]
4640+name = "winnow"
4641+version = "0.7.15"
4642+source = "registry+https://github.com/rust-lang/crates.io-index"
4643+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
4644+dependencies = [
4645+ "memchr",
4646+]
4647+
42194648 [[package]]
42204649 name = "winnow"
42214650 version = "1.0.4"
......@@ -4249,11 +4678,11 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
42494678
42504679 [[package]]
42514680 name = "wry"
4252-version = "0.57.0"
4681+version = "0.57.1"
42534682 source = "registry+https://github.com/rust-lang/crates.io-index"
4254-checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0"
4683+checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9"
42554684 dependencies = [
4256- "base64 0.22.1",
4685+ "base64 0.23.1",
42574686 "block2",
42584687 "cookie",
42594688 "crossbeam-channel",
......@@ -4274,7 +4703,6 @@ dependencies = [
42744703 "objc2-foundation",
42754704 "objc2-ui-kit",
42764705 "objc2-web-kit",
4277- "once_cell",
42784706 "percent-encoding",
42794707 "raw-window-handle",
42804708 "sha2",
......@@ -4314,9 +4742,9 @@ dependencies = [
43144742
43154743 [[package]]
43164744 name = "yoke"
4317-version = "0.8.3"
4745+version = "0.8.4"
43184746 source = "registry+https://github.com/rust-lang/crates.io-index"
4319-checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
4747+checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
43204748 dependencies = [
43214749 "stable_deref_trait",
43224750 "yoke-derive",
......@@ -4335,6 +4763,76 @@ dependencies = [
43354763 "synstructure",
43364764 ]
43374765
4766+[[package]]
4767+name = "zbus"
4768+version = "5.19.0"
4769+source = "registry+https://github.com/rust-lang/crates.io-index"
4770+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
4771+dependencies = [
4772+ "async-broadcast",
4773+ "async-executor",
4774+ "async-io",
4775+ "async-lock",
4776+ "async-process",
4777+ "async-recursion",
4778+ "async-task",
4779+ "async-trait",
4780+ "blocking",
4781+ "enumflags2",
4782+ "event-listener",
4783+ "futures-core",
4784+ "futures-lite",
4785+ "hex",
4786+ "libc",
4787+ "ordered-stream",
4788+ "rustix",
4789+ "serde",
4790+ "serde_repr",
4791+ "tracing",
4792+ "uds_windows",
4793+ "uuid",
4794+ "windows-sys 0.61.2",
4795+ "winnow 1.0.4",
4796+ "zbus_macros",
4797+ "zbus_names",
4798+ "zvariant",
4799+]
4800+
4801+[[package]]
4802+name = "zbus_macros"
4803+version = "5.19.0"
4804+source = "registry+https://github.com/rust-lang/crates.io-index"
4805+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
4806+dependencies = [
4807+ "proc-macro-crate 3.5.0",
4808+ "proc-macro2",
4809+ "quote",
4810+ "syn 3.0.9",
4811+ "zbus_names",
4812+ "zvariant",
4813+ "zvariant_utils",
4814+]
4815+
4816+[[package]]
4817+name = "zbus_names"
4818+version = "4.3.5"
4819+source = "registry+https://github.com/rust-lang/crates.io-index"
4820+checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
4821+dependencies = [
4822+ "serde",
4823+ "winnow 1.0.4",
4824+ "zvariant",
4825+]
4826+
4827+[[package]]
4828+name = "zcheapstr"
4829+version = "1.1.0"
4830+source = "registry+https://github.com/rust-lang/crates.io-index"
4831+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
4832+dependencies = [
4833+ "serde",
4834+]
4835+
43384836 [[package]]
43394837 name = "zerofrom"
43404838 version = "0.1.8"
......@@ -4369,9 +4867,9 @@ dependencies = [
43694867
43704868 [[package]]
43714869 name = "zerovec"
4372-version = "0.11.8"
4870+version = "0.11.9"
43734871 source = "registry+https://github.com/rust-lang/crates.io-index"
4374-checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
4872+checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
43754873 dependencies = [
43764874 "yoke",
43774875 "zerofrom",
......@@ -4380,9 +4878,9 @@ dependencies = [
43804878
43814879 [[package]]
43824880 name = "zerovec-derive"
4383-version = "0.11.6"
4881+version = "0.11.7"
43844882 source = "registry+https://github.com/rust-lang/crates.io-index"
4385-checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
4883+checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
43864884 dependencies = [
43874885 "proc-macro2",
43884886 "quote",
......@@ -4400,3 +4898,44 @@ name = "zmij"
44004898 version = "1.0.23"
44014899 source = "registry+https://github.com/rust-lang/crates.io-index"
44024900 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4901+
4902+[[package]]
4903+name = "zvariant"
4904+version = "5.15.1"
4905+source = "registry+https://github.com/rust-lang/crates.io-index"
4906+checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
4907+dependencies = [
4908+ "endi",
4909+ "enumflags2",
4910+ "serde",
4911+ "winnow 1.0.4",
4912+ "zcheapstr",
4913+ "zvariant_derive",
4914+ "zvariant_utils",
4915+]
4916+
4917+[[package]]
4918+name = "zvariant_derive"
4919+version = "5.15.1"
4920+source = "registry+https://github.com/rust-lang/crates.io-index"
4921+checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
4922+dependencies = [
4923+ "proc-macro-crate 3.5.0",
4924+ "proc-macro2",
4925+ "quote",
4926+ "syn 3.0.9",
4927+ "zvariant_utils",
4928+]
4929+
4930+[[package]]
4931+name = "zvariant_utils"
4932+version = "4.2.0"
4933+source = "registry+https://github.com/rust-lang/crates.io-index"
4934+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
4935+dependencies = [
4936+ "proc-macro2",
4937+ "quote",
4938+ "serde",
4939+ "syn 3.0.9",
4940+ "winnow 1.0.4",
4941+]
+12 -0 src-tauri/Cargo.toml #
......@@ -22,3 +22,15 @@ serde = { version = "1.0", features = ["derive"] }
2222 log = "0.4"
2323 tauri = { version = "2.12.4", features = [] }
2424 tauri-plugin-log = "2"
25+base64 = "0.22"
26+hex = "0.4.3"
27+mime2ext = "0.1.54"
28+tokio = { version = "1", features = ["time"] }
29+sha1 = "0.10"
30+sha2 = "0.10"
31+uuid = { version = "1", features = ["v4"] }
32+infer = "0.22.0"
33+tempfile = "3.27.0"
34+
35+sanitize-filename = "0.6"
36+tauri-plugin-opener = "2"
+23 -0 src-tauri/src/lib.rs #
......@@ -1,7 +1,23 @@
1+mod media;
2+
3+use tauri::Manager;
4+
15 #[cfg_attr(mobile, tauri::mobile_entry_point)]
26 pub fn run() {
37 tauri::Builder::default()
8+ .plugin(tauri_plugin_opener::init())
9+ .invoke_handler(tauri::generate_handler![
10+ media::begin_media,
11+ media::write_media,
12+ media::finish_media,
13+ media::has_media,
14+ media::remove_media
15+ ])
16+ .register_asynchronous_uri_scheme_protocol("tauri", media::protocol)
417 .setup(|app| {
18+ app.manage(media::MediaCache::new(
19+ app.path().app_cache_dir()?.join("media"),
20+ )?);
521 if cfg!(debug_assertions) {
622 app.handle().plugin(
723 tauri_plugin_log::Builder::default()
......@@ -9,6 +25,13 @@ pub fn run() {
925 .build(),
1026 )?;
1127 }
28+ // Build the configured window here so native navigation hooks are installed
29+ // before its first page loads.
30+ let navigation_app = app.handle().clone();
31+ let window =
32+ tauri::WebviewWindowBuilder::from_config(app, &app.config().app.windows[0])?
33+ .on_navigation(move |url| !media::open_navigation(&navigation_app, url));
34+ window.build()?;
1235 Ok(())
1336 })
1437 .run(tauri::generate_context!())
+395 -0 src-tauri/src/media.rs #
......@@ -0,0 +1,395 @@
1+mod hash;
2+mod store;
3+mod upload_slot;
4+
5+use hash::MediaHash;
6+pub use store::MediaCache;
7+use upload_slot::UploadSlot;
8+
9+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
10+use std::{io, sync::Mutex, time::Duration};
11+use tauri::{
12+ Manager,
13+ http::{Response, StatusCode, header::HeaderValue},
14+};
15+
16+struct UploadResource {
17+ upload: Mutex<UploadSlot>,
18+}
19+
20+impl tauri::Resource for UploadResource {}
21+
22+#[tauri::command]
23+pub async fn begin_media<R: tauri::Runtime>(
24+ webview: tauri::Webview<R>,
25+ mime: String,
26+) -> Result<tauri::ResourceId, String> {
27+ let mime = HeaderValue::from_str(&mime).map_err(|error| error.to_string())?;
28+ let upload = UploadSlot::new(mime).map_err(|error| error.to_string())?;
29+
30+ Ok(webview.resources_table().add(UploadResource {
31+ upload: Mutex::new(upload),
32+ }))
33+}
34+
35+#[tauri::command]
36+pub async fn write_media<R: tauri::Runtime>(
37+ webview: tauri::Webview<R>,
38+ request: tauri::ipc::Request<'_>,
39+) -> Result<(), String> {
40+ let rid = request
41+ .headers()
42+ .get("X-Media-Id")
43+ .and_then(|value| value.to_str().ok())
44+ .and_then(|value| value.parse::<tauri::ResourceId>().ok())
45+ .ok_or("Missing media resource ID")?;
46+
47+ let tauri::ipc::InvokeBody::Raw(data) = request.body() else {
48+ return Err("Expected binary media data".into());
49+ };
50+
51+ let resource = webview
52+ .resources_table()
53+ .get::<UploadResource>(rid)
54+ .map_err(|error| error.to_string())?;
55+
56+ resource
57+ .upload
58+ .lock()
59+ .unwrap()
60+ .write(data)
61+ .map_err(|error| error.to_string())
62+}
63+
64+#[tauri::command]
65+pub async fn finish_media<R: tauri::Runtime>(
66+ cache: tauri::State<'_, MediaCache>,
67+ webview: tauri::Webview<R>,
68+ rid: tauri::ResourceId,
69+) -> Result<String, String> {
70+ let resource = webview
71+ .resources_table()
72+ .take::<UploadResource>(rid)
73+ .map_err(|error| error.to_string())?;
74+
75+ let resource = std::sync::Arc::try_unwrap(resource)
76+ .map_err(|_| "Media upload is still in use".to_owned())?;
77+
78+ resource
79+ .upload
80+ .into_inner()
81+ .unwrap()
82+ .finish(&cache)
83+ .map_err(|error| error.to_string())
84+}
85+
86+#[tauri::command]
87+pub async fn has_media(
88+ cache: tauri::State<'_, MediaCache>,
89+ algorithm: String,
90+ hex: String,
91+) -> Result<Option<String>, String> {
92+ if !matches!(algorithm.as_str(), "sha-1" | "sha-256") {
93+ return Ok(None);
94+ }
95+
96+ let hash = MediaHash::from_hex(&algorithm, &hex).map_err(|error| error.to_string())?;
97+ cache.has(&hash).map_err(|error| error.to_string())
98+}
99+
100+#[tauri::command]
101+pub async fn remove_media(
102+ cache: tauri::State<'_, MediaCache>,
103+ algorithm: String,
104+ hex: String,
105+) -> Result<(), String> {
106+ if !matches!(algorithm.as_str(), "sha-1" | "sha-256") {
107+ return Ok(());
108+ }
109+
110+ let hash = MediaHash::from_hex(&algorithm, &hex).map_err(|error| error.to_string())?;
111+ cache.remove(&hash).map_err(|error| error.to_string())
112+}
113+
114+// Only intercept navigations to this app's media URLs, never resource requests.
115+pub fn open_navigation<R: tauri::Runtime>(app: &tauri::AppHandle<R>, url: &tauri::Url) -> bool {
116+ // Windows/Android use http(s)://tauri.localhost; macOS/Linux use tauri://localhost.
117+ // https://docs.rs/tauri/latest/tauri/struct.Builder.html#method.register_uri_scheme_protocol
118+ let local = (url.scheme() == "tauri" && url.host_str() == Some("localhost"))
119+ || (matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost"))
120+ || (cfg!(debug_assertions)
121+ && app
122+ .config()
123+ .build
124+ .dev_url
125+ .as_ref()
126+ .is_some_and(|dev| dev.origin() == url.origin()));
127+ if !local {
128+ return false;
129+ }
130+ let Some(hash) = parse_path(url.path()) else {
131+ return false;
132+ };
133+ let name = url
134+ .query_pairs()
135+ .find_map(|(key, value)| (key == "name").then(|| value.into_owned()));
136+ let app = app.clone();
137+ tauri::async_runtime::spawn(async move {
138+ use tauri_plugin_opener::OpenerExt;
139+ let cache = app.state::<MediaCache>();
140+ let result = cache
141+ .prepare_open(&hash, name.as_deref())
142+ .map_err(|error| error.to_string())
143+ .and_then(|file| {
144+ app.opener()
145+ .open_path(file.to_string_lossy(), None::<&str>)
146+ .map_err(|e| e.to_string())
147+ });
148+ if let Err(error) = result {
149+ log::error!("Could not open cached attachment: {error}");
150+ }
151+ });
152+ true
153+}
154+
155+fn parse_path(path: &str) -> Option<MediaHash> {
156+ let (algorithm, encoded) = path.strip_prefix("/.well-known/ni/")?.split_once('/')?;
157+ let bytes = URL_SAFE_NO_PAD.decode(encoded).ok()?;
158+ MediaHash::from_bytes(algorithm, &bytes)
159+}
160+
161+fn error_response(status: StatusCode) -> Response<Vec<u8>> {
162+ Response::builder().status(status).body(vec![]).unwrap()
163+}
164+
165+pub fn protocol<R: tauri::Runtime>(
166+ context: tauri::UriSchemeContext<'_, R>,
167+ request: tauri::http::Request<Vec<u8>>,
168+ responder: tauri::UriSchemeResponder,
169+) {
170+ let app = context.app_handle().clone();
171+ let path = request.uri().path().to_owned();
172+ let head = request.method() == tauri::http::Method::HEAD;
173+ if path.starts_with("/.well-known/ni/") {
174+ let Some(hash) = parse_path(&path) else {
175+ responder.respond(error_response(StatusCode::BAD_REQUEST));
176+ return;
177+ };
178+ if !head && request.method() != tauri::http::Method::GET {
179+ responder.respond(error_response(StatusCode::METHOD_NOT_ALLOWED));
180+ return;
181+ }
182+ tauri::async_runtime::spawn(async move {
183+ let cache = app.state::<MediaCache>();
184+ match wait_response(&cache, hash, head, Duration::from_secs(5)).await {
185+ Ok(response) => responder.respond(response),
186+ Err(error) => {
187+ log::error!("Failed to serve cached media: {error}");
188+ responder.respond(error_response(StatusCode::INTERNAL_SERVER_ERROR));
189+ }
190+ }
191+ });
192+ } else {
193+ tauri::async_runtime::spawn_blocking(move || {
194+ if let Some(asset) = app
195+ .asset_resolver()
196+ .get_for_scheme(path, request.uri().scheme_str() == Some("https"))
197+ {
198+ let mut response = Response::builder().header("Content-Type", asset.mime_type);
199+ if let Some(csp) = asset.csp_header {
200+ response = response.header("Content-Security-Policy", csp);
201+ }
202+ responder.respond(
203+ response
204+ .body(if head { vec![] } else { asset.bytes })
205+ .unwrap(),
206+ );
207+ } else {
208+ responder.respond(error_response(StatusCode::NOT_FOUND));
209+ }
210+ });
211+ }
212+}
213+
214+async fn wait_response(
215+ cache: &MediaCache,
216+ hash: MediaHash,
217+ head: bool,
218+ interval: Duration,
219+) -> Result<Response<Vec<u8>>, String> {
220+ loop {
221+ match cache.response(&hash, head) {
222+ Ok(response) => return Ok(response),
223+ Err(error) if error.kind() == io::ErrorKind::NotFound => {}
224+ Err(error) => return Err(error.to_string()),
225+ }
226+ tokio::time::sleep(interval).await;
227+ }
228+}
229+
230+#[cfg(test)]
231+mod tests {
232+ use super::*;
233+ use std::sync::Arc;
234+
235+ const SHA256: &str = "b6acae78c76442a73a94762eb91711a59c642ac3cfdaad636a061bdb7fad6975";
236+ const SHA1: &str = "daa728737f5b69b8f46dd7df156ee770e738759c";
237+ const DATA: &[u8] = b"media bytes";
238+
239+ fn hash(algorithm: &str, hex: &str) -> MediaHash {
240+ MediaHash::from_hex(algorithm, hex).unwrap()
241+ }
242+
243+ fn hash_url(algorithm: &str, hex: &str) -> String {
244+ hash(algorithm, hex).url()
245+ }
246+
247+ struct Fixture {
248+ cache: Arc<MediaCache>,
249+ _directory: tempfile::TempDir,
250+ }
251+ impl Fixture {
252+ fn new() -> Self {
253+ let directory = tempfile::tempdir().unwrap();
254+ Self {
255+ cache: Arc::new(MediaCache::new(directory.path().to_path_buf()).unwrap()),
256+ _directory: directory,
257+ }
258+ }
259+ fn begin(&self, mime: String) -> io::Result<UploadSlot> {
260+ UploadSlot::new(
261+ HeaderValue::from_str(&mime)
262+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?,
263+ )
264+ }
265+
266+ fn publish(&self, mime: &str) {
267+ let mut upload = self.begin(mime.into()).unwrap();
268+ upload.write(&DATA[..3]).unwrap();
269+ upload.write(&DATA[3..]).unwrap();
270+ assert_eq!(
271+ upload.finish(&self.cache).unwrap(),
272+ hash_url("sha-256", SHA256)
273+ );
274+ }
275+ }
276+
277+ #[test]
278+ fn finish_consumes_resource() {
279+ let fixture = Fixture::new();
280+ let mut resources = tauri::ResourceTable::default();
281+ let rid = resources.add(UploadResource {
282+ upload: Mutex::new(fixture.begin("image/png".into()).unwrap()),
283+ });
284+ resources
285+ .get::<UploadResource>(rid)
286+ .unwrap()
287+ .upload
288+ .lock()
289+ .unwrap()
290+ .write(DATA)
291+ .unwrap();
292+ let resource = Arc::into_inner(resources.take::<UploadResource>(rid).unwrap()).unwrap();
293+ let url = resource
294+ .upload
295+ .into_inner()
296+ .unwrap()
297+ .finish(&fixture.cache)
298+ .unwrap();
299+ assert_eq!(url, hash_url("sha-256", SHA256));
300+ assert!(resources.get::<UploadResource>(rid).is_err());
301+ assert!(resources.take::<UploadResource>(rid).is_err());
302+ assert!(!resources.has(rid));
303+ assert_eq!(
304+ fixture.cache.has(&hash("sha-1", SHA1)).unwrap(),
305+ Some(hash_url("sha-1", SHA1))
306+ );
307+ }
308+
309+ #[test]
310+ fn empty_and_interleaved_uploads_have_independent_hashes() {
311+ let fixture = Fixture::new();
312+ let empty = fixture.begin("application/octet-stream".into()).unwrap();
313+ let mut first = fixture.begin("image/png".into()).unwrap();
314+ let mut second = fixture.begin("image/png".into()).unwrap();
315+ first.write(&DATA[..3]).unwrap();
316+ second.write(DATA).unwrap();
317+ first.write(&DATA[3..]).unwrap();
318+ assert_eq!(
319+ first.finish(&fixture.cache).unwrap(),
320+ second.finish(&fixture.cache).unwrap()
321+ );
322+ let empty_hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
323+ assert_eq!(
324+ empty.finish(&fixture.cache).unwrap(),
325+ hash_url("sha-256", empty_hex)
326+ );
327+ let response = fixture
328+ .cache
329+ .response(
330+ &hash("sha-1", "da39a3ee5e6b4b0d3255bfef95601890afd80709"),
331+ false,
332+ )
333+ .unwrap();
334+ assert!(response.body().is_empty());
335+ assert_eq!(
336+ fixture.cache.has(&hash("sha-1", SHA1)).unwrap(),
337+ Some(hash_url("sha-1", SHA1))
338+ );
339+ }
340+
341+ #[test]
342+ fn validates_paths_and_publication() {
343+ let fixture = Fixture::new();
344+ for path in [
345+ "/etc/passwd",
346+ "/.well-known/ni/sha-256/../../etc/passwd",
347+ "/.well-known/ni/sha-1/../file",
348+ "/.well-known/ni/sha-1/AA",
349+ "/.well-known/ni/sha-512/AA",
350+ "/.well-known/ni/sha-256/%2e%2e",
351+ "/.well-known/ni/sha-1/",
352+ ] {
353+ assert!(parse_path(path).is_none(), "{path}");
354+ }
355+ for (algorithm, hex, length) in [("sha-1", SHA1, 20), ("sha-256", SHA256, 32)] {
356+ let bytes: Vec<u8> = (0..length)
357+ .map(|i| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).unwrap())
358+ .collect();
359+ assert_eq!(
360+ parse_path(&format!(
361+ "/.well-known/ni/{algorithm}/{}",
362+ URL_SAFE_NO_PAD.encode(bytes)
363+ )),
364+ Some(hash(algorithm, hex))
365+ );
366+ }
367+ assert!(fixture.begin("image/png\r\nX-Test: bad".into()).is_err());
368+ }
369+
370+ #[test]
371+ fn pending_request_loads_after_publication() {
372+ let fixture = Fixture::new();
373+ tauri::async_runtime::block_on(async {
374+ let cache = fixture.cache.clone();
375+ let pending = tauri::async_runtime::spawn(async move {
376+ wait_response(
377+ &cache,
378+ hash("sha-1", SHA1),
379+ false,
380+ Duration::from_millis(10),
381+ )
382+ .await
383+ });
384+ tokio::time::sleep(Duration::from_millis(30)).await;
385+ assert!(!pending.inner().is_finished());
386+ fixture.publish("image/png");
387+ let response = tokio::time::timeout(Duration::from_secs(2), pending)
388+ .await
389+ .unwrap()
390+ .unwrap()
391+ .unwrap();
392+ assert_eq!(response.body(), DATA);
393+ });
394+ }
395+}
+79 -0 src-tauri/src/media/hash.rs #
......@@ -0,0 +1,79 @@
1+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2+use std::io;
3+
4+#[cfg_attr(test, derive(Debug, PartialEq))]
5+pub(super) enum MediaHash {
6+ Sha1([u8; 20]),
7+ Sha256([u8; 32]),
8+}
9+
10+impl MediaHash {
11+ pub(super) fn from_bytes(algorithm: &str, bytes: &[u8]) -> Option<Self> {
12+ match algorithm {
13+ "sha-1" => Some(Self::Sha1(bytes.try_into().ok()?)),
14+ "sha-256" => Some(Self::Sha256(bytes.try_into().ok()?)),
15+ _ => None,
16+ }
17+ }
18+
19+ pub(super) fn from_hex(algorithm: &str, hex: &str) -> io::Result<Self> {
20+ let bytes =
21+ hex::decode(hex).map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
22+ Self::from_bytes(algorithm, &bytes)
23+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid media hash"))
24+ }
25+
26+ fn bytes(&self) -> &[u8] {
27+ match self {
28+ Self::Sha1(bytes) => bytes,
29+ Self::Sha256(bytes) => bytes,
30+ }
31+ }
32+
33+ pub(super) fn hex(&self) -> String {
34+ hex::encode(self.bytes())
35+ }
36+
37+ pub(super) fn url(&self) -> String {
38+ let algorithm = match self {
39+ Self::Sha1(_) => "sha-1",
40+ Self::Sha256(_) => "sha-256",
41+ };
42+ format!(
43+ "/.well-known/ni/{algorithm}/{}",
44+ URL_SAFE_NO_PAD.encode(self.bytes())
45+ )
46+ }
47+}
48+
49+#[cfg(test)]
50+mod tests {
51+ use super::*;
52+ #[test]
53+ fn validates_external_algorithms_and_hashes() {
54+ assert!(MediaHash::from_hex("sha-512", "00").is_err());
55+ for algorithm in ["sha-1", "sha-256"] {
56+ for hex in ["00".to_owned(), "../outside".to_owned(), "g".repeat(64)] {
57+ assert!(MediaHash::from_hex(algorithm, &hex).is_err());
58+ }
59+ }
60+ }
61+ #[test]
62+ fn hashes_round_trip_and_generate_urls() {
63+ for (algorithm, length, encoded) in [
64+ ("sha-1", 20, "AAAAAAAAAAAAAAAAAAAAAAAAAAA"),
65+ ("sha-256", 32, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
66+ ] {
67+ let hex = "00".repeat(length);
68+ let hash = MediaHash::from_hex(algorithm, &hex).unwrap();
69+ assert_eq!(
70+ hash,
71+ MediaHash::from_bytes(algorithm, &vec![0; length]).unwrap()
72+ );
73+ assert_eq!(hash.hex(), hex);
74+ assert_eq!(hash.url(), format!("/.well-known/ni/{algorithm}/{encoded}"));
75+ assert!(MediaHash::from_bytes(algorithm, &vec![0; length - 1]).is_none());
76+ assert!(MediaHash::from_bytes(algorithm, &vec![0; length + 1]).is_none());
77+ }
78+ }
79+}
+439 -0 src-tauri/src/media/store.rs #
......@@ -0,0 +1,439 @@
1+use super::hash::MediaHash;
2+use std::{fs, io, path::PathBuf};
3+use tauri::http::{Response, header::HeaderValue};
4+
5+pub struct MediaCache {
6+ root: PathBuf,
7+ tmp_dir: PathBuf,
8+}
9+
10+struct ResolvedMedia {
11+ root: PathBuf,
12+ sha256: MediaHash,
13+}
14+
15+impl ResolvedMedia {
16+ fn hex(&self) -> String {
17+ self.sha256.hex()
18+ }
19+
20+ fn media_path(&self) -> PathBuf {
21+ self.root.join("files").join(format!("f{}", self.hex()))
22+ }
23+
24+ fn mime_path(&self) -> PathBuf {
25+ self.root.join("mime").join(format!("{}.mime", self.hex()))
26+ }
27+
28+ fn alias_path(&self, hash: &MediaHash) -> PathBuf {
29+ self.root.join("aliases").join(hash.hex())
30+ }
31+
32+ fn read(&self) -> io::Result<Vec<u8>> {
33+ fs::read(self.media_path())
34+ }
35+
36+ fn mime(&self) -> io::Result<String> {
37+ fs::read_to_string(self.mime_path())
38+ }
39+
40+ fn write_mime(&self, mime: &HeaderValue) -> io::Result<()> {
41+ fs::write(self.mime_path(), mime.as_bytes())
42+ }
43+
44+ fn write_alias(&self, hash: &MediaHash) -> io::Result<()> {
45+ fs::write(self.alias_path(hash), self.hex())
46+ }
47+
48+ fn exists(&self) -> io::Result<bool> {
49+ Ok(self.media_path().try_exists()? && self.mime_path().try_exists()?)
50+ }
51+
52+ fn remove(&self) -> io::Result<()> {
53+ self.remove_file(self.media_path())?;
54+ self.remove_file(self.mime_path())
55+ }
56+
57+ fn remove_file(&self, path: PathBuf) -> io::Result<()> {
58+ match fs::remove_file(path) {
59+ Ok(()) => Ok(()),
60+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
61+ Err(error) => Err(error),
62+ }
63+ }
64+}
65+
66+impl MediaCache {
67+ pub(super) fn prepare_open(&self, hash: &MediaHash, name: Option<&str>) -> io::Result<PathBuf> {
68+ let media = self.resolve(hash)?;
69+ let mime = media.mime()?;
70+ let mime = mime.split(';').next().unwrap().trim();
71+ let source = media.media_path();
72+ let name = name
73+ .map(sanitize_filename::sanitize)
74+ .filter(|name| !name.is_empty());
75+ let name = if let Some(name) = name {
76+ name
77+ } else {
78+ let mime_extension = mime2ext::mime2ext(mime);
79+ match mime_extension {
80+ Some("bin") | None => infer::get_from_path(&source)?
81+ .map(|kind| kind.extension())
82+ .or(mime_extension),
83+ known => known,
84+ }
85+ .map_or_else(
86+ || "attachment".to_owned(),
87+ |ext| format!("attachment.{ext}"),
88+ )
89+ };
90+ let directory = self.tmp_dir.join(media.hex());
91+ fs::create_dir_all(&directory)?;
92+ let destination = directory.join(name);
93+ fs::copy(source, &destination)?;
94+ Ok(destination)
95+ }
96+
97+ pub fn new(root: PathBuf) -> io::Result<Self> {
98+ for directory in ["files", "mime", "aliases"] {
99+ fs::create_dir_all(root.join(directory))?;
100+ }
101+ Ok(Self {
102+ root,
103+ tmp_dir: std::env::temp_dir().join("cheogram"),
104+ })
105+ }
106+
107+ fn resolve(&self, hash: &MediaHash) -> io::Result<ResolvedMedia> {
108+ let sha256 = match hash {
109+ MediaHash::Sha256(bytes) => MediaHash::Sha256(*bytes),
110+ MediaHash::Sha1(_) => {
111+ let alias = fs::read_to_string(self.root.join("aliases").join(hash.hex()))?;
112+ MediaHash::from_hex("sha-256", &alias)
113+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
114+ }
115+ };
116+ Ok(ResolvedMedia {
117+ root: self.root.clone(),
118+ sha256,
119+ })
120+ }
121+
122+ pub(super) fn publish(
123+ &self,
124+ temporary: tempfile::TempPath,
125+ mime: &HeaderValue,
126+ sha256: MediaHash,
127+ sha1: &MediaHash,
128+ ) -> io::Result<()> {
129+ let media = ResolvedMedia {
130+ root: self.root.clone(),
131+ sha256,
132+ };
133+ fs::copy(&temporary, media.media_path())?;
134+ media.write_mime(mime)?;
135+ media.write_alias(sha1)?;
136+ Ok(())
137+ }
138+
139+ pub(super) fn has(&self, hash: &MediaHash) -> io::Result<Option<String>> {
140+ match self.resolve(hash) {
141+ Ok(media) => media.exists().map(|found| found.then(|| hash.url())),
142+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
143+ Err(error) => Err(error),
144+ }
145+ }
146+
147+ pub(super) fn remove(&self, hash: &MediaHash) -> io::Result<()> {
148+ match self.resolve(hash) {
149+ Ok(media) => media.remove(),
150+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
151+ Err(error) => Err(error),
152+ }
153+ }
154+
155+ pub(super) fn response(&self, hash: &MediaHash, head: bool) -> io::Result<Response<Vec<u8>>> {
156+ let media = self.resolve(hash)?;
157+ let mime = media.mime()?;
158+ let bytes = media.read()?;
159+ Response::builder()
160+ .header("Content-Type", mime)
161+ .header("Content-Length", bytes.len())
162+ .body(if head { vec![] } else { bytes })
163+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
164+ }
165+}
166+
167+#[cfg(test)]
168+mod tests {
169+ use std::fs;
170+
171+ use super::*;
172+ use crate::media::upload_slot::UploadSlot;
173+
174+ const SHA256: &str = "b6acae78c76442a73a94762eb91711a59c642ac3cfdaad636a061bdb7fad6975";
175+ const SHA1: &str = "daa728737f5b69b8f46dd7df156ee770e738759c";
176+ const DATA: &[u8] = b"media bytes";
177+
178+ fn hash(algorithm: &str, hex: &str) -> MediaHash {
179+ MediaHash::from_hex(algorithm, hex).unwrap()
180+ }
181+
182+ fn hash_url(algorithm: &str, hex: &str) -> String {
183+ hash(algorithm, hex).url()
184+ }
185+
186+ struct Fixture {
187+ cache: MediaCache,
188+ _directory: tempfile::TempDir,
189+ }
190+ impl Fixture {
191+ fn new() -> Self {
192+ let directory = tempfile::tempdir().unwrap();
193+ let mut cache = MediaCache::new(directory.path().join("cache")).unwrap();
194+ assert_eq!(cache.tmp_dir, std::env::temp_dir().join("cheogram"));
195+ cache.tmp_dir = directory.path().join("open");
196+ Self {
197+ cache,
198+ _directory: directory,
199+ }
200+ }
201+ fn begin(&self, mime: String) -> io::Result<UploadSlot> {
202+ UploadSlot::new(
203+ HeaderValue::from_str(&mime)
204+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?,
205+ )
206+ }
207+
208+ fn publish(&self, mime: &str) {
209+ let mut upload = self.begin(mime.into()).unwrap();
210+ upload.write(&DATA[..3]).unwrap();
211+ upload.write(&DATA[3..]).unwrap();
212+ assert_eq!(
213+ upload.finish(&self.cache).unwrap(),
214+ hash_url("sha-256", SHA256)
215+ );
216+ }
217+ }
218+
219+ #[test]
220+ fn invalid_mime_sidecars_return_io_errors() {
221+ let fixture = Fixture::new();
222+ fixture.publish("image/png");
223+ let media = fixture.cache.resolve(&hash("sha-256", SHA256)).unwrap();
224+ for bytes in [b"image/png\r\nX-Test: bad".as_slice(), &[0xff]] {
225+ fs::write(media.mime_path(), bytes).unwrap();
226+ let error = fixture
227+ .cache
228+ .response(&hash("sha-1", SHA1), false)
229+ .unwrap_err();
230+ assert_eq!(error.kind(), io::ErrorKind::InvalidData);
231+ }
232+ }
233+
234+ #[test]
235+ fn opening_copies_aliases_with_mime_extensions() {
236+ let fixture = Fixture::new();
237+ fixture.publish("image/png");
238+ let path = fixture
239+ .cache
240+ .prepare_open(&hash("sha-1", SHA1), None)
241+ .unwrap();
242+ assert_eq!(path.extension().unwrap(), "png");
243+ assert!(path.starts_with(fixture.cache.tmp_dir.clone()));
244+ assert_eq!(fs::read(&path).unwrap(), DATA);
245+ fs::write(&path, b"edited").unwrap();
246+ assert_eq!(
247+ fs::read(fixture.cache.root.join("files").join(format!("f{SHA256}"))).unwrap(),
248+ DATA
249+ );
250+ }
251+
252+ #[test]
253+ fn reopening_reuses_the_path_and_restores_cached_contents() {
254+ let fixture = Fixture::new();
255+ fixture.publish("image/png");
256+ let first = fixture
257+ .cache
258+ .prepare_open(&hash("sha-1", SHA1), Some("photo.png"))
259+ .unwrap();
260+ fs::write(&first, b"edited").unwrap();
261+ let second = fixture
262+ .cache
263+ .prepare_open(&hash("sha-256", SHA256), Some("photo.png"))
264+ .unwrap();
265+ assert_eq!(first, second);
266+ assert_eq!(fs::read(second).unwrap(), DATA);
267+ }
268+
269+ #[test]
270+ fn opening_uses_the_original_filename_or_falls_back_to_mime() {
271+ let fixture = Fixture::new();
272+ fixture.publish("image/png");
273+ let hash = hash("sha-256", SHA256);
274+ for name in ["photo.jpg", "résumé + notes & stuff #1.pdf", "notes"] {
275+ let path = fixture.cache.prepare_open(&hash, Some(name)).unwrap();
276+ assert_eq!(path.file_name().unwrap(), name);
277+ assert_eq!(
278+ path.parent().unwrap().parent().unwrap(),
279+ fixture.cache.tmp_dir.clone()
280+ );
281+ assert_eq!(fs::read(&path).unwrap(), DATA);
282+ }
283+ for (name, expected) in [
284+ ("", "attachment.png"),
285+ (".", "attachment.png"),
286+ ("..", "attachment.png"),
287+ ("/", "attachment.png"),
288+ ("../outside.png", "..outside.png"),
289+ ("/outside.png", "outside.png"),
290+ ("..\\outside.png", "..outside.png"),
291+ ("bad\0.png", "bad.png"),
292+ ("C:photo.png", "Cphoto.png"),
293+ ] {
294+ let path = fixture.cache.prepare_open(&hash, Some(name)).unwrap();
295+ assert_eq!(path.file_name().unwrap(), expected);
296+ assert_eq!(
297+ path.parent().unwrap().parent().unwrap(),
298+ fixture.cache.tmp_dir.clone()
299+ );
300+ assert_eq!(fs::read(&path).unwrap(), DATA);
301+ }
302+ }
303+
304+ #[test]
305+ fn opening_generic_binary_detects_content_or_uses_bin() {
306+ let fixture = Fixture::new();
307+ fixture.publish("application/octet-stream");
308+ let path = fixture
309+ .cache
310+ .prepare_open(&hash("sha-256", SHA256), None)
311+ .unwrap();
312+ assert_eq!(path.extension().unwrap(), "bin");
313+
314+ let mut upload = fixture.begin("application/octet-stream".into()).unwrap();
315+ upload.write(b"%PDF-1.7\n").unwrap();
316+ let url = upload.finish(&fixture.cache).unwrap();
317+ let hash = crate::media::parse_path(&url).unwrap();
318+ let path = fixture.cache.prepare_open(&hash, None).unwrap();
319+ assert_eq!(path.extension().unwrap(), "pdf");
320+ }
321+
322+ #[test]
323+ fn opening_prefers_mime_and_detects_content_for_unknown_mime() {
324+ let fixture = Fixture::new();
325+ for (mime, extension) in [("image/png", "png"), ("application/x-unknown", "pdf")] {
326+ let mut upload = fixture.begin(mime.into()).unwrap();
327+ upload.write(b"%PDF-1.7\n").unwrap();
328+ let url = upload.finish(&fixture.cache).unwrap();
329+ let hash = crate::media::parse_path(&url).unwrap();
330+ let path = fixture.cache.prepare_open(&hash, None).unwrap();
331+ assert_eq!(path.extension().unwrap(), extension);
332+ }
333+ }
334+
335+ #[test]
336+ fn opening_jpeg_uses_jpg_instead_of_jfif() {
337+ let fixture = Fixture::new();
338+ fixture.publish("image/jpeg; charset=binary");
339+ let path = fixture
340+ .cache
341+ .prepare_open(&hash("sha-256", SHA256), None)
342+ .unwrap();
343+ assert_eq!(path.file_name().unwrap(), "attachment.jpg");
344+ assert_eq!(fs::read(path).unwrap(), DATA);
345+ }
346+
347+ #[test]
348+ fn serves_aliases_and_mime_after_restart() {
349+ let fixture = Fixture::new();
350+ fixture.publish("image/svg+xml");
351+ let reopened = MediaCache::new(fixture.cache.root.clone()).unwrap();
352+ for (algorithm, hex) in [("sha-1", SHA1), ("sha-256", SHA256)] {
353+ assert_eq!(
354+ reopened.has(&hash(algorithm, hex)).unwrap(),
355+ Some(hash_url(algorithm, hex))
356+ );
357+ let response = reopened.response(&hash(algorithm, hex), false).unwrap();
358+ assert_eq!(response.body(), DATA);
359+ assert_eq!(response.headers()["content-type"], "image/svg+xml");
360+ assert_eq!(response.headers()["content-length"], DATA.len().to_string());
361+ }
362+ let head = reopened.response(&hash("sha-1", SHA1), true).unwrap();
363+ assert!(head.body().is_empty());
364+ assert_eq!(head.headers()["content-length"], DATA.len().to_string());
365+ }
366+
367+ #[test]
368+ fn duplicates_and_deletion_work_for_both_algorithms() {
369+ for algorithm in ["sha-1", "sha-256"] {
370+ let fixture = Fixture::new();
371+ fixture.publish("image/png");
372+ fixture.publish("application/octet-stream");
373+ let response = fixture.cache.response(&hash("sha-1", SHA1), false).unwrap();
374+ assert_eq!(response.body(), DATA);
375+ assert_eq!(
376+ response.headers()["content-type"],
377+ "application/octet-stream"
378+ );
379+ let hex = if algorithm == "sha-1" { SHA1 } else { SHA256 };
380+ fixture.cache.remove(&hash(algorithm, hex)).unwrap();
381+ fixture.cache.remove(&hash(algorithm, hex)).unwrap();
382+ assert_eq!(fixture.cache.has(&hash("sha-1", SHA1)).unwrap(), None);
383+ assert_eq!(fixture.cache.has(&hash("sha-256", SHA256)).unwrap(), None);
384+ assert!(
385+ !fixture
386+ .cache
387+ .root
388+ .join("mime")
389+ .join(format!("{SHA256}.mime"))
390+ .exists()
391+ );
392+ }
393+ }
394+
395+ #[test]
396+ fn missing_or_evicted_files_and_old_files_without_mime_are_misses() {
397+ let fixture = Fixture::new();
398+ assert!(
399+ fixture
400+ .cache
401+ .response(&hash("sha-1", SHA1), false)
402+ .is_err_and(|error| error.kind() == io::ErrorKind::NotFound)
403+ );
404+ let path = fixture.cache.root.join("files").join(format!("f{SHA256}"));
405+ fs::write(&path, DATA).unwrap();
406+ // Old JSON sidecars are ignored; the next download writes a MIME sidecar.
407+ fs::create_dir(fixture.cache.root.join("metadata")).unwrap();
408+ fs::write(
409+ fixture
410+ .cache
411+ .root
412+ .join("metadata")
413+ .join(format!("{SHA256}.json")),
414+ br#"{"contentType":"image/png"}"#,
415+ )
416+ .unwrap();
417+ assert_eq!(fixture.cache.has(&hash("sha-256", SHA256)).unwrap(), None);
418+ fixture.publish("image/png");
419+ fs::remove_file(path).unwrap();
420+ assert_eq!(fixture.cache.has(&hash("sha-1", SHA1)).unwrap(), None);
421+ assert!(
422+ fixture
423+ .cache
424+ .response(&hash("sha-256", SHA256), false)
425+ .is_err_and(|error| error.kind() == io::ErrorKind::NotFound)
426+ );
427+ }
428+
429+ #[test]
430+ fn validates_disk_aliases() {
431+ let fixture = Fixture::new();
432+ fs::write(
433+ fixture.cache.root.join("aliases").join(SHA1),
434+ "../../outside",
435+ )
436+ .unwrap();
437+ assert!(fixture.cache.has(&hash("sha-1", SHA1)).is_err());
438+ }
439+}
+132 -0 src-tauri/src/media/upload_slot.rs #
......@@ -0,0 +1,132 @@
1+use super::{hash::MediaHash, store::MediaCache};
2+use sha1::Sha1;
3+use sha2::{Digest, Sha256};
4+use std::io::{self, Write};
5+use tauri::http::header::HeaderValue;
6+use tempfile::NamedTempFile;
7+
8+pub(super) struct UploadSlot {
9+ temporary: NamedTempFile,
10+ mime: HeaderValue,
11+ sha256: Sha256,
12+ sha1: Sha1,
13+}
14+
15+impl UploadSlot {
16+ pub(super) fn new(mime: HeaderValue) -> io::Result<Self> {
17+ let temporary = NamedTempFile::new()?;
18+ Ok(Self {
19+ temporary,
20+ mime,
21+ sha256: Sha256::new(),
22+ sha1: Sha1::new(),
23+ })
24+ }
25+
26+ pub(super) fn write(&mut self, data: &[u8]) -> io::Result<()> {
27+ self.temporary.write_all(data)?;
28+ self.sha256.update(data);
29+ self.sha1.update(data);
30+ Ok(())
31+ }
32+
33+ pub(super) fn finish(self, cache: &MediaCache) -> io::Result<String> {
34+ let sha256 = MediaHash::Sha256(self.sha256.finalize().into());
35+ let sha1 = MediaHash::Sha1(self.sha1.finalize().into());
36+ let url = sha256.url();
37+ cache.publish(self.temporary.into_temp_path(), &self.mime, sha256, &sha1)?;
38+ Ok(url)
39+ }
40+}
41+
42+#[cfg(test)]
43+mod tests {
44+ use super::*;
45+ use std::fs;
46+
47+ const SHA256: &str = "b6acae78c76442a73a94762eb91711a59c642ac3cfdaad636a061bdb7fad6975";
48+ const SHA1: &str = "daa728737f5b69b8f46dd7df156ee770e738759c";
49+ const DATA: &[u8] = b"media bytes";
50+
51+ fn hash(algorithm: &str, hex: &str) -> MediaHash {
52+ MediaHash::from_hex(algorithm, hex).unwrap()
53+ }
54+
55+ fn hash_url(algorithm: &str, hex: &str) -> String {
56+ hash(algorithm, hex).url()
57+ }
58+
59+ #[test]
60+ fn upload_stores_plain_text_mime_and_returns_a_url() {
61+ let directory = tempfile::tempdir().unwrap();
62+ let cache = MediaCache::new(directory.path().to_path_buf()).unwrap();
63+ let mime = "text/plain; charset=utf-8";
64+ let mut upload = UploadSlot::new(HeaderValue::from_static(mime)).unwrap();
65+ upload.write(&DATA[..3]).unwrap();
66+ upload.write(&DATA[3..]).unwrap();
67+ let temporary = upload.temporary.path().to_path_buf();
68+ let url = upload.finish(&cache).unwrap();
69+ assert!(!temporary.exists());
70+ assert_eq!(url, hash_url("sha-256", SHA256));
71+ let response = cache.response(&hash("sha-256", SHA256), false).unwrap();
72+ assert_eq!(response.headers()["content-type"], mime);
73+ assert_eq!(response.body(), DATA);
74+ assert_eq!(
75+ fs::read_to_string(directory.path().join("aliases").join(SHA1)).unwrap(),
76+ SHA256
77+ );
78+ }
79+
80+ #[test]
81+ fn write_failure_cleans_up_temporary_file_on_drop() {
82+ let mut upload = UploadSlot::new(HeaderValue::from_static("image/png")).unwrap();
83+ let temporary = upload.temporary.path().to_path_buf();
84+ // Replace the writable handle with a read-only one to force a disk write error.
85+ let read_only = fs::File::open(upload.temporary.path()).unwrap();
86+ *upload.temporary.as_file_mut() = read_only;
87+ assert!(upload.write(DATA).is_err());
88+ drop(upload);
89+ assert!(!temporary.exists());
90+ }
91+ #[test]
92+ fn publication_failures_clean_up_temporary_files() {
93+ for stage in ["files", "mime", "aliases"] {
94+ let directory = tempfile::tempdir().unwrap();
95+ let cache = MediaCache::new(directory.path().to_path_buf()).unwrap();
96+ let mut upload = UploadSlot::new(HeaderValue::from_static("image/png")).unwrap();
97+ upload.write(DATA).unwrap();
98+ let temporary = upload.temporary.path().to_path_buf();
99+ match stage {
100+ // A missing parent prevents content persistence; directories at
101+ // the sidecar destinations prevent writing them.
102+ "files" => fs::remove_dir(directory.path().join("files")).unwrap(),
103+ "mime" => {
104+ fs::create_dir(directory.path().join("mime").join(format!("{SHA256}.mime")))
105+ .unwrap()
106+ }
107+ _ => fs::create_dir(directory.path().join("aliases").join(SHA1)).unwrap(),
108+ }
109+ assert!(upload.finish(&cache).is_err(), "{stage}");
110+ assert!(!temporary.exists(), "{stage}");
111+ }
112+ }
113+
114+ #[test]
115+ fn close_and_resource_drop_remove_partial_uploads() {
116+ for close in [true, false] {
117+ let mut upload = UploadSlot::new(HeaderValue::from_static("image/png")).unwrap();
118+ upload.write(DATA).unwrap();
119+ let temporary = upload.temporary.path().to_path_buf();
120+ let mut resources = tauri::ResourceTable::default();
121+ let rid = resources.add(crate::media::UploadResource {
122+ upload: std::sync::Mutex::new(upload),
123+ });
124+ if close {
125+ resources.close(rid).unwrap();
126+ assert!(!resources.has(rid));
127+ }
128+ drop(resources);
129+ assert!(!temporary.exists());
130+ }
131+ }
132+}
+1 -0 src-tauri/tauri.conf.json #
......@@ -12,6 +12,7 @@
1212 "app": {
1313 "windows": [
1414 {
15+ "create": false,
1516 "title": "Cheogram",
1617 "titleBarStyle": "Transparent",
1718 "backgroundColor": "#7401cf",
+1 -1 src/App.tsx #
......@@ -24,6 +24,7 @@ import { ios, webkit, useEffectEvent, serviceWorker } from "./util";
2424 import { SettingsContext, SettingsData } from "./components/Settings";
2525 import { Call } from "./components/Call";
2626 import { LoginRequest } from "./components/LoginRequest";
27+import { mediaStore } from "./media-store";
2728
2829 Sentry.init({
2930 dsn: "https://ec9ae5aef68e4035937d4c5943c4156c@app.glitchtip.com/7125",
......@@ -40,7 +41,6 @@ Sentry.init({
4041 replaysOnErrorSampleRate: 1.0,
4142 });
4243
43-const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
4444 const idbp = await borogove.persistence.IDB("snikket", mediaStore, null, stem);
4545 let persistence = null;
4646 if (typeof navigator.storage?.getDirectory === "function" && (webkit || ios)) {
+25 -0 src/components/MessageAttachments.test.tsx #
......@@ -70,6 +70,31 @@ function setup(
7070 afterEach(cleanup);
7171
7272 describe("MessageAttachments", () => {
73+ it.each(["image/png", "application/pdf"])(
74+ "passes the original filename on cached %s links",
75+ (mime) => {
76+ const uri = "/.well-known/ni/sha-256/abc";
77+ const name = "résumé + notes & stuff #1.pdf";
78+ const view = setup([
79+ { attachment: { ...attachment(name, uri), mime }, outcomes: [] },
80+ ]);
81+ const url = new URL(
82+ view.getByRole("link").getAttribute("href")!,
83+ location.href,
84+ );
85+ expect(url.pathname).toBe(uri);
86+ expect(url.searchParams.get("name")).toBe(name);
87+ if (mime === "image/png")
88+ expect(view.getByRole("img").getAttribute("src")).toBe(uri);
89+ },
90+ );
91+
92+ it("keeps unnamed cached links unchanged", () => {
93+ const uri = "/.well-known/ni/sha-256/abc";
94+ const view = setup([{ attachment: attachment("", uri), outcomes: [] }]);
95+ expect(view.getByRole("link").getAttribute("href")).toBe(uri);
96+ });
97+
7398 it("fetches a whole batch, hides controls during fetching, and reveals content together", async () => {
7499 const first = attachment("first", null);
75100 const second = attachment("second", null);
+7 -3 src/components/MessageAttachments.tsx #
......@@ -120,11 +120,15 @@ const AttachmentContent = ({
120120 fetchedAttachment: FetchedAttachment;
121121 }) => {
122122 const { uri, embeddable } = fetchedAttachment;
123- if (!embeddable) return <a href={uri}>{attachment.name || "attachment"}</a>;
123+ const href =
124+ attachment.name && uri.startsWith("/.well-known/ni/")
125+ ? `${uri}?name=${encodeURIComponent(attachment.name)}`
126+ : uri;
127+ if (!embeddable) return <a href={href}>{attachment.name || "attachment"}</a>;
124128
125129 if (attachment.mime?.indexOf("image/") === 0) {
126130 return (
127- <a href={uri}>
131+ <a href={href}>
128132 <img
129133 alt={attachment.name || "attachment"}
130134 crossOrigin="anonymous"
......@@ -145,7 +149,7 @@ const AttachmentContent = ({
145149 ></video>
146150 );
147151 } else {
148- return <a href={uri}>{attachment.name || "attachment"}</a>;
152+ return <a href={href}>{attachment.name || "attachment"}</a>;
149153 }
150154 };
151155
+116 -0 src/media-store-tauri.test.ts #
......@@ -0,0 +1,116 @@
1+import { beforeEach, expect, it, vi } from "vitest";
2+import { Hash } from "borogove";
3+import { MediaStoreTauri } from "./media-store-tauri";
4+
5+const native = vi.hoisted(() => ({ invoke: vi.fn() }));
6+vi.mock("@tauri-apps/api/core", () => ({
7+ ...native,
8+ Resource: class {
9+ constructor(readonly rid: number) {}
10+ close() {
11+ return native.invoke("plugin:resources|close", { rid: this.rid });
12+ }
13+ },
14+}));
15+
16+const data = new TextEncoder().encode("media bytes");
17+const sha1 = "daa728737f5b69b8f46dd7df156ee770e738759c";
18+const sha256 =
19+ "b6acae78c76442a73a94762eb91711a59c642ac3cfdaad636a061bdb7fad6975";
20+const url = "/.well-known/ni/sha-256/native-result";
21+const stream = (chunks: Uint8Array[] = [data]) =>
22+ new ReadableStream<Uint8Array>({
23+ start(controller) {
24+ for (const chunk of chunks) controller.enqueue(chunk);
25+ controller.close();
26+ },
27+ });
28+
29+beforeEach(() => {
30+ vi.resetAllMocks();
31+ native.invoke.mockImplementation(async (command) => {
32+ if (command === "begin_media") return 42;
33+ if (command === "finish_media") return url;
34+ if (command === "has_media") return null;
35+ });
36+});
37+
38+it("sends chunk views in order and returns the native URL", async () => {
39+ const store = new MediaStoreTauri();
40+ const padded = new Uint8Array(data.length + 4);
41+ padded.set(data, 2);
42+ const chunks = [padded.subarray(2, 5), padded.subarray(5, data.length + 2)];
43+ const source = stream(chunks);
44+ expect(await store.storeMedia("image/png", source)).toBe(url);
45+ expect(native.invoke.mock.calls).toEqual([
46+ ["begin_media", { mime: "image/png" }],
47+ ["write_media", chunks[0], { headers: { "X-Media-Id": "42" } }],
48+ ["write_media", chunks[1], { headers: { "X-Media-Id": "42" } }],
49+ ["finish_media", { rid: 42 }],
50+ ]);
51+ expect(source.locked).toBe(false);
52+});
53+
54+it("closes the native resource after a write fails", async () => {
55+ const store = new MediaStoreTauri();
56+ const error = new Error("disk full");
57+ const source = stream();
58+ native.invoke.mockResolvedValueOnce(42).mockRejectedValueOnce(error);
59+ await expect(store.storeMedia("image/png", source)).rejects.toBe(error);
60+ expect(source.locked).toBe(false);
61+ expect(native.invoke).toHaveBeenLastCalledWith("plugin:resources|close", {
62+ rid: 42,
63+ });
64+});
65+
66+it("does not close an already consumed resource when finishing fails", async () => {
67+ const store = new MediaStoreTauri();
68+ const error = new Error("publication failed");
69+ native.invoke.mockImplementation(async (command) => {
70+ if (command === "begin_media") return 42;
71+ if (command === "finish_media") throw error;
72+ if (command === "plugin:resources|close")
73+ throw new Error("bad resource ID");
74+ });
75+ await expect(store.storeMedia("image/png", stream())).rejects.toBe(error);
76+ expect(native.invoke).toHaveBeenLastCalledWith("finish_media", { rid: 42 });
77+});
78+
79+it("closes the native resource when the source fails", async () => {
80+ const store = new MediaStoreTauri();
81+ const error = new Error("download failed");
82+ const source = new ReadableStream<Uint8Array>({
83+ start(controller) {
84+ controller.error(error);
85+ },
86+ });
87+ await expect(store.storeMedia("image/png", source)).rejects.toBe(error);
88+ expect(native.invoke.mock.calls).toEqual([
89+ ["begin_media", { mime: "image/png" }],
90+ ["plugin:resources|close", { rid: 42 }],
91+ ]);
92+});
93+
94+it.each(["sha-1", "sha-256"])(
95+ "delegates %s lookup and removal to Rust",
96+ async (algorithm) => {
97+ const store = new MediaStoreTauri();
98+ const hex = algorithm === "sha-1" ? sha1 : sha256;
99+ const hash = Hash.fromHex(algorithm, hex)!;
100+ native.invoke.mockResolvedValueOnce(null).mockResolvedValueOnce(url);
101+ expect(await store.hasMedia(hash)).toBeNull();
102+ expect(await store.hasMedia(hash)).toBe(url);
103+ expect(native.invoke).toHaveBeenLastCalledWith("has_media", {
104+ algorithm,
105+ hex,
106+ });
107+ const bytes = Uint8Array.from(hex.match(/../g)!, (byte) =>
108+ parseInt(byte, 16),
109+ ).buffer;
110+ expect(await store.removeMedia(algorithm, bytes)).toBe(true);
111+ expect(native.invoke).toHaveBeenLastCalledWith("remove_media", {
112+ algorithm,
113+ hex,
114+ });
115+ },
116+);
+44 -0 src/media-store-tauri.ts #
......@@ -0,0 +1,44 @@
1+import { Hash, type persistence } from "borogove";
2+import { invoke, Resource } from "@tauri-apps/api/core";
3+
4+export class MediaStoreTauri implements persistence.MediaStore {
5+ // Instead of using KV we store data on the filesystem because the rust
6+ // implementation doesn't have access to this KV store
7+ setKV(value: persistence.KeyValueStore) {
8+ void value;
9+ }
10+
11+ async storeMedia(mime: string, source: ReadableStream<Uint8Array>) {
12+ const upload = new Resource(await invoke<number>("begin_media", { mime }));
13+ try {
14+ await source.pipeTo(
15+ new WritableStream<Uint8Array>({
16+ write(chunk) {
17+ return invoke("write_media", chunk, {
18+ headers: { "X-Media-Id": String(upload.rid) },
19+ });
20+ },
21+ }),
22+ );
23+ } catch (error) {
24+ await upload.close();
25+ throw error;
26+ }
27+ return await invoke<string>("finish_media", { rid: upload.rid });
28+ }
29+
30+ hasMedia(hash: Hash) {
31+ return invoke<string | null>("has_media", {
32+ algorithm: hash.algorithm,
33+ hex: hash.toHex(),
34+ });
35+ }
36+
37+ async removeMedia(algorithm: string, bytes: ArrayBuffer) {
38+ await invoke("remove_media", {
39+ algorithm,
40+ hex: new Hash(algorithm, bytes).toHex(),
41+ });
42+ return true;
43+ }
44+}
+7 -0 src/media-store.ts #
......@@ -0,0 +1,7 @@
1+import * as borogove from "borogove";
2+import { isTauri } from "@tauri-apps/api/core";
3+import { MediaStoreTauri } from "./media-store-tauri";
4+
5+export const mediaStore = isTauri()
6+ ? new MediaStoreTauri()
7+ : await borogove.persistence.MediaStoreCache("snikket");
Back to top