Skip to main content

max / quasi

Add quasi-tauri, the second host adapter, and quasi-http beneath both The Tauri custom-protocol adapter, unblocked by the URL-form spike, plus the layer the two hosts turned out to share once there were two of them. quasi-http holds decoding, status mapping, the HX-Retarget header, the Render trait and the htmx contract, in http types neither host owns and with nothing async in it. That last part is what makes it callable from a protocol handler, which has no executor to hand work to. The htmx module said in its own doc comment that it would move here when a second caller arrived; this is that. quasi-tauri serves the document as well as the routes, on its own scheme. That is the decision in it: an hx-post resolves against the document origin, so a router on a scheme of its own would make every action cross-origin and cost a CORS layer plus two CSP entries for the two platform forms of one scheme. A passthrough closure is this host's version of axum's merged routes. No cfg and no platform branch, because wry reverts the Windows workaround before the handler is called. quasi-axum keeps its whole public API and all 15 tests unchanged. Also: time 0.3.45 to 0.3.55 for RUSTSEC-2026-0009, which needed the workspace rust-version raised to 1.88, and an ignore list in deny.toml for the 18 advisories tauri's tree brings that have no upgrade path. Each entry carries its reason and GoingsOn 59ff2fdb reviews them.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 22:53 UTC
Signed with PGP, not checked
Commit: 6b1745eed90d15170455caaef7bf154aab3de30e
Parent: 38fd4c8
15 files changed, +1782 insertions, -202 deletions
M CONTRIBUTING.md +1 -1
@@ -47,7 +47,7 @@
47 47 Crates are scaffolded when they start, not up front:
48 48
49 49 ```
50 - cargo new --lib crates/quasi-host-tauri
50 + cargo new --lib crates/quasi-wry
51 51 ```
52 52
53 53 Then add it to `Cargo.toml`'s `[workspace] members`, add the `[lints] workspace
M Cargo.lock +480
@@ -2,12 +2,86 @@
2 2 # It is not intended for manual editing.
3 3 version = 4
4 4
5 + [[package]]
6 + name = "adler2"
7 + version = "2.0.1"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10 +
11 + [[package]]
12 + name = "aho-corasick"
13 + version = "1.1.5"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
16 + dependencies = [
17 + "memchr",
18 + ]
19 +
20 + [[package]]
21 + name = "alloc-no-stdlib"
22 + version = "2.0.4"
23 + source = "registry+https://github.com/rust-lang/crates.io-index"
24 + checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
25 +
26 + [[package]]
27 + name = "alloc-stdlib"
28 + version = "0.2.4"
29 + source = "registry+https://github.com/rust-lang/crates.io-index"
30 + checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
31 + dependencies = [
32 + "alloc-no-stdlib",
33 + ]
34 +
35 + [[package]]
36 + name = "android_system_properties"
37 + version = "0.1.6"
38 + source = "registry+https://github.com/rust-lang/crates.io-index"
39 + checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
40 + dependencies = [
41 + "libc",
42 + ]
43 +
44 + [[package]]
45 + name = "anyhow"
46 + version = "1.0.104"
47 + source = "registry+https://github.com/rust-lang/crates.io-index"
48 + checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
49 +
50 + [[package]]
51 + name = "atk"
52 + version = "0.18.2"
53 + source = "registry+https://github.com/rust-lang/crates.io-index"
54 + checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
55 + dependencies = [
56 + "atk-sys",
57 + "glib",
58 + "libc",
59 + ]
60 +
61 + [[package]]
62 + name = "atk-sys"
63 + version = "0.18.2"
64 + source = "registry+https://github.com/rust-lang/crates.io-index"
65 + checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
66 + dependencies = [
67 + "glib-sys",
68 + "gobject-sys",
69 + "libc",
70 + "system-deps",
71 + ]
72 +
5 73 [[package]]
6 74 name = "atomic-waker"
7 75 version = "1.1.2"
8 76 source = "registry+https://github.com/rust-lang/crates.io-index"
9 77 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
10 78
79 + [[package]]
80 + name = "autocfg"
81 + version = "1.5.1"
82 + source = "registry+https://github.com/rust-lang/crates.io-index"
83 + checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
84 +
11 85 [[package]]
12 86 name = "axum"
13 87 version = "0.8.9"
@@ -60,11 +134,713 @@
60 134 "tracing",
61 135 ]
62 136
137 + [[package]]
138 + name = "base64"
139 + version = "0.21.7"
140 + source = "registry+https://github.com/rust-lang/crates.io-index"
141 + checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
142 +
143 + [[package]]
144 + name = "base64"
145 + version = "0.22.1"
146 + source = "registry+https://github.com/rust-lang/crates.io-index"
147 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
148 +
149 + [[package]]
150 + name = "bit-set"
151 + version = "0.8.0"
152 + source = "registry+https://github.com/rust-lang/crates.io-index"
153 + checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
154 + dependencies = [
155 + "bit-vec",
156 + ]
157 +
158 + [[package]]
159 + name = "bit-vec"
160 + version = "0.8.0"
161 + source = "registry+https://github.com/rust-lang/crates.io-index"
162 + checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
163 +
164 + [[package]]
165 + name = "bitflags"
166 + version = "1.3.2"
167 + source = "registry+https://github.com/rust-lang/crates.io-index"
168 + checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
169 +
170 + [[package]]
171 + name = "bitflags"
172 + version = "2.13.1"
173 + source = "registry+https://github.com/rust-lang/crates.io-index"
174 + checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
175 + dependencies = [
176 + "serde_core",
177 + ]
178 +
179 + [[package]]
180 + name = "block-buffer"
181 + version = "0.10.4"
182 + source = "registry+https://github.com/rust-lang/crates.io-index"
183 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
184 + dependencies = [
185 + "generic-array",
186 + ]
187 +
188 + [[package]]
189 + name = "block2"
190 + version = "0.6.2"
191 + source = "registry+https://github.com/rust-lang/crates.io-index"
192 + checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
193 + dependencies = [
194 + "objc2",
195 + ]
196 +
197 + [[package]]
198 + name = "brotli"
199 + version = "8.0.4"
200 + source = "registry+https://github.com/rust-lang/crates.io-index"
201 + checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
202 + dependencies = [
203 + "alloc-no-stdlib",
204 + "alloc-stdlib",
205 + "brotli-decompressor",
206 + ]
207 +
208 + [[package]]
209 + name = "brotli-decompressor"
210 + version = "5.0.3"
211 + source = "registry+https://github.com/rust-lang/crates.io-index"
212 + checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
213 + dependencies = [
214 + "alloc-no-stdlib",
215 + "alloc-stdlib",
216 + ]
217 +
218 + [[package]]
219 + name = "bumpalo"
220 + version = "3.20.3"
221 + source = "registry+https://github.com/rust-lang/crates.io-index"
222 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
223 +
224 + [[package]]
225 + name = "bytemuck"
226 + version = "1.25.2"
227 + source = "registry+https://github.com/rust-lang/crates.io-index"
228 + checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
229 +
230 + [[package]]
231 + name = "byteorder"
232 + version = "1.5.0"
233 + source = "registry+https://github.com/rust-lang/crates.io-index"
234 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
235 +
63 236 [[package]]
64 237 name = "bytes"
65 238 version = "1.12.1"
66 239 source = "registry+https://github.com/rust-lang/crates.io-index"
67 240 checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
241 + dependencies = [
242 + "serde",
243 + ]
244 +
245 + [[package]]
246 + name = "cairo-rs"
247 + version = "0.18.5"
248 + source = "registry+https://github.com/rust-lang/crates.io-index"
249 + checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
250 + dependencies = [
251 + "bitflags 2.13.1",
252 + "cairo-sys-rs",
253 + "glib",
254 + "libc",
255 + "once_cell",
256 + "thiserror 1.0.69",
257 + ]
258 +
259 + [[package]]
260 + name = "cairo-sys-rs"
261 + version = "0.18.2"
262 + source = "registry+https://github.com/rust-lang/crates.io-index"
263 + checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
264 + dependencies = [
265 + "glib-sys",
266 + "libc",
267 + "system-deps",
268 + ]
269 +
270 + [[package]]
271 + name = "camino"
272 + version = "1.2.5"
273 + source = "registry+https://github.com/rust-lang/crates.io-index"
274 + checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
275 + dependencies = [
276 + "serde_core",
277 + ]
278 +
279 + [[package]]
280 + name = "cargo-platform"
281 + version = "0.1.9"
282 + source = "registry+https://github.com/rust-lang/crates.io-index"
283 + checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
284 + dependencies = [
285 + "serde",
286 + ]
287 +
288 + [[package]]
289 + name = "cargo_metadata"
290 + version = "0.19.2"
291 + source = "registry+https://github.com/rust-lang/crates.io-index"
292 + checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
293 + dependencies = [
294 + "camino",
295 + "cargo-platform",
296 + "semver",
297 + "serde",
298 + "serde_json",
299 + "thiserror 2.0.20",
300 + ]
301 +
302 + [[package]]
303 + name = "cargo_toml"
304 + version = "0.22.3"
305 + source = "registry+https://github.com/rust-lang/crates.io-index"
306 + checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
307 + dependencies = [
308 + "serde",
309 + "toml 0.9.12+spec-1.1.0",
310 + ]
311 +
312 + [[package]]
313 + name = "cc"
314 + version = "1.4.2"
315 + source = "registry+https://github.com/rust-lang/crates.io-index"
316 + checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
317 + dependencies = [
318 + "find-msvc-tools",
319 + "shlex",
320 + ]
321 +
322 + [[package]]
323 + name = "cesu8"
324 + version = "1.1.0"
325 + source = "registry+https://github.com/rust-lang/crates.io-index"
326 + checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
327 +
328 + [[package]]
329 + name = "cfb"
330 + version = "0.7.3"
331 + source = "registry+https://github.com/rust-lang/crates.io-index"
332 + checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
333 + dependencies = [
334 + "byteorder",
335 + "fnv",
336 + "uuid",
337 + ]
338 +
339 + [[package]]
340 + name = "cfg-expr"
341 + version = "0.15.8"
342 + source = "registry+https://github.com/rust-lang/crates.io-index"
343 + checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
344 + dependencies = [
345 + "smallvec",
346 + "target-lexicon",
347 + ]
348 +
349 + [[package]]
350 + name = "cfg-if"
351 + version = "1.0.4"
352 + source = "registry+https://github.com/rust-lang/crates.io-index"
353 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
354 +
355 + [[package]]
356 + name = "chrono"
357 + version = "0.4.45"
358 + source = "registry+https://github.com/rust-lang/crates.io-index"
359 + checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
360 + dependencies = [
361 + "iana-time-zone",
362 + "num-traits",
363 + "serde",
364 + "windows-link 0.2.1",
365 + ]
366 +
367 + [[package]]
368 + name = "combine"
369 + version = "4.6.7"
370 + source = "registry+https://github.com/rust-lang/crates.io-index"
371 + checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
372 + dependencies = [
373 + "bytes",
374 + "memchr",
375 + ]
376 +
377 + [[package]]
378 + name = "cookie"
379 + version = "0.18.1"
380 + source = "registry+https://github.com/rust-lang/crates.io-index"
381 + checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
382 + dependencies = [
383 + "time",
384 + "version_check",
385 + ]
386 +
387 + [[package]]
388 + name = "core-foundation"
389 + version = "0.10.1"
390 + source = "registry+https://github.com/rust-lang/crates.io-index"
391 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
392 + dependencies = [
393 + "core-foundation-sys",
394 + "libc",
395 + ]
396 +
397 + [[package]]
398 + name = "core-foundation-sys"
399 + version = "0.8.7"
400 + source = "registry+https://github.com/rust-lang/crates.io-index"
401 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
402 +
403 + [[package]]
404 + name = "core-graphics"
405 + version = "0.25.0"
406 + source = "registry+https://github.com/rust-lang/crates.io-index"
407 + checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
408 + dependencies = [
409 + "bitflags 2.13.1",
410 + "core-foundation",
411 + "core-graphics-types",
412 + "foreign-types",
413 + "libc",
414 + ]
415 +
416 + [[package]]
417 + name = "core-graphics-types"
418 + version = "0.2.0"
419 + source = "registry+https://github.com/rust-lang/crates.io-index"
420 + checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
421 + dependencies = [
422 + "bitflags 2.13.1",
423 + "core-foundation",
424 + "libc",
425 + ]
426 +
427 + [[package]]
428 + name = "cpufeatures"
429 + version = "0.2.17"
430 + source = "registry+https://github.com/rust-lang/crates.io-index"
431 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
432 + dependencies = [
433 + "libc",
434 + ]
435 +
436 + [[package]]
437 + name = "crc32fast"
438 + version = "1.5.0"
439 + source = "registry+https://github.com/rust-lang/crates.io-index"
440 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
441 + dependencies = [
442 + "cfg-if",
443 + ]
444 +
445 + [[package]]
446 + name = "crossbeam-channel"
447 + version = "0.5.16"
448 + source = "registry+https://github.com/rust-lang/crates.io-index"
449 + checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
450 + dependencies = [
451 + "crossbeam-utils",
452 + ]
453 +
454 + [[package]]
455 + name = "crossbeam-utils"
456 + version = "0.8.22"
457 + source = "registry+https://github.com/rust-lang/crates.io-index"
458 + checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
459 +
460 + [[package]]
461 + name = "crypto-common"
462 + version = "0.1.7"
463 + source = "registry+https://github.com/rust-lang/crates.io-index"
464 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
465 + dependencies = [
466 + "generic-array",
467 + "typenum",
468 + ]
469 +
470 + [[package]]
471 + name = "cssparser"
472 + version = "0.36.0"
473 + source = "registry+https://github.com/rust-lang/crates.io-index"
474 + checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
475 + dependencies = [
476 + "cssparser-macros",
477 + "dtoa-short",
478 + "itoa",
479 + "phf",
480 + "smallvec",
481 + ]
482 +
483 + [[package]]
484 + name = "cssparser-macros"
485 + version = "0.6.1"
486 + source = "registry+https://github.com/rust-lang/crates.io-index"
487 + checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
488 + dependencies = [
489 + "quote",
490 + "syn 2.0.119",
491 + ]
492 +
493 + [[package]]
494 + name = "ctor"
495 + version = "0.8.0"
496 + source = "registry+https://github.com/rust-lang/crates.io-index"
497 + checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98"
498 + dependencies = [
499 + "ctor-proc-macro",
500 + "dtor",
501 + ]
502 +
503 + [[package]]
504 + name = "ctor-proc-macro"
505 + version = "0.0.7"
506 + source = "registry+https://github.com/rust-lang/crates.io-index"
507 + checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
508 +
509 + [[package]]
510 + name = "darling"
511 + version = "0.21.3"
512 + source = "registry+https://github.com/rust-lang/crates.io-index"
513 + checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
514 + dependencies = [
515 + "darling_core",
516 + "darling_macro",
517 + ]
518 +
519 + [[package]]
520 + name = "darling_core"
521 + version = "0.21.3"
522 + source = "registry+https://github.com/rust-lang/crates.io-index"
523 + checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
524 + dependencies = [
525 + "fnv",
526 + "ident_case",
527 + "proc-macro2",
528 + "quote",
529 + "strsim",
530 + "syn 2.0.119",
531 + ]
532 +
533 + [[package]]
534 + name = "darling_macro"
535 + version = "0.21.3"
536 + source = "registry+https://github.com/rust-lang/crates.io-index"
537 + checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
538 + dependencies = [
539 + "darling_core",
540 + "quote",
541 + "syn 2.0.119",
542 + ]
543 +
544 + [[package]]
545 + name = "dbus"
546 + version = "0.9.12"
547 + source = "registry+https://github.com/rust-lang/crates.io-index"
Lines truncated
M Cargo.toml +7 -1
@@ -3,14 +3,20 @@
3 3 members = [
4 4 "crates/quasi",
5 5 "crates/quasi-axum",
6 + "crates/quasi-http",
6 7 "crates/quasi-router",
8 + "crates/quasi-tauri",
7 9 ]
8 10
9 11 [workspace.dependencies]
10 12
11 13 [workspace.package]
12 14 edition = "2024"
13 - rust-version = "1.85"
15 + # 1.85 is Rust 2024's floor and is what this was. Raised to 1.88 on 2026-08-08
16 + # because the patched `time` (0.3.47, RUSTSEC-2026-0009) needs it, and a
17 + # declared floor that keeps a security fix out is buying nothing: nothing here
18 + # is published, and the toolchain is `stable`.
19 + rust-version = "1.88"
14 20 authors = ["Max Johnson <me@maxj.phd>"]
15 21 repository = "https://makenot.work/git/max/quasi"
16 22 license = "MIT"
M README.md +28 -16
@@ -57,14 +57,17 @@
57 57 ## Layout
58 58
59 59 - `crates/quasi-router/` — the host-agnostic router. The keystone.
60 + - `crates/quasi-http/` — the seam the http-shaped hosts share: decoding,
61 + status mapping, the htmx contract, the `Render` trait.
60 62 - `crates/quasi-axum/` — the axum host adapter.
63 + - `crates/quasi-tauri/` — the Tauri custom-protocol host adapter.
61 64 - `crates/quasi/` — the scaffolder binary.
62 65
63 66 Crates are added when their component starts, not up front.
64 67
65 68 ## Status
66 69
67 - `quasi-router` is implemented and `quasi` is still a stub.
70 + The router and both host adapters are implemented. `quasi` is still a stub.
68 71
69 72 The router carries the contract in full: one address space where the verb
70 73 separates a read from a write, a screen tree composed from `makeover-layout`'s
@@ -73,23 +76,32 @@
73 76 because the two renderers shipping first call it inside a frame and an event
74 77 loop.
75 78
76 - `quasi-axum` is the first host adapter, and it is thin on purpose. It decodes
77 - query strings and form bodies, calls the router on a blocking thread, turns an
78 - error's class into a status code, and turns a fragment's region into an
79 - `HX-Retarget` header. It mounts as a fallback, so ordinary axum routes merged in
80 - front of it keep serving the things a description has no word for: static
81 - assets, health, file uploads.
79 + `quasi-http` is what the two hosts turned out to share once there were two of
80 + them. Decoding a query string and a form body, mapping an error's class to a
81 + status, naming a fragment's region in an `HX-Retarget` header, and the htmx
82 + client configuration a classified error needs. Nothing in it knows which host it
83 + is in, and nothing in it is async.
82 84
83 - It emits no markup. A `Render` implementation supplies that, because the Tauri
84 - custom-protocol adapter serves HTML to a webview too, and generating it inside
85 - one host adapter would guarantee a second copy. Whole-screen markup is
86 - `makeover-webview` phase B and is not written yet, so an app brings its own
87 - renderer until it is.
85 + `quasi-axum` is the hosted adapter. What is left in it is axum's own: it mounts
86 + as a fallback, so ordinary axum routes merged in front keep serving the things a
87 + description has no word for (static assets, health, file uploads), it reads the
88 + body with a limit, and it makes the blocking hop the sync router needs.
88 89
89 - Not yet written: the Tauri custom-protocol adapter, which is blocked on
90 - measuring the URL form across webkit2gtk, WKWebView and WebView2. The scaffolder
91 - follows the router once its shape is proven against two hosts, which is why it
92 - is still a stub rather than half-written.
90 + `quasi-tauri` is the desktop and iOS adapter, a custom-protocol handler. The
91 + window loads from the adapter's own scheme rather than from tauri's asset
92 + protocol, which is what keeps every action a screen emits same-origin: no CORS,
93 + nothing added to the CSP. A `passthrough` closure is its version of axum's
94 + merged routes. There is no platform branch in it, because wry hands a handler
95 + `<scheme>://localhost/<path>` on Linux, macOS, iOS and Windows alike.
96 +
97 + Neither adapter emits markup. A `Render` implementation supplies that, because
98 + both serve HTML to a webview and generating it inside one would guarantee a
99 + second copy. Whole-screen markup is `makeover-webview` phase B and is not
100 + written yet, so an app brings its own renderer until it is.
101 +
102 + Not yet written: the scaffolder. It followed the router on the condition that
103 + the router's shape be proven against two hosts, which it now is, so it is next
104 + rather than blocked.
93 105
94 106 Design and sequencing live in the wiki note `quasi-overview`; the backlog is in
95 107 GoingsOn under project `quasicoherent`.
M deny.toml +45
@@ -9,6 +9,51 @@
9 9 version = 2
10 10 yanked = "deny"
11 11
12 + # Every entry below arrived on 2026-08-08 with `quasi-tauri`, and every one of
13 + # them is transitive through `tauri` with no version of anything we depend on
14 + # directly that avoids it. The two that had an upgrade path were taken rather
15 + # than listed: `time` went to 0.3.55 for RUSTSEC-2026-0009, which is what raised
16 + # the workspace `rust-version` to 1.88.
17 + #
18 + # An ignore list rots into a list of things nobody looks at, so this one has a
19 + # review task: GoingsOn `quasicoherent`, "Re-check the quasi-tauri advisory
20 + # ignores". Delete an entry the moment tauri's tree stops needing it.
21 + ignore = [
22 + # `plist` requires `quick-xml ^0.38` and the fix is 0.41, so there is no
23 + # upgrade until `plist` bumps. What quick-xml parses in this graph is
24 + # `Info.plist`, which is bundle metadata we generate, not input a user
25 + # sends. Both advisories are denial of service against the parsing thread.
26 + "RUSTSEC-2026-0194",
27 + "RUSTSEC-2026-0195",
28 +
29 + # gtk-rs GTK3 bindings, unmaintained. Ten crates, one cause: tauri's Linux
30 + # backend is GTK3 and the bindings were retired when gtk-rs moved to GTK4.
31 + # Unmaintained rather than vulnerable, and it resolves when tauri does.
32 + "RUSTSEC-2024-0411",
33 + "RUSTSEC-2024-0412",
34 + "RUSTSEC-2024-0413",
35 + "RUSTSEC-2024-0414",
36 + "RUSTSEC-2024-0415",
37 + "RUSTSEC-2024-0416",
38 + "RUSTSEC-2024-0417",
39 + "RUSTSEC-2024-0418",
40 + "RUSTSEC-2024-0419",
41 + "RUSTSEC-2024-0420",
42 +
43 + # `proc-macro-error`, unmaintained, via `glib-macros` and so via the same
44 + # GTK3 stack. A build-time proc macro: it runs on our machines over our own
45 + # source and is not in any shipped binary.
46 + "RUSTSEC-2024-0370",
47 +
48 + # The `unic-*` set, unmaintained, all five via `urlpattern` in
49 + # `tauri-utils`. Unicode character tables.
50 + "RUSTSEC-2025-0075",
51 + "RUSTSEC-2025-0080",
52 + "RUSTSEC-2025-0081",
53 + "RUSTSEC-2025-0098",
54 + "RUSTSEC-2025-0100",
55 + ]
56 +
12 57 [licenses]
13 58 version = 2
14 59 confidence-threshold = 0.9
@@ -14,9 +14,10 @@
14 14
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 + quasi-http = { path = "../quasi-http", version = "0.1.0" }
17 18 axum = "0.8.8"
19 + http = "1.3.1"
18 20 tokio = { version = "1.50.0", features = ["rt"] }
19 - form_urlencoded = "1.2.2"
20 21
21 22 [dev-dependencies]
22 23 tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread"] }
@@ -2,10 +2,11 @@
2 2 //!
3 3 //! <!-- wiki: quasi-overview -->
4 4 //!
5 - //! A host adapter is thin by design. This one turns an HTTP request into a
6 - //! path, a verb and a bag of named values, hands them to the router, and turns
7 - //! the answer back into a response. Everything it knows about the app is in the
8 - //! router; everything it knows about markup is in the [`Render`] it was given.
5 + //! A host adapter is thin by design, and since the Tauri protocol adapter
6 + //! arrived it is thinner still: decoding, status mapping and the retarget
7 + //! header live in [`quasi_http`], which both hosts call. What is left here is
8 + //! the part that is genuinely axum's, which is how it mounts, how it reads a
9 + //! body, and where it puts the blocking hop.
9 10 //!
10 11 //! Hosted web was described as free rather than aspirational, and this is what
11 12 //! that meant: a router returning descriptions plus a renderer emitting HTML is
@@ -14,15 +15,11 @@
14 15 //!
15 16 //! # What it owns
16 17 //!
17 - //! - Decoding. Query strings and form bodies arrive as [`Params`], because
18 - //! [`quasi_router`] deliberately does not parse either.
18 + //! - Mounting, as a fallback. See [`Adapter::into_router`].
19 + //! - Reading the body, with a limit, before anything is parsed.
19 20 //! - The blocking hop. The router is sync per decision 6, so the call happens
20 21 //! on a blocking thread, which is what an axum handler over a blocking store
21 22 //! pays anyway.
22 - //! - Status codes. A [`RouteError`]'s class becomes one, so a cache, a log and
23 - //! a monitor can all tell a denied action from a completed one.
24 - //! - Retargeting. A [`Response::Fragment`] names the region it replaces, and
25 - //! that becomes an `HX-Retarget` header.
26 23 //!
27 24 //! # What it does not own
28 25 //!
@@ -71,22 +68,11 @@
71 68
72 69 use axum::body::{Body, Bytes};
73 70 use axum::extract::Request;
74 - use axum::http::{HeaderValue, Method as HttpMethod, StatusCode, header};
75 71 use axum::response::Response as HttpResponse;
76 - use quasi_router::{Method, Node, Params, Response, RouteError, Router};
72 + use quasi_http::Refusal;
73 + use quasi_router::{RouteError, Router};
77 74
78 - pub mod htmx;
79 - pub mod render;
80 -
81 - pub use crate::render::Render;
82 -
83 - /// How much of a form body is read before the request is refused.
84 - ///
85 - /// A description-layer form is fields and choices, so a request an order of
86 - /// magnitude past this is a mistake or an attack rather than a long answer.
87 - /// File uploads do not come through here: they are their own axum route, since
88 - /// a byte stream is not something a description describes.
89 - pub const DEFAULT_BODY_LIMIT: usize = 256 * 1024;
75 + pub use quasi_http::{DEFAULT_BODY_LIMIT, Render, htmx};
90 76
91 77 /// The router, the app's state and a renderer, mounted as an axum service.
92 78 pub struct Adapter<S, R> {
@@ -156,170 +142,52 @@
156 142 {
157 143 let (parts, body) = request.into_parts();
158 144
159 - let Some(method) = translate(&parts.method) else {
160 - // quasi has two verbs, so this is the adapter's own refusal rather than
161 - // a route that is missing. `Allow` is required on a 405 and is the
162 - // difference between a client that can correct itself and one that
163 - // retries the same thing.
164 - return bare(
165 - StatusCode::METHOD_NOT_ALLOWED,
166 - [(header::ALLOW, "GET, POST")],
167 - );
145 + // The limit is applied while reading rather than after, because a hosted
146 + // server is the one host where the sender is not our own webview and the
147 + // bytes should never be buffered in the first place.
148 + let bytes: Bytes = match axum::body::to_bytes(body, context.body_limit).await {
149 + Ok(bytes) => bytes,
150 + Err(_) => return convert(quasi_http::refuse(Refusal::TooLarge)),
168 151 };
169 152
170 - let path = parts.uri.path().to_owned();
171 -
172 - // The body first, so a form field beats a query argument of the same name.
173 - // A form is the answer to the question the screen asked; a query argument
174 - // on a POST is context that came along with it.
175 - let mut params = Params::new();
176 - if method.mutates() && is_form(&parts.headers) {
177 - match read_form(body, context.body_limit).await {
178 - Ok(form) => params.absorb(form),
179 - Err(status) => return bare(status, []),
180 - }
181 - }
182 - params.absorb(decode(parts.uri.query().unwrap_or_default()));
153 + let incoming = match quasi_http::decode(
154 + &parts.method,
155 + &parts.uri,
156 + &parts.headers,
157 + &bytes,
158 + context.body_limit,
159 + ) {
160 + Ok(incoming) => incoming,
161 + Err(refusal) => return convert(quasi_http::refuse(refusal)),
162 + };
183 163
184 164 // Decision 6: the router is sync. Calling it directly would block the
185 165 // executor for however long the store takes.
186 166 let dispatch = {
187 167 let context = Arc::clone(&context);
188 168 tokio::task::spawn_blocking(move || {
189 - context.router.handle(&context.state, method, &path, params)
169 + context.router.handle(
170 + &context.state,
171 + incoming.method,
172 + &incoming.path,
173 + incoming.params,
174 + )
190 175 })
191 176 .await
192 177 };
193 178
194 - match dispatch {
195 - Ok(Ok(response)) => render(&*context.render, response),
196 - Ok(Err(error)) => render_error(&*context.render, &error),
179 + let outcome = dispatch.unwrap_or_else(|_| {
197 180 // A panic in a handler. Reported as ours, because it is.
198 - Err(_) => render_error(
199 - &*context.render,
200 - &RouteError::internal("the request could not be completed"),
201 - ),
202 - }
181 + Err(RouteError::internal("the request could not be completed"))
182 + });
183 +
184 + convert(quasi_http::respond(&*context.render, outcome))
203 185 }
204 186
205 - /// Turn an answer into a response.
206 - fn render<R: Render>(renderer: &R, response: Response) -> HttpResponse {
207 - match response {
208 - Response::Screen(screen) => html(
209 - renderer,
210 - StatusCode::OK,
211 - renderer.screen(&screen),
212 - Vec::new(),
213 - ),
214 - Response::Fragment { region, node } => {
215 - // The router said what it changed, so the client is told rather
216 - // than left to infer it from which element was clicked. The webview
217 - // renderer owes every slot an `id` matching its
218 - // `Slot::id` for this to land.
219 - let target = HeaderValue::try_from(format!("#{region}")).ok();
220 - let headers = target
221 - .map(|value| vec![(htmx::RETARGET, value)])
222 - .unwrap_or_default();
223 - html(renderer, StatusCode::OK, renderer.fragment(&node), headers)
224 - }
225 - }
226 - }
227 -
228 - /// Turn a failure into a response.
229 - ///
230 - /// The status comes from the class and the body from the notice, so an
231 - /// operator's tooling and the user's screen are reading the same event.
232 - ///
233 - /// The body is sent, and it is worth knowing that htmx will drop it unless the
234 - /// page configures [`htmx::RESPONSE_HANDLING`]: htmx 2's default does not swap
235 - /// a 4xx, so a classified error renders nothing at all.
236 - fn render_error<R: Render>(renderer: &R, error: &RouteError) -> HttpResponse {
237 - let node = Node::Notice {
238 - kind: error.notice,
239 - tone: error.tone(),
240 - text: error.message.clone(),
241 - };
242 - let status = StatusCode::from_u16(error.class.http_status())
243 - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
244 - html(renderer, status, renderer.fragment(&node), Vec::new())
245 - }
246 -
247 - /// A rendered body, with the renderer's own content type.
248 - fn html<R: Render>(
249 - renderer: &R,
250 - status: StatusCode,
251 - body: String,
252 - extra: Vec<(&'static str, HeaderValue)>,
253 - ) -> HttpResponse {
254 - let mut builder = HttpResponse::builder()
255 - .status(status)
256 - .header(header::CONTENT_TYPE, renderer.content_type());
257 - for (name, value) in extra {
258 - builder = builder.header(name, value);
259 - }
260 - builder
261 - .body(Body::from(body))
262 - .unwrap_or_else(|_| bare(StatusCode::INTERNAL_SERVER_ERROR, []))
263 - }
264 -
265 - /// A response with no body.
266 - fn bare<'a>(
267 - status: StatusCode,
268 - headers: impl IntoIterator<Item = (header::HeaderName, &'a str)>,
269 - ) -> HttpResponse {
270 - let mut builder = HttpResponse::builder().status(status);
271 - for (name, value) in headers {
272 - builder = builder.header(name, value);
273 - }
274 - builder
275 - .body(Body::empty())
276 - .expect("a response with no body and static headers is always valid")
277 - }
278 -
279 - /// The two verbs the description layer has, and nothing else.
280 - fn translate(method: &HttpMethod) -> Option<Method> {
281 - match *method {
282 - HttpMethod::GET => Some(Method::Get),
283 - HttpMethod::POST => Some(Method::Post),
284 - _ => None,
285 - }
286 - }
287 -
288 - /// Whether the body is a form this adapter reads.
289 - ///
290 - /// `multipart/form-data` is deliberately not read here. A file is a byte
291 - /// stream, a description has no word for one, and buffering an upload into
292 - /// [`Params`] would be the wrong shape at any size.
293 - fn is_form(headers: &axum::http::HeaderMap) -> bool {
294 - headers
295 - .get(header::CONTENT_TYPE)
296 - .and_then(|value| value.to_str().ok())
297 - .is_some_and(|value| {
298 - value.split(';').next().is_some_and(|kind| {
299 - kind.trim()
300 - .eq_ignore_ascii_case("application/x-www-form-urlencoded")
301 - })
302 - })
303 - }
304 -
305 - /// Read and decode a form body.
306 - async fn read_form(body: Body, limit: usize) -> Result<Params, StatusCode> {
307 - let bytes: Bytes = axum::body::to_bytes(body, limit)
308 - .await
309 - .map_err(|_| StatusCode::PAYLOAD_TOO_LARGE)?;
310 - let text = std::str::from_utf8(&bytes).map_err(|_| StatusCode::BAD_REQUEST)?;
311 - Ok(decode(text))
312 - }
313 -
314 - /// Percent-decoded name and value pairs, repeats kept.
315 - ///
316 - /// Repeats are the point: a checkbox group submits one name several times, and
317 - /// a decoder that keeps the last is a bug that only shows on the screen with
318 - /// the multi-select on it.
319 - fn decode(encoded: &str) -> Params {
320 - form_urlencoded::parse(encoded.as_bytes())
321 - .map(|(name, value)| (name.into_owned(), value.into_owned()))
322 - .collect()
187 + /// An `http` response with a `Vec` body becomes an axum one.
188 + fn convert(response: http::Response<Vec<u8>>) -> HttpResponse {
189 + let (parts, body) = response.into_parts();
190 + HttpResponse::from_parts(parts, Body::from(body))
323 191 }
324 192
325 193 #[cfg(test)]
@@ -5,9 +5,10 @@
5 5 //! is the small amount an HTTP host has to know to speak it correctly, which is
6 6 //! two response headers and one client configuration.
7 7 //!
8 - //! This belongs to the webview transport rather than to axum specifically, so
9 - //! it moves to a shared webview layer when the Tauri custom-protocol adapter
10 - //! arrives and becomes the second caller. It is here now because there is one.
8 + //! This belonged to the webview transport rather than to axum specifically,
9 + //! and lived in `quasi-axum` while axum was the only caller. The Tauri
10 + //! custom-protocol adapter is the second, so it moved here as its own doc
11 + //! comment said it would.
11 12
12 13 /// The header naming the element a response replaces.
13 14 ///
@@ -34,7 +35,7 @@
34 35 /// {"code":"[45]..","swap":false,"error":true}]
35 36 /// ```
36 37 ///
37 - /// Decision 9 has this adapter answer 403 for a denial and 404 for a missing
38 + /// Decision 9 has an adapter answer 403 for a denial and 404 for a missing
38 39 /// thing, so under the default the user sees **nothing at all** where a banner
39 40 /// was meant to be. That is not an optional refinement of the webview adapter,
40 41 /// it is a required piece of it.
@@ -1,12 +1,13 @@
1 1 //! The seam between a description and bytes.
2 2 //!
3 - //! This crate does not emit markup, and that is the important decision in it.
3 + //! No host adapter emits markup, and that is the important decision in the set
4 + //! of them.
4 5 //!
5 6 //! A hosted axum route and a Tauri custom-protocol handler both serve HTML to a
6 - //! webview. If the HTML were generated here, the protocol adapter would need a
7 - //! second copy of it, and two copies of a markup emitter is the divergence the
8 - //! whole stack exists to end. So markup belongs to the webview renderer, which
9 - //! is `makeover-webview`, and this crate takes it as a parameter.
7 + //! webview. If the HTML were generated in either, the other would need a second
8 + //! copy of it, and two copies of a markup emitter is the divergence the whole
9 + //! stack exists to end. So markup belongs to the webview renderer, which is
10 + //! `makeover-webview`, and every adapter takes it as a parameter.
10 11 //!
11 12 //! Today `makeover-webview` emits a stylesheet, form fields and table cells,
12 13 //! which is phase A. Whole-screen markup is phase B and is not written, so an
@@ -22,7 +23,7 @@
22 23 /// and a renderer genuinely does different work for each: a screen is a
23 24 /// document and a fragment is the inside of one element.
24 25 ///
25 - /// Errors are not a third method. The adapter turns a
26 + /// Errors are not a third method. An adapter turns a
26 27 /// [`RouteError`](quasi_router::RouteError) into a [`Node::Notice`] and asks
27 28 /// for a fragment, so failure becomes UI through the same path as everything
28 29 /// else. That is decision 9 holding at the boundary rather than only in the
@@ -1,0 +1,18 @@
1 + [package]
2 + name = "quasi-http"
3 + version = "0.1.0"
4 + description = "The http-shaped seam quasi's webview host adapters share: decoding in, a rendered description out"
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + repository.workspace = true
9 + license.workspace = true
10 + publish = false
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
16 + quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 + http = "1.3.1"
18 + form_urlencoded = "1.2.2"
@@ -1,0 +1,257 @@
1 + //! The http-shaped seam every webview host adapter shares.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! A hosted axum route and a Tauri custom-protocol handler are the same
6 + //! function wearing two coats. Both are handed a verb, a path, a query string
7 + //! and maybe a form body; both owe back a status, some headers and a rendered
8 + //! body; and both are talking to htmx on the other side. The coats are the
9 + //! parts that differ, and they are thin: how the bytes arrive, and which thread
10 + //! is allowed to block.
11 + //!
12 + //! So the middle lives here, in `http` types that neither host owns, and a host
13 + //! adapter is left with only its own coat. [`quasi_axum`] was the first caller
14 + //! and carried this code inline; the Tauri adapter is the second, and the
15 + //! [`htmx`] module said in its own doc comment that it would move when that
16 + //! happened.
17 + //!
18 + //! # What is here
19 + //!
20 + //! - [`decode`], which turns a request into the [`Method`], path and [`Params`]
21 + //! the router takes. [`quasi_router`] deliberately parses neither a query
22 + //! string nor a form body, because every host already has that code.
23 + //! - [`respond`] and [`refuse`], which turn the router's answer, or the
24 + //! adapter's own refusal, into a response.
25 + //! - [`Render`], the seam to markup, which is a parameter rather than an
26 + //! implementation for the reason its own docs give.
27 + //! - [`htmx`], the two response headers and one client configuration an http
28 + //! host has to know to speak the webview transport correctly.
29 + //!
30 + //! # What is not here
31 + //!
32 + //! Anything that knows which host it is in. There is no runtime, no thread and
33 + //! no socket in this crate, and nothing in it is `async`: the blocking hop is a
34 + //! host's own problem and the two hosts solve it differently. That is what
35 + //! keeps this callable from a Tauri protocol handler, which has no executor of
36 + //! its own to hand work to.
37 + //!
38 + //! [`quasi_axum`]: https://makenot.work/git/max/quasi
39 +
40 + use quasi_router::{Method, Node, Params, Response, RouteError};
41 +
42 + pub mod htmx;
43 + pub mod render;
44 +
45 + pub use crate::render::Render;
46 +
47 + /// How much of a form body is read before the request is refused.
48 + ///
49 + /// A description-layer form is fields and choices, so a request an order of
50 + /// magnitude past this is a mistake or an attack rather than a long answer.
51 + /// File uploads do not come through here: a byte stream is not something a
52 + /// description describes, and each host keeps its own path for them.
53 + pub const DEFAULT_BODY_LIMIT: usize = 256 * 1024;
54 +
55 + /// The verbs the description layer has, as an `Allow` header value.
56 + pub const ALLOWED_METHODS: &str = "GET, POST";
57 +
58 + /// A request, in the terms the router takes.
59 + #[derive(Debug, Clone, PartialEq, Eq)]
60 + pub struct Incoming {
61 + /// Asking or telling.
62 + pub method: Method,
63 + /// The path, with no scheme, host or query on it.
64 + pub path: String,
65 + /// Everything named the request carried, form ahead of query.
66 + pub params: Params,
67 + }
68 +
69 + /// A request the adapter turns away without troubling the router.
70 + ///
71 + /// Three, and all three are about the envelope rather than the address. A
72 + /// missing route is not here: that is a [`RouteError`] from the router, it is
73 + /// classified, and it renders a notice like any other failure.
74 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
75 + pub enum Refusal {
76 + /// A verb the description layer does not have.
77 + Method,
78 + /// A form body past the limit.
79 + TooLarge,
80 + /// A form body that is not UTF-8.
81 + Malformed,
82 + }
83 +
84 + impl Refusal {
85 + /// The status this refusal answers with.
86 + #[must_use]
87 + pub const fn status(self) -> u16 {
88 + match self {
89 + Self::Method => 405,
90 + Self::TooLarge => 413,
91 + Self::Malformed => 400,
92 + }
93 + }
94 + }
95 +
96 + /// Read a request into the router's terms.
97 + ///
98 + /// `body` is the bytes the host already has. Reading them is the host's job
99 + /// because the two hosts read them differently, and by the time either calls
100 + /// this the read has happened.
101 + ///
102 + /// # Ordering
103 + ///
104 + /// The form is absorbed before the query, and [`Params::get`] answers with the
105 + /// first match, so a form field beats a query argument of the same name. A form
106 + /// is the answer to the question the screen asked; a query argument on a POST is
107 + /// context that came along with it. The path capture beats both, and the router
108 + /// is what applies that.
109 + pub fn decode(
110 + method: &http::Method,
111 + uri: &http::Uri,
112 + headers: &http::HeaderMap,
113 + body: &[u8],
114 + body_limit: usize,
115 + ) -> Result<Incoming, Refusal> {
116 + let method = translate(method).ok_or(Refusal::Method)?;
117 +
118 + let mut params = Params::new();
119 + if method.mutates() && is_form(headers) {
120 + if body.len() > body_limit {
121 + return Err(Refusal::TooLarge);
122 + }
123 + let text = std::str::from_utf8(body).map_err(|_| Refusal::Malformed)?;
124 + params.absorb(decode_pairs(text));
125 + }
126 + params.absorb(decode_pairs(uri.query().unwrap_or_default()));
127 +
128 + Ok(Incoming {
129 + method,
130 + path: uri.path().to_owned(),
131 + params,
132 + })
133 + }
134 +
135 + /// Turn the router's answer into a response.
136 + ///
137 + /// Takes the `Result` whole rather than the two halves separately, because a
138 + /// failure is not a special case here: it becomes a [`Node::Notice`] and is
139 + /// rendered as a fragment down the same path as everything else. That is
140 + /// decision 9 holding at the boundary and not only in the router.
141 + ///
142 + /// A host with a way of failing the router does not have, such as a panicking
143 + /// handler or a worker that died, reports it as
144 + /// [`RouteError::internal`] and gets the same treatment.
145 + pub fn respond<R: Render + ?Sized>(
146 + render: &R,
147 + outcome: Result<Response, RouteError>,
148 + ) -> http::Response<Vec<u8>> {
149 + match outcome {
150 + Ok(Response::Screen(screen)) => body(render, 200, render.screen(&screen), None),
151 + Ok(Response::Fragment { region, node }) => {
152 + // The router said what it changed, so the client is told rather
153 + // than left to infer it from which element was clicked. The webview
154 + // renderer owes every slot an `id` matching its `Slot::id` for this
155 + // to land.
156 + body(
157 + render,
158 + 200,
159 + render.fragment(&node),
160 + Some(format!("#{region}")),
161 + )
162 + }
163 + Err(error) => {
164 + let node = Node::Notice {
165 + kind: error.notice,
166 + tone: error.tone(),
167 + text: error.message.clone(),
168 + };
169 + body(
170 + render,
171 + error.class.http_status(),
172 + render.fragment(&node),
173 + None,
174 + )
175 + }
176 + }
177 + }
178 +
179 + /// Turn the adapter's own refusal into a response.
180 + ///
181 + /// No body, because there is nothing to say that the status does not already
182 + /// say and no description was ever reached. `Allow` on a 405 is what a client
183 + /// needs to correct itself rather than retry the same thing.
184 + #[must_use]
185 + pub fn refuse(refusal: Refusal) -> http::Response<Vec<u8>> {
186 + let mut builder = http::Response::builder().status(refusal.status());
187 + if refusal == Refusal::Method {
188 + builder = builder.header(http::header::ALLOW, ALLOWED_METHODS);
189 + }
190 + builder
191 + .body(Vec::new())
192 + .expect("a response with no body and a static header is always valid")
193 + }
194 +
195 + /// A rendered body, with the renderer's own content type.
196 + fn body<R: Render + ?Sized>(
197 + render: &R,
198 + status: u16,
199 + rendered: String,
200 + retarget: Option<String>,
201 + ) -> http::Response<Vec<u8>> {
202 + let mut builder = http::Response::builder()
203 + .status(status)
204 + .header(http::header::CONTENT_TYPE, render.content_type());
205 + if let Some(target) = retarget {
206 + builder = builder.header(htmx::RETARGET, target);
207 + }
208 + builder.body(rendered.into_bytes()).unwrap_or_else(|_| {
209 + // Only reachable if a renderer answered with a content type that is not
210 + // a legal header value, which is our bug and not the request's.
211 + http::Response::builder()
212 + .status(500)
213 + .body(Vec::new())
214 + .expect("a response with no body and no headers is always valid")
215 + })
216 + }
217 +
218 + /// The two verbs the description layer has, and nothing else.
219 + fn translate(method: &http::Method) -> Option<Method> {
220 + match *method {
221 + http::Method::GET => Some(Method::Get),
222 + http::Method::POST => Some(Method::Post),
223 + _ => None,
224 + }
225 + }
226 +
227 + /// Whether the body is a form these adapters read.
228 + ///
229 + /// `multipart/form-data` is deliberately not read. A file is a byte stream, a
230 + /// description has no word for one, and buffering an upload into [`Params`]
231 + /// would be the wrong shape at any size. Such a request still routes, with no
232 + /// parameters from its body.
233 + fn is_form(headers: &http::HeaderMap) -> bool {
234 + headers
235 + .get(http::header::CONTENT_TYPE)
236 + .and_then(|value| value.to_str().ok())
237 + .is_some_and(|value| {
238 + value.split(';').next().is_some_and(|kind| {
239 + kind.trim()
240 + .eq_ignore_ascii_case("application/x-www-form-urlencoded")
241 + })
242 + })
243 + }
244 +
245 + /// Percent-decoded name and value pairs, repeats kept.
246 + ///
247 + /// Repeats are the point: a checkbox group submits one name several times, and
248 + /// a decoder that keeps the last is a bug that only shows on the screen with
249 + /// the multi-select on it.
250 + fn decode_pairs(encoded: &str) -> Params {
251 + form_urlencoded::parse(encoded.as_bytes())
252 + .map(|(name, value)| (name.into_owned(), value.into_owned()))
253 + .collect()
254 + }
255 +
256 + #[cfg(test)]
257 + mod tests;
@@ -1,0 +1,272 @@
1 + //! The decoding and response rules, tested without a host around them.
2 + //!
3 + //! Everything here would otherwise be tested twice, once through axum and once
4 + //! through a Tauri protocol handler, and the two copies would drift. What is
5 + //! left in each host's own tests is the part only that host has: its mounting,
6 + //! its body reading and its blocking hop.
7 +
8 + use quasi_router::{Class, Node, RegionKind, Response, RouteError, Screen, Slot};
9 +
10 + use super::{DEFAULT_BODY_LIMIT, Refusal, Render, decode, refuse, respond};
11 +
12 + /// A renderer that says what it was handed, so a test can read it back.
13 + struct Spy;
14 +
15 + impl Render for Spy {
16 + fn screen(&self, screen: &Screen) -> String {
17 + format!("screen:{}", screen.title)
18 + }
19 +
20 + fn fragment(&self, node: &Node) -> String {
21 + match node {
22 + Node::Text { text, .. } => format!("text:{text}"),
23 + Node::Notice { tone, text, .. } => format!("notice:{tone:?}:{text}"),
24 + other => format!("other:{other:?}"),
25 + }
26 + }
27 + }
28 +
29 + /// Decode a request built from its parts.
30 + fn read(
31 + method: &str,
32 + uri: &str,
33 + content_type: Option<&str>,
34 + body: &str,
35 + ) -> Result<super::Incoming, Refusal> {
36 + let mut builder = http::Request::builder().method(method).uri(uri);
37 + if let Some(kind) = content_type {
38 + builder = builder.header(http::header::CONTENT_TYPE, kind);
39 + }
40 + let request = builder.body(()).unwrap();
41 + decode(
42 + request.method(),
43 + request.uri(),
44 + request.headers(),
45 + body.as_bytes(),
46 + DEFAULT_BODY_LIMIT,
47 + )
48 + }
49 +
50 + /// A form POST, which is the shape every action arrives in.
51 + fn form(uri: &str, body: &str) -> Result<super::Incoming, Refusal> {
52 + read("POST", uri, Some("application/x-www-form-urlencoded"), body)
53 + }
54 +
55 + /// The params, flattened, so an assertion reads like the wire did.
56 + fn joined(incoming: &super::Incoming) -> String {
57 + incoming
58 + .params
59 + .iter()
60 + .map(|(k, v)| format!("{k}={v}"))
61 + .collect::<Vec<_>>()
62 + .join(",")
63 + }
64 +
65 + /// The body of a response, as text.
66 + fn text(response: &http::Response<Vec<u8>>) -> String {
67 + String::from_utf8(response.body().clone()).unwrap()
68 + }
69 +
70 + #[test]
71 + fn a_path_arrives_with_no_scheme_or_host_on_it() {
72 + // The spike's finding is what makes this one assertion enough for every
73 + // platform: wry reverts the Windows workaround before the handler is
74 + // called, so a custom-protocol request is `<scheme>://localhost/<path>`
75 + // everywhere and `Uri::path` is the whole address either way.
76 + let incoming = read("GET", "quasi://localhost/task/7", None, "").unwrap();
77 + assert_eq!(incoming.path, "/task/7");
78 +
79 + let hosted = read("GET", "/task/7", None, "").unwrap();
80 + assert_eq!(hosted.path, "/task/7");
81 + }
82 +
83 + #[test]
84 + fn a_query_string_is_percent_decoded() {
85 + let incoming = read("GET", "/task/7?note=a%20b&flag=1", None, "").unwrap();
86 + assert_eq!(joined(&incoming), "note=a b,flag=1");
87 + }
88 +
89 + #[test]
90 + fn a_form_body_is_decoded_on_a_post() {
91 + let incoming = form("/task/7/edit", "title=new+title").unwrap();
92 + assert_eq!(joined(&incoming), "title=new title");
93 + }
94 +
95 + #[test]
96 + fn a_form_field_beats_a_query_argument_of_the_same_name() {
97 + let incoming = form("/task/7/edit?title=from-query", "title=from-form").unwrap();
98 + assert_eq!(incoming.params.get("title"), Some("from-form"));
99 + }
100 +
101 + #[test]
102 + fn repeated_names_all_survive() {
103 + let incoming = form("/tags", "tag=rust&tag=router&tag=quasi").unwrap();
104 + assert_eq!(
105 + incoming.params.get_all("tag").collect::<Vec<_>>(),
106 + ["rust", "router", "quasi"]
107 + );
108 + }
109 +
110 + #[test]
111 + fn a_get_never_reads_a_body_even_when_one_is_sent() {
112 + // A safe verb with a body is either a confused client or a smuggling
113 + // attempt, and quasi has no route that would want it either way.
114 + let incoming = read(
115 + "GET",
116 + "/task/7",
117 + Some("application/x-www-form-urlencoded"),
118 + "title=ignored",
119 + )
120 + .unwrap();
121 + assert!(incoming.params.is_empty());
122 + }
123 +
124 + #[test]
125 + fn a_body_that_is_not_a_form_is_ignored_rather_than_guessed_at() {
126 + let incoming = read(
127 + "POST",
128 + "/task/7/edit",
129 + Some("multipart/form-data; boundary=xyz"),
130 + "--xyz--",
131 + )
132 + .unwrap();
133 + assert!(incoming.params.is_empty());
134 + }
135 +
136 + #[test]
137 + fn a_charset_on_the_form_content_type_still_reads_as_a_form() {
138 + let incoming = read(
139 + "POST",
140 + "/task/7/edit",
141 + Some("application/x-www-form-urlencoded; charset=utf-8"),
142 + "title=ok",
143 + )
144 + .unwrap();
145 + assert_eq!(incoming.params.get("title"), Some("ok"));
146 + }
147 +
148 + #[test]
149 + fn a_verb_the_description_layer_lacks_is_refused() {
150 + assert_eq!(read("DELETE", "/task/7", None, ""), Err(Refusal::Method));
151 + assert_eq!(read("PUT", "/task/7", None, ""), Err(Refusal::Method));
152 + }
153 +
154 + #[test]
155 + fn an_oversized_form_is_refused_before_it_is_parsed() {
156 + let request = http::Request::builder()
157 + .method("POST")
158 + .uri("/tags")
159 + .header(
160 + http::header::CONTENT_TYPE,
161 + "application/x-www-form-urlencoded",
162 + )
163 + .body(())
164 + .unwrap();
165 + let body = "tag=".to_owned() + &"x".repeat(1024);
166 + let outcome = decode(
167 + request.method(),
168 + request.uri(),
169 + request.headers(),
170 + body.as_bytes(),
171 + 16,
172 + );
173 + assert_eq!(outcome, Err(Refusal::TooLarge));
174 + }
175 +
176 + #[test]
177 + fn a_form_body_that_is_not_utf8_is_refused() {
178 + let request = http::Request::builder()
179 + .method("POST")
180 + .uri("/tags")
181 + .header(
182 + http::header::CONTENT_TYPE,
183 + "application/x-www-form-urlencoded",
184 + )
185 + .body(())
186 + .unwrap();
187 + let outcome = decode(
188 + request.method(),
189 + request.uri(),
190 + request.headers(),
191 + &[0xff, 0xfe],
192 + DEFAULT_BODY_LIMIT,
193 + );
194 + assert_eq!(outcome, Err(Refusal::Malformed));
195 + }
196 +
197 + #[test]
198 + fn a_refusal_of_the_verb_says_which_verbs_there_are() {
199 + let response = refuse(Refusal::Method);
200 + assert_eq!(response.status(), 405);
201 + assert_eq!(
202 + response.headers().get(http::header::ALLOW).unwrap(),
203 + "GET, POST"
204 + );
205 + assert!(response.body().is_empty());
206 + }
207 +
208 + #[test]
209 + fn a_screen_is_served_whole_and_names_no_target() {
210 + let screen = Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane));
211 + let response = respond(&Spy, Ok(screen.into()));
212 + assert_eq!(response.status(), 200);
213 + assert_eq!(text(&response), "screen:Home");
214 + assert!(response.headers().get(super::htmx::RETARGET).is_none());
215 + }
216 +
217 + #[test]
218 + fn a_fragment_carries_the_region_it_replaces() {
219 + let answer = Response::fragment("detail", Node::text("hello"));
220 + let response = respond(&Spy, Ok(answer));
221 + assert_eq!(text(&response), "text:hello");
222 + // A slot id becomes a CSS selector, which is what htmx wants.
223 + assert_eq!(
224 + response.headers().get(super::htmx::RETARGET).unwrap(),
225 + "#detail"
226 + );
227 + }
228 +
229 + #[test]
230 + fn every_class_becomes_its_status_with_the_notice_as_the_body() {
231 + for (error, status) in [
232 + (RouteError::denied("not yours"), 403),
233 + (RouteError::new(Class::NotFound, "gone"), 404),
234 + (RouteError::internal("our fault"), 500),
235 + ] {
236 + let expected = format!("notice:{:?}:{}", error.tone(), error.message);
237 + let response = respond(&Spy, Err(error));
238 + assert_eq!(response.status(), status);
239 + assert_eq!(text(&response), expected);
240 + }
241 + }
242 +
243 + #[test]
244 + fn an_error_body_is_sent_rather_than_left_to_the_status() {
245 + // htmx will drop it unless the page carries `htmx::CONFIG_META`, which is
246 + // exactly why that constant is not optional.
247 + let response = respond(&Spy, Err(RouteError::denied("not yours")));
248 + assert!(!response.body().is_empty());
249 + }
250 +
251 + #[test]
252 + fn the_content_type_is_the_renderers_answer() {
253 + struct Json;
254 + impl Render for Json {
255 + fn screen(&self, _: &Screen) -> String {
256 + "{}".to_owned()
257 + }
258 + fn fragment(&self, _: &Node) -> String {
259 + "{}".to_owned()
260 + }
261 + fn content_type(&self) -> &'static str {
262 + "application/json"
263 + }
264 + }
265 +
266 + let screen = Screen::sidebar_content("Home");
267 + let response = respond(&Json, Ok(screen.into()));
268 + assert_eq!(
269 + response.headers().get(http::header::CONTENT_TYPE).unwrap(),
270 + "application/json"
271 + );
272 + }
@@ -1,0 +1,29 @@
1 + [package]
2 + name = "quasi-tauri"
3 + version = "0.1.0"
4 + description = "The Tauri custom-protocol host adapter for quasi-router: a webview request in, a rendered description out"
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + repository.workspace = true
9 + license.workspace = true
10 + publish = false
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
16 + quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 + quasi-http = { path = "../quasi-http", version = "0.1.0" }
18 + http = "1.3.1"
19 + # Default features off: this crate uses tauri's protocol registration and its
20 + # blocking pool, and nothing else. An app brings the features it wants, and the
21 + # adapter should not be what turns on a tray icon or a webview devtools build.
22 + tauri = { version = "2.11.5", default-features = false }
23 +
24 + [dev-dependencies]
25 + # The doctest names a concrete `Runtime`, and `Wry` is behind a default feature
26 + # this crate does not otherwise ask for. Only the example needs it: nothing in
27 + # the test module builds a window, which is what keeps them runnable with no
28 + # display.
29 + tauri = { version = "2.11.5" }