cheogram-www

created pr with cheogram-www/tauri-media-handling.1 on 2026-10-28T15:15:38Z · by 3c31bc8c
commented on 2026-10-28T15:19:01Z · by 3c31bc8c
The following changes since commit 62ea2af732f009407e7d2de6588cd10657f4e89b: npm run tauri init (2026-10-28 10:28:04 -0400) are available in the Git repository at: https://tangled.org/mrry.band/cheogram-www tauri-media-handling for you to fetch changes up to d1d3a238ce8271bab76def99868cfaf5402e1a54: Tauri file system media store (2026-10-28 11:11:55 -0400) ---------------------------------------------------------------- Eric Roberts (1): Tauri file system media store package.json | 1 + src-tauri/Cargo.lock | 231 +++++++----- src-tauri/Cargo.toml | 9 + src-tauri/src/lib.rs | 50 ++- src-tauri/src/media.rs | 460 ++++++++++++++++++++++ src-tauri/src/media/store.rs | 588 +++++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 1 + src/App.tsx | 2 +- src/components/MessageAttachments.test.tsx | 25 ++ src/components/MessageAttachments.tsx | 10 +- src/media-store-tauri.test.ts | 89 +++++ src/media-store-tauri.ts | 49 +++ src/media-store.ts | 7 + 13 files changed, 1421 insertions(+), 101 deletions(-) create mode 100644 src-tauri/src/media.rs create mode 100644 src-tauri/src/media/store.rs create mode 100644 src/media-store-tauri.test.ts create mode 100644 src/media-store-tauri.ts create mode 100644 src/media-store.ts
added cheogram-www/tauri-media-handling.2 on 2026-10-28T15:44:56Z · by 3c31bc8c
1: d1d3a23 ! 1: 5c9c8ae Tauri file system media store
added cheogram-www/tauri-media-handling.3 on 2026-10-28T16:08:30Z · by 9e834e0d
1: 5c9c8ae < -: ------- Tauri file system media store
-: ------- > 1: 615caaf Review comments
added cheogram-www/tauri-media-handling.4 on 2026-10-28T21:31:45Z · by 3c31bc8c
1: 615caaf < -: ------- Review comments
-: ------- > 1: 58e4016 Tauri file system media store
added cheogram-www/tauri-media-handling.5 on 2026-10-28T21:33:19Z · by 3c31bc8c
1: 58e4016 = 1: b0c5136 Tauri file system media store
added cheogram-www/tauri-media-handling.6 on 2026-10-28T21:33:59Z · by 3c31bc8c
1: b0c5136 ! 1: f6bda1a Tauri file system media store
added cheogram-www/tauri-media-handling.7 on 2026-10-29T14:13:32Z · by 3c31bc8c
1: f6bda1a ! 1: efe5296 Tauri file system media store
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:tauri-media-handling | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:tauri-media-handling [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:tauri-media-handling
add comment to patch request:
ssh pr.pico.sh comment cheogram-www:tauri-media-handling "lgtm!"

Patchset cheogram-www/tauri-media-handling.6 on 2026-10-28T21:33:59Z · commit f6bda1a

Tauri file system media store
Eric Roberts 2026-10-16T18:10:16Z
Semantic diff summary
81 added, 7 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",
+670 -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+ "mime_guess",
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"
1764+source = "registry+https://github.com/rust-lang/crates.io-index"
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"
15241779 source = "registry+https://github.com/rust-lang/crates.io-index"
1525-checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
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,16 @@ version = "0.3.17"
18052070 source = "registry+https://github.com/rust-lang/crates.io-index"
18062071 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
18072072
2073+[[package]]
2074+name = "mime_guess"
2075+version = "2.0.5"
2076+source = "registry+https://github.com/rust-lang/crates.io-index"
2077+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
2078+dependencies = [
2079+ "mime",
2080+ "unicase",
2081+]
2082+
18082083 [[package]]
18092084 name = "miniz_oxide"
18102085 version = "0.8.9"
......@@ -1851,7 +2126,7 @@ dependencies = [
18512126 "objc2-core-foundation",
18522127 "objc2-foundation",
18532128 "once_cell",
1854- "png 0.18.1",
2129+ "png",
18552130 "serde",
18562131 "thiserror 2.0.21",
18572132 "windows-sys 0.61.2",
......@@ -1932,9 +2207,9 @@ dependencies = [
19322207
19332208 [[package]]
19342209 name = "num_threads"
1935-version = "0.1.7"
2210+version = "0.1.8"
19362211 source = "registry+https://github.com/rust-lang/crates.io-index"
1937-checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
2212+checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
19382213 dependencies = [
19392214 "libc",
19402215 ]
......@@ -2141,12 +2416,33 @@ version = "1.21.4"
21412416 source = "registry+https://github.com/rust-lang/crates.io-index"
21422417 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
21432418
2419+[[package]]
2420+name = "open"
2421+version = "5.4.5"
2422+source = "registry+https://github.com/rust-lang/crates.io-index"
2423+checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
2424+dependencies = [
2425+ "dunce",
2426+ "is-wsl",
2427+ "libc",
2428+]
2429+
21442430 [[package]]
21452431 name = "option-ext"
21462432 version = "0.2.0"
21472433 source = "registry+https://github.com/rust-lang/crates.io-index"
21482434 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
21492435
2436+[[package]]
2437+name = "ordered-stream"
2438+version = "0.2.0"
2439+source = "registry+https://github.com/rust-lang/crates.io-index"
2440+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
2441+dependencies = [
2442+ "futures-core",
2443+ "pin-project-lite",
2444+]
2445+
21502446 [[package]]
21512447 name = "pango"
21522448 version = "0.18.3"
......@@ -2172,6 +2468,12 @@ dependencies = [
21722468 "system-deps",
21732469 ]
21742470
2471+[[package]]
2472+name = "parking"
2473+version = "2.2.1"
2474+source = "registry+https://github.com/rust-lang/crates.io-index"
2475+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2476+
21752477 [[package]]
21762478 name = "parking_lot"
21772479 version = "0.12.5"
......@@ -2260,6 +2562,17 @@ version = "0.2.17"
22602562 source = "registry+https://github.com/rust-lang/crates.io-index"
22612563 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
22622564
2565+[[package]]
2566+name = "piper"
2567+version = "0.2.5"
2568+source = "registry+https://github.com/rust-lang/crates.io-index"
2569+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
2570+dependencies = [
2571+ "atomic-waker",
2572+ "fastrand",
2573+ "futures-io",
2574+]
2575+
22632576 [[package]]
22642577 name = "pkg-config"
22652578 version = "0.3.34"
......@@ -2268,9 +2581,9 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
22682581
22692582 [[package]]
22702583 name = "plist"
2271-version = "1.10.1"
2584+version = "1.10.3"
22722585 source = "registry+https://github.com/rust-lang/crates.io-index"
2273-checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb"
2586+checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
22742587 dependencies = [
22752588 "base64 0.23.1",
22762589 "indexmap 2.14.2",
......@@ -2281,11 +2594,11 @@ dependencies = [
22812594
22822595 [[package]]
22832596 name = "png"
2284-version = "0.17.16"
2597+version = "0.18.1"
22852598 source = "registry+https://github.com/rust-lang/crates.io-index"
2286-checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
2599+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
22872600 dependencies = [
2288- "bitflags 1.3.2",
2601+ "bitflags 2.13.2",
22892602 "crc32fast",
22902603 "fdeflate",
22912604 "flate2",
......@@ -2293,16 +2606,17 @@ dependencies = [
22932606 ]
22942607
22952608 [[package]]
2296-name = "png"
2297-version = "0.18.1"
2609+name = "polling"
2610+version = "3.11.0"
22982611 source = "registry+https://github.com/rust-lang/crates.io-index"
2299-checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
2612+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
23002613 dependencies = [
2301- "bitflags 2.13.2",
2302- "crc32fast",
2303- "fdeflate",
2304- "flate2",
2305- "miniz_oxide 0.8.9",
2614+ "cfg-if",
2615+ "concurrent-queue",
2616+ "hermit-abi",
2617+ "pin-project-lite",
2618+ "rustix",
2619+ "windows-sys 0.61.2",
23062620 ]
23072621
23082622 [[package]]
......@@ -2353,11 +2667,10 @@ dependencies = [
23532667
23542668 [[package]]
23552669 name = "proc-macro-crate"
2356-version = "2.0.2"
2670+version = "2.0.0"
23572671 source = "registry+https://github.com/rust-lang/crates.io-index"
2358-checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
2672+checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8"
23592673 dependencies = [
2360- "toml_datetime 0.6.3",
23612674 "toml_edit 0.20.2",
23622675 ]
23632676
......@@ -2509,11 +2822,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
25092822
25102823 [[package]]
25112824 name = "reqwest"
2512-version = "0.13.5"
2825+version = "0.13.4"
25132826 source = "registry+https://github.com/rust-lang/crates.io-index"
2514-checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
2827+checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
25152828 dependencies = [
2516- "base64 0.23.1",
2829+ "base64 0.22.1",
25172830 "bytes",
25182831 "futures-core",
25192832 "futures-util",
......@@ -2556,6 +2869,19 @@ dependencies = [
25562869 "semver",
25572870 ]
25582871
2872+[[package]]
2873+name = "rustix"
2874+version = "1.1.6"
2875+source = "registry+https://github.com/rust-lang/crates.io-index"
2876+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
2877+dependencies = [
2878+ "bitflags 2.13.2",
2879+ "errno",
2880+ "libc",
2881+ "linux-raw-sys",
2882+ "windows-sys 0.61.2",
2883+]
2884+
25592885 [[package]]
25602886 name = "rustversion"
25612887 version = "1.0.23"
......@@ -2571,6 +2897,15 @@ dependencies = [
25712897 "winapi-util",
25722898 ]
25732899
2900+[[package]]
2901+name = "sanitize-filename"
2902+version = "0.6.0"
2903+source = "registry+https://github.com/rust-lang/crates.io-index"
2904+checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d"
2905+dependencies = [
2906+ "regex",
2907+]
2908+
25742909 [[package]]
25752910 name = "schemars"
25762911 version = "0.8.22"
......@@ -2816,6 +3151,17 @@ dependencies = [
28163151 "stable_deref_trait",
28173152 ]
28183153
3154+[[package]]
3155+name = "sha1"
3156+version = "0.10.7"
3157+source = "registry+https://github.com/rust-lang/crates.io-index"
3158+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
3159+dependencies = [
3160+ "cfg-if",
3161+ "cpufeatures",
3162+ "digest",
3163+]
3164+
28193165 [[package]]
28203166 name = "sha2"
28213167 version = "0.10.9"
......@@ -2833,6 +3179,16 @@ version = "2.0.1"
28333179 source = "registry+https://github.com/rust-lang/crates.io-index"
28343180 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
28353181
3182+[[package]]
3183+name = "signal-hook-registry"
3184+version = "1.4.8"
3185+source = "registry+https://github.com/rust-lang/crates.io-index"
3186+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
3187+dependencies = [
3188+ "errno",
3189+ "libc",
3190+]
3191+
28363192 [[package]]
28373193 name = "simd-adler32"
28383194 version = "0.3.10"
......@@ -2853,9 +3209,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
28533209
28543210 [[package]]
28553211 name = "smallvec"
2856-version = "1.16.3"
3212+version = "1.16.4"
28573213 source = "registry+https://github.com/rust-lang/crates.io-index"
2858-checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
3214+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
28593215
28603216 [[package]]
28613217 name = "socket2"
......@@ -3029,15 +3385,15 @@ dependencies = [
30293385 "cfg-expr",
30303386 "heck 0.5.0",
30313387 "pkg-config",
3032- "toml 0.8.2",
3388+ "toml 0.8.23",
30333389 "version-compare",
30343390 ]
30353391
30363392 [[package]]
30373393 name = "tao"
3038-version = "0.37.1"
3394+version = "0.37.2"
30393395 source = "registry+https://github.com/rust-lang/crates.io-index"
3040-checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
3396+checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548"
30413397 dependencies = [
30423398 "bitflags 2.13.2",
30433399 "block2",
......@@ -3092,9 +3448,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
30923448
30933449 [[package]]
30943450 name = "tauri"
3095-version = "2.12.4"
3451+version = "2.12.5"
30963452 source = "registry+https://github.com/rust-lang/crates.io-index"
3097-checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212"
3453+checksum = "5fb0244332cfb9cee7e2a568bf5da29a36ed8fdbd717f6f47a66b6b7b358c072"
30983454 dependencies = [
30993455 "anyhow",
31003456 "bytes",
......@@ -3143,9 +3499,9 @@ dependencies = [
31433499
31443500 [[package]]
31453501 name = "tauri-build"
3146-version = "2.7.3"
3502+version = "2.7.4"
31473503 source = "registry+https://github.com/rust-lang/crates.io-index"
3148-checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc"
3504+checksum = "adddb0e648752aaac5da9c3c3c6a68e1e87cab55e3b757b18a9441ef8c3ac614"
31493505 dependencies = [
31503506 "anyhow",
31513507 "cargo_toml",
......@@ -3166,16 +3522,16 @@ dependencies = [
31663522
31673523 [[package]]
31683524 name = "tauri-codegen"
3169-version = "2.7.3"
3525+version = "2.7.4"
31703526 source = "registry+https://github.com/rust-lang/crates.io-index"
3171-checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f"
3527+checksum = "fb37631cf71dab5e8ab55e1bdd701e18a52653d90b78e71d0e5598b1e0a61316"
31723528 dependencies = [
31733529 "base64 0.23.1",
31743530 "brotli",
31753531 "ico",
31763532 "json-patch",
31773533 "plist",
3178- "png 0.18.1",
3534+ "png",
31793535 "proc-macro2",
31803536 "quote",
31813537 "semver",
......@@ -3193,9 +3549,9 @@ dependencies = [
31933549
31943550 [[package]]
31953551 name = "tauri-macros"
3196-version = "2.7.3"
3552+version = "2.7.4"
31973553 source = "registry+https://github.com/rust-lang/crates.io-index"
3198-checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f"
3554+checksum = "0cc38db58854c10fb5f91a887ed8bf82d3239bb92b5d8db86eda09e96d9963d1"
31993555 dependencies = [
32003556 "heck 0.5.0",
32013557 "proc-macro2",
......@@ -3242,6 +3598,28 @@ dependencies = [
32423598 "time",
32433599 ]
32443600
3601+[[package]]
3602+name = "tauri-plugin-opener"
3603+version = "2.7.2"
3604+source = "registry+https://github.com/rust-lang/crates.io-index"
3605+checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219"
3606+dependencies = [
3607+ "dunce",
3608+ "glob",
3609+ "objc2-app-kit",
3610+ "objc2-foundation",
3611+ "open",
3612+ "schemars 0.8.22",
3613+ "serde",
3614+ "serde_json",
3615+ "tauri",
3616+ "tauri-plugin",
3617+ "thiserror 2.0.21",
3618+ "url",
3619+ "windows",
3620+ "zbus",
3621+]
3622+
32453623 [[package]]
32463624 name = "tauri-runtime"
32473625 version = "2.12.2"
......@@ -3269,9 +3647,9 @@ dependencies = [
32693647
32703648 [[package]]
32713649 name = "tauri-runtime-wry"
3272-version = "2.12.2"
3650+version = "2.12.3"
32733651 source = "registry+https://github.com/rust-lang/crates.io-index"
3274-checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae"
3652+checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f"
32753653 dependencies = [
32763654 "gtk",
32773655 "http",
......@@ -3279,7 +3657,6 @@ dependencies = [
32793657 "log",
32803658 "objc2",
32813659 "objc2-app-kit",
3282- "once_cell",
32833660 "percent-encoding",
32843661 "raw-window-handle",
32853662 "softbuffer",
......@@ -3333,15 +3710,28 @@ dependencies = [
33333710
33343711 [[package]]
33353712 name = "tauri-winres"
3336-version = "0.3.6"
3713+version = "0.3.7"
33373714 source = "registry+https://github.com/rust-lang/crates.io-index"
3338-checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
3715+checksum = "c52bb6904475a6ca662f15ef787f18ade61d5740a1ee12c45d3b748efdb6f796"
33393716 dependencies = [
33403717 "dunce",
33413718 "embed-resource",
33423719 "toml 1.1.8+spec-1.1.0",
33433720 ]
33443721
3722+[[package]]
3723+name = "tempfile"
3724+version = "3.27.0"
3725+source = "registry+https://github.com/rust-lang/crates.io-index"
3726+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
3727+dependencies = [
3728+ "fastrand",
3729+ "getrandom 0.4.3",
3730+ "once_cell",
3731+ "rustix",
3732+ "windows-sys 0.61.2",
3733+]
3734+
33453735 [[package]]
33463736 name = "tendril"
33473737 version = "0.5.1"
......@@ -3425,9 +3815,9 @@ dependencies = [
34253815
34263816 [[package]]
34273817 name = "tinystr"
3428-version = "0.8.4"
3818+version = "0.8.5"
34293819 source = "registry+https://github.com/rust-lang/crates.io-index"
3430-checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
3820+checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
34313821 dependencies = [
34323822 "displaydoc",
34333823 "zerovec",
......@@ -3441,9 +3831,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
34413831
34423832 [[package]]
34433833 name = "tokio"
3444-version = "1.53.3"
3834+version = "1.54.1"
34453835 source = "registry+https://github.com/rust-lang/crates.io-index"
3446-checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
3836+checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
34473837 dependencies = [
34483838 "bytes",
34493839 "libc",
......@@ -3455,9 +3845,9 @@ dependencies = [
34553845
34563846 [[package]]
34573847 name = "tokio-util"
3458-version = "0.7.20"
3848+version = "0.7.18"
34593849 source = "registry+https://github.com/rust-lang/crates.io-index"
3460-checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
3850+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
34613851 dependencies = [
34623852 "bytes",
34633853 "futures-core",
......@@ -3468,14 +3858,14 @@ dependencies = [
34683858
34693859 [[package]]
34703860 name = "toml"
3471-version = "0.8.2"
3861+version = "0.8.23"
34723862 source = "registry+https://github.com/rust-lang/crates.io-index"
3473-checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
3863+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
34743864 dependencies = [
34753865 "serde",
34763866 "serde_spanned 0.6.9",
3477- "toml_datetime 0.6.3",
3478- "toml_edit 0.20.2",
3867+ "toml_datetime 0.6.11",
3868+ "toml_edit 0.22.27",
34793869 ]
34803870
34813871 [[package]]
......@@ -3495,9 +3885,9 @@ dependencies = [
34953885
34963886 [[package]]
34973887 name = "toml_datetime"
3498-version = "0.6.3"
3888+version = "0.6.11"
34993889 source = "registry+https://github.com/rust-lang/crates.io-index"
3500-checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
3890+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
35013891 dependencies = [
35023892 "serde",
35033893 ]
......@@ -3518,7 +3908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
35183908 checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
35193909 dependencies = [
35203910 "indexmap 2.14.2",
3521- "toml_datetime 0.6.3",
3911+ "toml_datetime 0.6.11",
35223912 "winnow 0.5.40",
35233913 ]
35243914
......@@ -3527,12 +3917,23 @@ name = "toml_edit"
35273917 version = "0.20.2"
35283918 source = "registry+https://github.com/rust-lang/crates.io-index"
35293919 checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
3920+dependencies = [
3921+ "indexmap 2.14.2",
3922+ "toml_datetime 0.6.11",
3923+ "winnow 0.5.40",
3924+]
3925+
3926+[[package]]
3927+name = "toml_edit"
3928+version = "0.22.27"
3929+source = "registry+https://github.com/rust-lang/crates.io-index"
3930+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
35303931 dependencies = [
35313932 "indexmap 2.14.2",
35323933 "serde",
35333934 "serde_spanned 0.6.9",
3534- "toml_datetime 0.6.3",
3535- "winnow 0.5.40",
3935+ "toml_datetime 0.6.11",
3936+ "winnow 0.7.15",
35363937 ]
35373938
35383939 [[package]]
......@@ -3614,9 +4015,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
36144015 checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
36154016 dependencies = [
36164017 "pin-project-lite",
4018+ "tracing-attributes",
36174019 "tracing-core",
36184020 ]
36194021
4022+[[package]]
4023+name = "tracing-attributes"
4024+version = "0.1.31"
4025+source = "registry+https://github.com/rust-lang/crates.io-index"
4026+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
4027+dependencies = [
4028+ "proc-macro2",
4029+ "quote",
4030+ "syn 2.0.119",
4031+]
4032+
36204033 [[package]]
36214034 name = "tracing-core"
36224035 version = "0.1.36"
......@@ -3642,7 +4055,7 @@ dependencies = [
36424055 "objc2-core-graphics",
36434056 "objc2-foundation",
36444057 "once_cell",
3645- "png 0.18.1",
4058+ "png",
36464059 "serde",
36474060 "thiserror 2.0.21",
36484061 "windows-sys 0.61.2",
......@@ -3666,6 +4079,23 @@ version = "1.20.1"
36664079 source = "registry+https://github.com/rust-lang/crates.io-index"
36674080 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
36684081
4082+[[package]]
4083+name = "uds_windows"
4084+version = "1.2.1"
4085+source = "registry+https://github.com/rust-lang/crates.io-index"
4086+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
4087+dependencies = [
4088+ "memoffset",
4089+ "tempfile",
4090+ "windows-sys 0.61.2",
4091+]
4092+
4093+[[package]]
4094+name = "unicase"
4095+version = "2.10.0"
4096+source = "registry+https://github.com/rust-lang/crates.io-index"
4097+checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
4098+
36694099 [[package]]
36704100 name = "unicode-ident"
36714101 version = "1.0.27"
......@@ -3711,9 +4141,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
37114141
37124142 [[package]]
37134143 name = "uuid"
3714-version = "1.28.0"
4144+version = "1.29.0"
37154145 source = "registry+https://github.com/rust-lang/crates.io-index"
3716-checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
4146+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
37174147 dependencies = [
37184148 "getrandom 0.4.3",
37194149 "js-sys",
......@@ -3765,9 +4195,9 @@ dependencies = [
37654195
37664196 [[package]]
37674197 name = "want"
3768-version = "0.3.2"
4198+version = "0.3.1"
37694199 source = "registry+https://github.com/rust-lang/crates.io-index"
3770-checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
4200+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
37714201 dependencies = [
37724202 "try-lock",
37734203 ]
......@@ -4216,6 +4646,15 @@ dependencies = [
42164646 "memchr",
42174647 ]
42184648
4649+[[package]]
4650+name = "winnow"
4651+version = "0.7.15"
4652+source = "registry+https://github.com/rust-lang/crates.io-index"
4653+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
4654+dependencies = [
4655+ "memchr",
4656+]
4657+
42194658 [[package]]
42204659 name = "winnow"
42214660 version = "1.0.4"
......@@ -4249,11 +4688,11 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
42494688
42504689 [[package]]
42514690 name = "wry"
4252-version = "0.57.0"
4691+version = "0.57.1"
42534692 source = "registry+https://github.com/rust-lang/crates.io-index"
4254-checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0"
4693+checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9"
42554694 dependencies = [
4256- "base64 0.22.1",
4695+ "base64 0.23.1",
42574696 "block2",
42584697 "cookie",
42594698 "crossbeam-channel",
......@@ -4274,7 +4713,6 @@ dependencies = [
42744713 "objc2-foundation",
42754714 "objc2-ui-kit",
42764715 "objc2-web-kit",
4277- "once_cell",
42784716 "percent-encoding",
42794717 "raw-window-handle",
42804718 "sha2",
......@@ -4314,9 +4752,9 @@ dependencies = [
43144752
43154753 [[package]]
43164754 name = "yoke"
4317-version = "0.8.3"
4755+version = "0.8.4"
43184756 source = "registry+https://github.com/rust-lang/crates.io-index"
4319-checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
4757+checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
43204758 dependencies = [
43214759 "stable_deref_trait",
43224760 "yoke-derive",
......@@ -4335,6 +4773,76 @@ dependencies = [
43354773 "synstructure",
43364774 ]
43374775
4776+[[package]]
4777+name = "zbus"
4778+version = "5.19.0"
4779+source = "registry+https://github.com/rust-lang/crates.io-index"
4780+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
4781+dependencies = [
4782+ "async-broadcast",
4783+ "async-executor",
4784+ "async-io",
4785+ "async-lock",
4786+ "async-process",
4787+ "async-recursion",
4788+ "async-task",
4789+ "async-trait",
4790+ "blocking",
4791+ "enumflags2",
4792+ "event-listener",
4793+ "futures-core",
4794+ "futures-lite",
4795+ "hex",
4796+ "libc",
4797+ "ordered-stream",
4798+ "rustix",
4799+ "serde",
4800+ "serde_repr",
4801+ "tracing",
4802+ "uds_windows",
4803+ "uuid",
4804+ "windows-sys 0.61.2",
4805+ "winnow 1.0.4",
4806+ "zbus_macros",
4807+ "zbus_names",
4808+ "zvariant",
4809+]
4810+
4811+[[package]]
4812+name = "zbus_macros"
4813+version = "5.19.0"
4814+source = "registry+https://github.com/rust-lang/crates.io-index"
4815+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
4816+dependencies = [
4817+ "proc-macro-crate 3.5.0",
4818+ "proc-macro2",
4819+ "quote",
4820+ "syn 3.0.9",
4821+ "zbus_names",
4822+ "zvariant",
4823+ "zvariant_utils",
4824+]
4825+
4826+[[package]]
4827+name = "zbus_names"
4828+version = "4.3.5"
4829+source = "registry+https://github.com/rust-lang/crates.io-index"
4830+checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
4831+dependencies = [
4832+ "serde",
4833+ "winnow 1.0.4",
4834+ "zvariant",
4835+]
4836+
4837+[[package]]
4838+name = "zcheapstr"
4839+version = "1.1.0"
4840+source = "registry+https://github.com/rust-lang/crates.io-index"
4841+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
4842+dependencies = [
4843+ "serde",
4844+]
4845+
43384846 [[package]]
43394847 name = "zerofrom"
43404848 version = "0.1.8"
......@@ -4369,9 +4877,9 @@ dependencies = [
43694877
43704878 [[package]]
43714879 name = "zerovec"
4372-version = "0.11.8"
4880+version = "0.11.9"
43734881 source = "registry+https://github.com/rust-lang/crates.io-index"
4374-checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
4882+checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
43754883 dependencies = [
43764884 "yoke",
43774885 "zerofrom",
......@@ -4380,9 +4888,9 @@ dependencies = [
43804888
43814889 [[package]]
43824890 name = "zerovec-derive"
4383-version = "0.11.6"
4891+version = "0.11.7"
43844892 source = "registry+https://github.com/rust-lang/crates.io-index"
4385-checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
4893+checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
43864894 dependencies = [
43874895 "proc-macro2",
43884896 "quote",
......@@ -4400,3 +4908,44 @@ name = "zmij"
44004908 version = "1.0.23"
44014909 source = "registry+https://github.com/rust-lang/crates.io-index"
44024910 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4911+
4912+[[package]]
4913+name = "zvariant"
4914+version = "5.15.1"
4915+source = "registry+https://github.com/rust-lang/crates.io-index"
4916+checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
4917+dependencies = [
4918+ "endi",
4919+ "enumflags2",
4920+ "serde",
4921+ "winnow 1.0.4",
4922+ "zcheapstr",
4923+ "zvariant_derive",
4924+ "zvariant_utils",
4925+]
4926+
4927+[[package]]
4928+name = "zvariant_derive"
4929+version = "5.15.1"
4930+source = "registry+https://github.com/rust-lang/crates.io-index"
4931+checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
4932+dependencies = [
4933+ "proc-macro-crate 3.5.0",
4934+ "proc-macro2",
4935+ "quote",
4936+ "syn 3.0.9",
4937+ "zvariant_utils",
4938+]
4939+
4940+[[package]]
4941+name = "zvariant_utils"
4942+version = "4.2.0"
4943+source = "registry+https://github.com/rust-lang/crates.io-index"
4944+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
4945+dependencies = [
4946+ "proc-macro2",
4947+ "quote",
4948+ "serde",
4949+ "syn 3.0.9",
4950+ "winnow 1.0.4",
4951+]
+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+mime_guess = "2"
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"
+38 -0 src-tauri/src/lib.rs #
......@@ -1,14 +1,52 @@
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+ // Is this not the protocol that tauri uses by default? Do we need to register it manually?
17+ //
18+ // eric: this is us registering our own handler here (media::protocol). So yes we do need to
19+ // register manually
20+ .register_asynchronous_uri_scheme_protocol("tauri", media::protocol)
421 .setup(|app| {
22+ app.manage(media::MediaCache::new(
23+ // This is fine for now, but maybe we'll want some to be in cache dir and some in
24+ // more "permanent" dir eventually? We do than on Android anyway
25+ //
26+ // eric: there is also app_data_dir we could use which would be more permanent.
27+ // not sure right now how we would decide to split those but I think it can be
28+ // a future thing
29+ app.path().app_cache_dir()?.join("media"),
30+ )?);
531 if cfg!(debug_assertions) {
32+ // Should we have this kind of debug log controlled by an env var so it can be
33+ // enabled at runtime instead of compile time?
34+ //
35+ // eric: this is just the default I didn't change it here. Could look into this
36+ // in a different change in the future.
637 app.handle().plugin(
738 tauri_plugin_log::Builder::default()
839 .level(log::LevelFilter::Info)
940 .build(),
1041 )?;
1142 }
43+ // Build the configured window here so native navigation hooks are installed
44+ // before its first page loads.
45+ let navigation_app = app.handle().clone();
46+ let window =
47+ tauri::WebviewWindowBuilder::from_config(app, &app.config().app.windows[0])?
48+ .on_navigation(move |url| !media::open_navigation(&navigation_app, url));
49+ window.build()?;
1250 Ok(())
1351 })
1452 .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+}
+434 -0 src-tauri/src/media/store.rs #
......@@ -0,0 +1,434 @@
1+use super::hash::MediaHash;
2+use std::{
3+ fs,
4+ io::{self, Read},
5+ path::PathBuf,
6+};
7+use tauri::http::{Response, header::HeaderValue};
8+
9+pub struct MediaCache {
10+ root: PathBuf,
11+}
12+
13+struct ResolvedMedia {
14+ root: PathBuf,
15+ sha256: MediaHash,
16+}
17+
18+impl ResolvedMedia {
19+ fn hex(&self) -> String {
20+ self.sha256.hex()
21+ }
22+
23+ fn media_path(&self) -> PathBuf {
24+ self.root.join("files").join(format!("f{}", self.hex()))
25+ }
26+
27+ fn mime_path(&self) -> PathBuf {
28+ self.root.join("mime").join(format!("{}.mime", self.hex()))
29+ }
30+
31+ fn alias_path(&self, hash: &MediaHash) -> PathBuf {
32+ self.root.join("aliases").join(hash.hex())
33+ }
34+
35+ fn read(&self) -> io::Result<Vec<u8>> {
36+ fs::read(self.media_path())
37+ }
38+
39+ fn mime(&self) -> io::Result<String> {
40+ fs::read_to_string(self.mime_path())
41+ }
42+
43+ fn write_mime(&self, mime: &HeaderValue) -> io::Result<()> {
44+ fs::write(self.mime_path(), mime.as_bytes())
45+ }
46+
47+ fn write_alias(&self, hash: &MediaHash) -> io::Result<()> {
48+ fs::write(self.alias_path(hash), self.hex())
49+ }
50+
51+ fn exists(&self) -> io::Result<bool> {
52+ Ok(self.media_path().try_exists()? && self.mime_path().try_exists()?)
53+ }
54+
55+ fn remove(&self) -> io::Result<()> {
56+ self.remove_file(self.media_path())?;
57+ self.remove_file(self.mime_path())
58+ }
59+
60+ fn remove_file(&self, path: PathBuf) -> io::Result<()> {
61+ match fs::remove_file(path) {
62+ Ok(()) => Ok(()),
63+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
64+ Err(error) => Err(error),
65+ }
66+ }
67+}
68+
69+impl MediaCache {
70+ pub(super) fn prepare_open(&self, hash: &MediaHash, name: Option<&str>) -> io::Result<PathBuf> {
71+ let media = self.resolve(hash)?;
72+ let mime = media.mime()?;
73+ let mime = mime.split(';').next().unwrap().trim();
74+ let source = media.media_path();
75+ let name = name
76+ .map(sanitize_filename::sanitize)
77+ .filter(|name| !name.is_empty());
78+ let name = if let Some(name) = name {
79+ name
80+ } else {
81+ // TODO: This sucks I think, need to rework this
82+ //
83+ // mime_guess lists JPEG's `jfif` alias first; prefer the extension
84+ // commonly registered with desktop image viewers.
85+ let extension = if mime.eq_ignore_ascii_case("image/jpeg") {
86+ Some("jpg")
87+ } else if mime.eq_ignore_ascii_case("application/octet-stream") {
88+ // Generic binary MIME has no meaningful extension. Inspect the bytes
89+ // rather than selecting an arbitrary alias such as `aaf`.
90+
91+ let mut header = Vec::new();
92+ fs::File::open(&source)?
93+ .take(8192)
94+ .read_to_end(&mut header)?;
95+ Some(infer::get(&header).map_or("bin", |kind| kind.extension()))
96+ } else {
97+ mime_guess::get_mime_extensions_str(mime).and_then(|v| v.first().copied())
98+ };
99+ extension.map_or_else(
100+ || "attachment".to_owned(),
101+ |ext| format!("attachment.{ext}"),
102+ )
103+ };
104+ let directory = self.root.join("open").join(media.hex());
105+ fs::create_dir_all(&directory)?;
106+ let destination = directory.join(name);
107+ fs::copy(source, &destination)?;
108+ Ok(destination)
109+ }
110+
111+ pub fn new(root: PathBuf) -> io::Result<Self> {
112+ for directory in ["files", "mime", "aliases"] {
113+ fs::create_dir_all(root.join(directory))?;
114+ }
115+ Ok(Self { root })
116+ }
117+
118+ fn resolve(&self, hash: &MediaHash) -> io::Result<ResolvedMedia> {
119+ let sha256 = match hash {
120+ MediaHash::Sha256(bytes) => MediaHash::Sha256(*bytes),
121+ MediaHash::Sha1(_) => {
122+ let alias = fs::read_to_string(self.root.join("aliases").join(hash.hex()))?;
123+ MediaHash::from_hex("sha-256", &alias)
124+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
125+ }
126+ };
127+ Ok(ResolvedMedia {
128+ root: self.root.clone(),
129+ sha256,
130+ })
131+ }
132+
133+ pub(super) fn publish(
134+ &self,
135+ temporary: tempfile::TempPath,
136+ mime: &HeaderValue,
137+ sha256: MediaHash,
138+ sha1: &MediaHash,
139+ ) -> io::Result<()> {
140+ let media = ResolvedMedia {
141+ root: self.root.clone(),
142+ sha256,
143+ };
144+ fs::copy(&temporary, media.media_path())?;
145+ media.write_mime(mime)?;
146+ media.write_alias(sha1)?;
147+ Ok(())
148+ }
149+
150+ pub(super) fn has(&self, hash: &MediaHash) -> io::Result<Option<String>> {
151+ match self.resolve(hash) {
152+ Ok(media) => media.exists().map(|found| found.then(|| hash.url())),
153+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
154+ Err(error) => Err(error),
155+ }
156+ }
157+
158+ pub(super) fn remove(&self, hash: &MediaHash) -> io::Result<()> {
159+ match self.resolve(hash) {
160+ Ok(media) => media.remove(),
161+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
162+ Err(error) => Err(error),
163+ }
164+ }
165+
166+ pub(super) fn response(&self, hash: &MediaHash, head: bool) -> io::Result<Response<Vec<u8>>> {
167+ let media = self.resolve(hash)?;
168+ let mime = media.mime()?;
169+ let bytes = media.read()?;
170+ Response::builder()
171+ .header("Content-Type", mime)
172+ .header("Content-Length", bytes.len())
173+ .body(if head { vec![] } else { bytes })
174+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
175+ }
176+}
177+
178+#[cfg(test)]
179+mod tests {
180+ use std::fs;
181+
182+ use super::*;
183+ use crate::media::upload_slot::UploadSlot;
184+
185+ const SHA256: &str = "b6acae78c76442a73a94762eb91711a59c642ac3cfdaad636a061bdb7fad6975";
186+ const SHA1: &str = "daa728737f5b69b8f46dd7df156ee770e738759c";
187+ const DATA: &[u8] = b"media bytes";
188+
189+ fn hash(algorithm: &str, hex: &str) -> MediaHash {
190+ MediaHash::from_hex(algorithm, hex).unwrap()
191+ }
192+
193+ fn hash_url(algorithm: &str, hex: &str) -> String {
194+ hash(algorithm, hex).url()
195+ }
196+
197+ struct Fixture {
198+ cache: MediaCache,
199+ _directory: tempfile::TempDir,
200+ }
201+ impl Fixture {
202+ fn new() -> Self {
203+ let directory = tempfile::tempdir().unwrap();
204+ Self {
205+ cache: MediaCache::new(directory.path().to_path_buf()).unwrap(),
206+ _directory: directory,
207+ }
208+ }
209+ fn begin(&self, mime: String) -> io::Result<UploadSlot> {
210+ UploadSlot::new(
211+ HeaderValue::from_str(&mime)
212+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?,
213+ )
214+ }
215+
216+ fn publish(&self, mime: &str) {
217+ let mut upload = self.begin(mime.into()).unwrap();
218+ upload.write(&DATA[..3]).unwrap();
219+ upload.write(&DATA[3..]).unwrap();
220+ assert_eq!(
221+ upload.finish(&self.cache).unwrap(),
222+ hash_url("sha-256", SHA256)
223+ );
224+ }
225+ }
226+
227+ #[test]
228+ fn invalid_mime_sidecars_return_io_errors() {
229+ let fixture = Fixture::new();
230+ fixture.publish("image/png");
231+ let media = fixture.cache.resolve(&hash("sha-256", SHA256)).unwrap();
232+ for bytes in [b"image/png\r\nX-Test: bad".as_slice(), &[0xff]] {
233+ fs::write(media.mime_path(), bytes).unwrap();
234+ let error = fixture
235+ .cache
236+ .response(&hash("sha-1", SHA1), false)
237+ .unwrap_err();
238+ assert_eq!(error.kind(), io::ErrorKind::InvalidData);
239+ }
240+ }
241+
242+ #[test]
243+ fn opening_copies_aliases_with_mime_extensions() {
244+ let fixture = Fixture::new();
245+ fixture.publish("image/png");
246+ let path = fixture
247+ .cache
248+ .prepare_open(&hash("sha-1", SHA1), None)
249+ .unwrap();
250+ assert_eq!(path.extension().unwrap(), "png");
251+ assert!(path.starts_with(fixture.cache.root.join("open")));
252+ assert_eq!(fs::read(&path).unwrap(), DATA);
253+ fs::write(&path, b"edited").unwrap();
254+ assert_eq!(
255+ fs::read(fixture.cache.root.join("files").join(format!("f{SHA256}"))).unwrap(),
256+ DATA
257+ );
258+ }
259+
260+ #[test]
261+ fn reopening_reuses_the_path_and_restores_cached_contents() {
262+ let fixture = Fixture::new();
263+ fixture.publish("image/png");
264+ let first = fixture
265+ .cache
266+ .prepare_open(&hash("sha-1", SHA1), Some("photo.png"))
267+ .unwrap();
268+ fs::write(&first, b"edited").unwrap();
269+ let second = fixture
270+ .cache
271+ .prepare_open(&hash("sha-256", SHA256), Some("photo.png"))
272+ .unwrap();
273+ assert_eq!(first, second);
274+ assert_eq!(fs::read(second).unwrap(), DATA);
275+ }
276+
277+ #[test]
278+ fn opening_uses_the_original_filename_or_falls_back_to_mime() {
279+ let fixture = Fixture::new();
280+ fixture.publish("image/png");
281+ let hash = hash("sha-256", SHA256);
282+ for name in ["photo.jpg", "résumé + notes & stuff #1.pdf", "notes"] {
283+ let path = fixture.cache.prepare_open(&hash, Some(name)).unwrap();
284+ assert_eq!(path.file_name().unwrap(), name);
285+ assert_eq!(
286+ path.parent().unwrap().parent().unwrap(),
287+ fixture.cache.root.join("open")
288+ );
289+ assert_eq!(fs::read(&path).unwrap(), DATA);
290+ }
291+ for (name, expected) in [
292+ ("", "attachment.png"),
293+ (".", "attachment.png"),
294+ ("..", "attachment.png"),
295+ ("/", "attachment.png"),
296+ ("../outside.png", "..outside.png"),
297+ ("/outside.png", "outside.png"),
298+ ("..\\outside.png", "..outside.png"),
299+ ("bad\0.png", "bad.png"),
300+ ("C:photo.png", "Cphoto.png"),
301+ ] {
302+ let path = fixture.cache.prepare_open(&hash, Some(name)).unwrap();
303+ assert_eq!(path.file_name().unwrap(), expected);
304+ assert_eq!(
305+ path.parent().unwrap().parent().unwrap(),
306+ fixture.cache.root.join("open")
307+ );
308+ assert_eq!(fs::read(&path).unwrap(), DATA);
309+ }
310+ }
311+
312+ #[test]
313+ fn opening_generic_binary_detects_content_or_uses_bin() {
314+ let fixture = Fixture::new();
315+ fixture.publish("application/octet-stream");
316+ let path = fixture
317+ .cache
318+ .prepare_open(&hash("sha-256", SHA256), None)
319+ .unwrap();
320+ assert_eq!(path.extension().unwrap(), "bin");
321+
322+ let mut upload = fixture.begin("application/octet-stream".into()).unwrap();
323+ upload.write(b"%PDF-1.7\n").unwrap();
324+ let url = upload.finish(&fixture.cache).unwrap();
325+ let hash = crate::media::parse_path(&url).unwrap();
326+ let path = fixture.cache.prepare_open(&hash, None).unwrap();
327+ assert_eq!(path.extension().unwrap(), "pdf");
328+ }
329+
330+ #[test]
331+ fn opening_jpeg_uses_jpg_instead_of_jfif() {
332+ let fixture = Fixture::new();
333+ fixture.publish("image/jpeg; charset=binary");
334+ let path = fixture
335+ .cache
336+ .prepare_open(&hash("sha-256", SHA256), None)
337+ .unwrap();
338+ assert_eq!(path.file_name().unwrap(), "attachment.jpg");
339+ assert_eq!(fs::read(path).unwrap(), DATA);
340+ }
341+
342+ #[test]
343+ fn serves_aliases_and_mime_after_restart() {
344+ let fixture = Fixture::new();
345+ fixture.publish("image/svg+xml");
346+ let reopened = MediaCache::new(fixture.cache.root.clone()).unwrap();
347+ for (algorithm, hex) in [("sha-1", SHA1), ("sha-256", SHA256)] {
348+ assert_eq!(
349+ reopened.has(&hash(algorithm, hex)).unwrap(),
350+ Some(hash_url(algorithm, hex))
351+ );
352+ let response = reopened.response(&hash(algorithm, hex), false).unwrap();
353+ assert_eq!(response.body(), DATA);
354+ assert_eq!(response.headers()["content-type"], "image/svg+xml");
355+ assert_eq!(response.headers()["content-length"], DATA.len().to_string());
356+ }
357+ let head = reopened.response(&hash("sha-1", SHA1), true).unwrap();
358+ assert!(head.body().is_empty());
359+ assert_eq!(head.headers()["content-length"], DATA.len().to_string());
360+ }
361+
362+ #[test]
363+ fn duplicates_and_deletion_work_for_both_algorithms() {
364+ for algorithm in ["sha-1", "sha-256"] {
365+ let fixture = Fixture::new();
366+ fixture.publish("image/png");
367+ fixture.publish("application/octet-stream");
368+ let response = fixture.cache.response(&hash("sha-1", SHA1), false).unwrap();
369+ assert_eq!(response.body(), DATA);
370+ assert_eq!(
371+ response.headers()["content-type"],
372+ "application/octet-stream"
373+ );
374+ let hex = if algorithm == "sha-1" { SHA1 } else { SHA256 };
375+ fixture.cache.remove(&hash(algorithm, hex)).unwrap();
376+ fixture.cache.remove(&hash(algorithm, hex)).unwrap();
377+ assert_eq!(fixture.cache.has(&hash("sha-1", SHA1)).unwrap(), None);
378+ assert_eq!(fixture.cache.has(&hash("sha-256", SHA256)).unwrap(), None);
379+ assert!(
380+ !fixture
381+ .cache
382+ .root
383+ .join("mime")
384+ .join(format!("{SHA256}.mime"))
385+ .exists()
386+ );
387+ }
388+ }
389+
390+ #[test]
391+ fn missing_or_evicted_files_and_old_files_without_mime_are_misses() {
392+ let fixture = Fixture::new();
393+ assert!(
394+ fixture
395+ .cache
396+ .response(&hash("sha-1", SHA1), false)
397+ .is_err_and(|error| error.kind() == io::ErrorKind::NotFound)
398+ );
399+ let path = fixture.cache.root.join("files").join(format!("f{SHA256}"));
400+ fs::write(&path, DATA).unwrap();
401+ // Old JSON sidecars are ignored; the next download writes a MIME sidecar.
402+ fs::create_dir(fixture.cache.root.join("metadata")).unwrap();
403+ fs::write(
404+ fixture
405+ .cache
406+ .root
407+ .join("metadata")
408+ .join(format!("{SHA256}.json")),
409+ br#"{"contentType":"image/png"}"#,
410+ )
411+ .unwrap();
412+ assert_eq!(fixture.cache.has(&hash("sha-256", SHA256)).unwrap(), None);
413+ fixture.publish("image/png");
414+ fs::remove_file(path).unwrap();
415+ assert_eq!(fixture.cache.has(&hash("sha-1", SHA1)).unwrap(), None);
416+ assert!(
417+ fixture
418+ .cache
419+ .response(&hash("sha-256", SHA256), false)
420+ .is_err_and(|error| error.kind() == io::ErrorKind::NotFound)
421+ );
422+ }
423+
424+ #[test]
425+ fn validates_disk_aliases() {
426+ let fixture = Fixture::new();
427+ fs::write(
428+ fixture.cache.root.join("aliases").join(SHA1),
429+ "../../outside",
430+ )
431+ .unwrap();
432+ assert!(fixture.cache.has(&hash("sha-1", SHA1)).is_err());
433+ }
434+}
+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+}
+3 -0 src-tauri/tauri.conf.json #
......@@ -12,6 +12,9 @@
1212 "app": {
1313 "windows": [
1414 {
15+ "create": false, // why?
16+ // eric: because we need to attach on_navigation before creation,
17+ // so we create it ourselves with window.build() (in lib.rs).
1518 "title": "Cheogram",
1619 "titleBarStyle": "Transparent",
1720 "backgroundColor": "#7401cf",
+1 -1 src/App.tsx #
......@@ -17,6 +17,7 @@ import { ios, webkit, href, useEffectEvent, showNotification } from "./util";
1717 import { SettingsContext, SettingsData } from "./components/Settings";
1818 import { Call } from "./components/Call";
1919 import { LoginRequest } from "./components/LoginRequest";
20+import { mediaStore } from "./media-store";
2021
2122 Sentry.init({
2223 dsn: "https://ec9ae5aef68e4035937d4c5943c4156c@app.glitchtip.com/7125",
......@@ -33,7 +34,6 @@ Sentry.init({
3334 replaysOnErrorSampleRate: 1.0,
3435 });
3536
36-const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
3737 const idbp = await borogove.persistence.IDB("snikket", mediaStore, null, stem);
3838 let persistence = null;
3939 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