max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
15 files changed,
+1782 insertions,
-202 deletions
| @@ -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 |
| @@ -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
| @@ -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" |
| @@ -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`. |
| @@ -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" } |