mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-17 17:12:13 +02:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366831df5b | ||
|
|
2d2a390bfd | ||
|
|
5cce23566a | ||
|
|
e99f6d2bc7 | ||
|
|
bea58b16b4 | ||
|
|
93fba4d9b4 | ||
|
|
b9071eafe0 | ||
|
|
778c74c635 | ||
|
|
0f434361a7 | ||
|
|
d27d11af7c | ||
|
|
1a19a06a23 | ||
|
|
07a9a6c6c0 | ||
|
|
e54240d579 | ||
|
|
8bca013ab4 | ||
|
|
10e962a0e6 | ||
|
|
78954e10c8 | ||
|
|
9eb7a001da | ||
|
|
6a02cbe525 | ||
|
|
6f91f76064 | ||
|
|
32e92d484b | ||
|
|
cdbbef34f8 | ||
|
|
4838353585 | ||
|
|
93001e3da7 | ||
|
|
b31c066717 | ||
|
|
5d1d24870a | ||
|
|
6f0d0ef275 | ||
|
|
85a9b2a908 | ||
|
|
f3f05502d1 | ||
|
|
2e0f7d1818 | ||
|
|
dc793181bb | ||
|
|
7dfa7e07e3 |
@@ -103,6 +103,18 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
||||
# crates/yaak-web compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
|
||||
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
|
||||
# through to host glibc headers ("bits/libc-header-start.h" not found).
|
||||
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
|
||||
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
|
||||
# this compiler, so the shipped binary keeps 22.04's glibc floor.
|
||||
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
|
||||
chmod +x /tmp/llvm.sh
|
||||
sudo /tmp/llvm.sh 18
|
||||
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
|
||||
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
|
||||
Generated
+378
-77
@@ -2,6 +2,18 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "accessory"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc86a613208ca7d144ca24f6ec49c5cdfa5c05a46f2f7ab5880ff99371f165de"
|
||||
dependencies = [
|
||||
"macroific 3.0.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.0"
|
||||
@@ -40,18 +52,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "0.6.10"
|
||||
@@ -249,7 +249,7 @@ dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"url",
|
||||
@@ -265,7 +265,7 @@ dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
@@ -585,9 +585,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.1"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
|
||||
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
@@ -595,14 +595,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.38.0"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
|
||||
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1066,10 +1067,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.26"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac"
|
||||
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
@@ -1386,6 +1388,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
@@ -1870,6 +1882,20 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
|
||||
|
||||
[[package]]
|
||||
name = "delegate-display"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54e3a499943fd5180aeffcb708164ffb22125ceeccb05491b5297981d37d6249"
|
||||
dependencies = [
|
||||
"impartial-ord",
|
||||
"itoa",
|
||||
"macroific 3.0.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
@@ -2361,6 +2387,18 @@ dependencies = [
|
||||
"regex-syntax 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy_constructor"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a8211ab12c36b63c269e17873331dc1da196a3edb7a5c79015677ef972da34a"
|
||||
dependencies = [
|
||||
"macroific 3.0.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fast-glob"
|
||||
version = "1.0.0"
|
||||
@@ -2417,6 +2455,12 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.4.2"
|
||||
@@ -2849,8 +2893,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3091,7 +3137,7 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
dependencies = [
|
||||
"ahash 0.7.8",
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3099,9 +3145,6 @@ name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -3131,11 +3174,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3497,6 +3540,17 @@ dependencies = [
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "impartial-ord"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc680e03d1a08bdc1c01b60e0c39723430ced63d8cabd93cee0449b6ba916a25"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include_dir"
|
||||
version = "0.7.4"
|
||||
@@ -3516,6 +3570,40 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexed_db_futures"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69ff41758cbd104e91033bb53bc449bec7eea65652960c81eddf3fc146ecea19"
|
||||
dependencies = [
|
||||
"accessory",
|
||||
"cfg-if",
|
||||
"delegate-display",
|
||||
"derive_more 2.1.1",
|
||||
"fancy_constructor",
|
||||
"indexed_db_futures_macros_internal",
|
||||
"js-sys",
|
||||
"sealed 0.6.0",
|
||||
"smallvec",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexed_db_futures_macros_internal"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caeba94923b68f254abef921cea7e7698bf4675fdd89d7c58bf1ed885b49a27d"
|
||||
dependencies = [
|
||||
"macroific 2.0.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -3561,17 +3649,6 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inherent"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c38228f24186d9cc68c729accb4d413be9eaed6ad07ff79e0270d9e56f3de13"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.0"
|
||||
@@ -4007,9 +4084,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -4109,6 +4186,102 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "macroific"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285"
|
||||
dependencies = [
|
||||
"macroific_attr_parse 2.0.0",
|
||||
"macroific_core 2.0.0",
|
||||
"macroific_macro 2.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c437abb3bf40939b00d1dff858ed2050d948bc230dee39546ed16795fcf10ec2"
|
||||
dependencies = [
|
||||
"macroific_attr_parse 3.0.1",
|
||||
"macroific_core 3.0.1",
|
||||
"macroific_macro 3.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_attr_parse"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sealed 0.6.0",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_attr_parse"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeb7a82ec1aa16094da719be18615d79cb2574b037358e4e6c6d96eedcff3a93"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sealed 0.7.0",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_core"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sealed 0.6.0",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_core"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26c8e789484d2fa216eee9d5e0565ba80ccda389bcf0e111143a7b8f4792336e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sealed 0.7.0",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_macro"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b"
|
||||
dependencies = [
|
||||
"macroific_attr_parse 2.0.0",
|
||||
"macroific_core 2.0.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_macro"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e30d63cb102648965753f0d47a6ab171b1cbb1fb5c363f4784ea986782fc7b77"
|
||||
dependencies = [
|
||||
"macroific_attr_parse 3.0.1",
|
||||
"macroific_core 3.0.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -4291,7 +4464,7 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4851,15 +5024,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.73"
|
||||
version = "0.10.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
|
||||
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
@@ -4883,18 +5055,18 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.5.0+3.5.0"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.109"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -5708,7 +5880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
||||
dependencies = [
|
||||
"phf_shared 0.10.0",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5718,7 +5890,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6242,9 +6414,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "r2d2_sqlite"
|
||||
version = "0.25.0"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb14dba8247a6a15b7fdbc7d389e2e6f03ee9f184f87117706d509c092dfe846"
|
||||
checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6"
|
||||
dependencies = [
|
||||
"r2d2",
|
||||
"rusqlite",
|
||||
@@ -6283,9 +6455,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -6294,9 +6466,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.1"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
@@ -7130,10 +7302,20 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"chrono",
|
||||
@@ -7142,6 +7324,7 @@ dependencies = [
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7165,7 +7348,7 @@ dependencies = [
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -7423,20 +7606,20 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sea-query"
|
||||
version = "0.32.6"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64c91783d1514b99754fc6a4079081dcc2c587dadbff65c48c7f62297443536a"
|
||||
checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"inherent",
|
||||
"itoa",
|
||||
"sea-query-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sea-query-derive"
|
||||
version = "0.4.3"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
|
||||
checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537"
|
||||
dependencies = [
|
||||
"darling 0.20.11",
|
||||
"heck 0.4.1",
|
||||
@@ -7448,9 +7631,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sea-query-rusqlite"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3743bbdfb24b1a84cc1a6fbf4b1188e6851f6e00ea20944b44c56bf03a585bb4"
|
||||
checksum = "1ec6038023c8517c623e5bf9606b3c54d40bc8296bb6b2986040428dd84deddd"
|
||||
dependencies = [
|
||||
"rusqlite",
|
||||
"sea-query",
|
||||
@@ -7462,6 +7645,28 @@ version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "sealed"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sealed"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
@@ -7861,9 +8066,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
@@ -8031,6 +8236,34 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-vfs"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f7a5c9ac229421d577bb5a9bb59048838509958b218dd4e0b3c1214a87c361e"
|
||||
dependencies = [
|
||||
"indexed_db_futures",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.0"
|
||||
@@ -8165,6 +8398,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -9265,7 +9509,7 @@ dependencies = [
|
||||
"indexmap 1.9.3",
|
||||
"pin-project",
|
||||
"pin-project-lite",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -9482,7 +9726,7 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log 0.4.29",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
@@ -9752,7 +9996,7 @@ checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"js-sys",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -10952,6 +11196,7 @@ name = "yaak"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"log 0.4.29",
|
||||
"md5 0.8.0",
|
||||
"serde_json",
|
||||
@@ -10996,7 +11241,7 @@ dependencies = [
|
||||
"pretty_graphql",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.12.20",
|
||||
"rlimit",
|
||||
"serde",
|
||||
@@ -11023,6 +11268,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"yaak",
|
||||
"yaak-api",
|
||||
"yaak-commands",
|
||||
"yaak-common",
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
@@ -11035,6 +11281,7 @@ dependencies = [
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-rpc",
|
||||
"yaak-rpc-schema",
|
||||
"yaak-sse",
|
||||
"yaak-sync",
|
||||
"yaak-system-appearance",
|
||||
@@ -11079,7 +11326,7 @@ dependencies = [
|
||||
"log 0.4.29",
|
||||
"oxc_resolver",
|
||||
"predicates",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"reqwest 0.12.20",
|
||||
"rolldown",
|
||||
"schemars 0.8.22",
|
||||
@@ -11101,6 +11348,23 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-commands"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"yaak",
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-rpc-schema",
|
||||
"yaak-templates",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-common"
|
||||
version = "0.1.0"
|
||||
@@ -11135,6 +11399,7 @@ name = "yaak-database"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"getrandom 0.2.16",
|
||||
"include_dir",
|
||||
"log 0.4.29",
|
||||
"nanoid",
|
||||
@@ -11147,6 +11412,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11268,7 +11534,7 @@ dependencies = [
|
||||
"csscolorparser",
|
||||
"log 0.4.29",
|
||||
"objc",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
@@ -11310,7 +11576,7 @@ dependencies = [
|
||||
"log 0.4.29",
|
||||
"md5 0.7.0",
|
||||
"path-slash",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.12.20",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -11372,6 +11638,22 @@ dependencies = [
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-rpc-schema"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"ts-rs",
|
||||
"yaak-git",
|
||||
"yaak-grpc",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-sse",
|
||||
"yaak-sync",
|
||||
"yaak-templates",
|
||||
"yaak-ws",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-sse"
|
||||
version = "0.1.0"
|
||||
@@ -11414,6 +11696,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"regex 1.11.1",
|
||||
"tauri",
|
||||
"yaak-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11437,6 +11720,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"p12",
|
||||
"pem",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-platform-verifier",
|
||||
@@ -11447,13 +11731,30 @@ dependencies = [
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
"log 0.4.29",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"sqlite-wasm-rs",
|
||||
"sqlite-wasm-vfs",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"yaak-models",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-window"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"md5 0.8.0",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/yaak",
|
||||
"crates/yaak-commands",
|
||||
# Common/foundation crates
|
||||
"crates/common/yaak-database",
|
||||
"crates/common/yaak-rpc",
|
||||
"crates/common/yaak-rpc-schema",
|
||||
# Shared crates (no Tauri dependency)
|
||||
"crates/yaak-core",
|
||||
"crates/yaak-common",
|
||||
@@ -19,6 +21,7 @@ members = [
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-tls",
|
||||
"crates/yaak-ws",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-api",
|
||||
"crates/yaak-proxy",
|
||||
# Proxy-specific crates
|
||||
@@ -63,10 +66,12 @@ ts-rs = "11.1.0"
|
||||
# Internal crates - common/foundation
|
||||
yaak-database = { path = "crates/common/yaak-database" }
|
||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
||||
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
|
||||
|
||||
# Internal crates - shared
|
||||
yaak-core = { path = "crates/yaak-core" }
|
||||
yaak = { path = "crates/yaak" }
|
||||
yaak-commands = { path = "crates/yaak-commands" }
|
||||
yaak-common = { path = "crates/yaak-common" }
|
||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||
yaak-git = { path = "crates/yaak-git" }
|
||||
|
||||
@@ -44,6 +44,25 @@ After bootstrapping, start the app in development mode:
|
||||
npm start
|
||||
```
|
||||
|
||||
## Run the App in a Browser
|
||||
|
||||
The client can also run as a plain web page, with no Tauri and no local process
|
||||
behind it. Set `YAAK_TARGET=web` and start the frontend on its own:
|
||||
|
||||
```shell
|
||||
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||
```
|
||||
|
||||
That flag picks the browser host in `packages/platform/src/web/`, which answers
|
||||
commands from an IndexedDB database the page owns instead of from the Rust
|
||||
engine. Data persists across reloads and is shared between tabs on the same
|
||||
origin. Sending HTTP is not available yet — the Send button reports that and
|
||||
everything else about the request is still saved. `packages/platform/src/web/README.md`
|
||||
lists which commands the browser host implements and which it declines.
|
||||
|
||||
Desktop builds are unaffected: without the flag the platform package installs
|
||||
the Tauri host exactly as before.
|
||||
|
||||
## SQLite Migrations
|
||||
|
||||
New migrations can be created from the `src-tauri/` directory:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { SettingsTab } from "../components/Settings/Settings";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
@@ -14,11 +15,19 @@ export const openSettings = createFastMutation<void, string, SettingsTabWithSubt
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId/settings",
|
||||
params: { workspaceId },
|
||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||
});
|
||||
const to = "/workspaces/$workspaceId/settings" as const;
|
||||
const params = { workspaceId };
|
||||
const search = { tab: (tab ?? undefined) as SettingsTab | undefined };
|
||||
|
||||
// Settings is its own window where the host has windows to give. Where it
|
||||
// doesn't — a browser tab — the same route opens in place, which is the
|
||||
// whole difference: it is already a route, not a separate app.
|
||||
if (!platform.capabilities.multiWindow) {
|
||||
await router.navigate({ to, params, search });
|
||||
return;
|
||||
}
|
||||
|
||||
const location = router.buildLocation({ to, params, search });
|
||||
|
||||
await rpc("cmd_new_child_window", {
|
||||
url: location.href,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||
@@ -24,7 +25,9 @@ export const switchWorkspace = createFastMutation<
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
if (inNewWindow) {
|
||||
// A host without windows opens the workspace here instead. Refusing would
|
||||
// strand the user on the workspace they were trying to leave.
|
||||
if (inNewWindow && platform.capabilities.multiWindow) {
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
||||
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
|
||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||
@@ -409,14 +410,13 @@ function EnsureCompleteResponse({
|
||||
Component,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
Component: ComponentType<{ bodyPath: string }>;
|
||||
Component: ComponentType<{ bodyUrl: string }>;
|
||||
}) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
// Wait until the response has been fully-downloaded before asking for it
|
||||
const complete = response.state === "closed";
|
||||
const bodyUrl = useResponseBodyUrl(complete ? response : null);
|
||||
|
||||
// Wait until the response has been fully-downloaded
|
||||
if (response.state !== "closed") {
|
||||
if (!complete || bodyUrl.isPending) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
@@ -424,7 +424,15 @@ function EnsureCompleteResponse({
|
||||
);
|
||||
}
|
||||
|
||||
return <Component bodyPath={response.bodyPath} />;
|
||||
if (bodyUrl.error) {
|
||||
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
|
||||
}
|
||||
|
||||
if (bodyUrl.data == null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
return <Component bodyUrl={bodyUrl.data} />;
|
||||
}
|
||||
|
||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||
|
||||
@@ -1,56 +1,138 @@
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Button } from "./core/Button";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
interface Props {
|
||||
importData: (filePath: string) => Promise<void>;
|
||||
importFile: (filePath: string) => Promise<void>;
|
||||
importUrl: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ImportDataDialog({ importData }: Props) {
|
||||
/**
|
||||
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
|
||||
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
|
||||
*/
|
||||
function isFilePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("./") ||
|
||||
value.startsWith("../") ||
|
||||
value.startsWith("~/") ||
|
||||
value.startsWith("\\\\") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function fileName(path: string): string {
|
||||
return path.split(/[/\\]/).at(-1) || path;
|
||||
}
|
||||
|
||||
export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
|
||||
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
||||
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
|
||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const trimmedSource = source?.trim() ?? "";
|
||||
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
|
||||
|
||||
const selectSource = (value: string) => {
|
||||
setSource(value);
|
||||
// Remount the input so it shows the path of the newly-picked file
|
||||
setForceUpdateKey((k) => k + 1);
|
||||
};
|
||||
|
||||
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
|
||||
useEffect(() => {
|
||||
return platform.window.onDragDrop((event) => {
|
||||
if (event.type === "over") {
|
||||
const p = event.position;
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (r == null) return;
|
||||
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
|
||||
} else if (event.type === "drop" && isHovering) {
|
||||
const p = event.paths[0];
|
||||
if (p) selectSource(p);
|
||||
setIsHovering(false);
|
||||
} else {
|
||||
setIsHovering(false);
|
||||
}
|
||||
});
|
||||
}, [isHovering, setSource]);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
||||
if (selected == null) return;
|
||||
selectSource(selected);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (filePath != null) {
|
||||
await importFile(filePath);
|
||||
} else {
|
||||
await importUrl(trimmedSource);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={5} className="pb-4">
|
||||
<VStack ref={ref} space={4} className="pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
|
||||
<VStack space={1}>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>OpenAPI 3.0, 3.1</li>
|
||||
<li>Postman Collection v2, v2.1</li>
|
||||
<li>Insomnia v4+</li>
|
||||
<li>Swagger 2.0</li>
|
||||
<li>
|
||||
Curl commands <em className="text-text-subtle">(or paste into URL)</em>
|
||||
</li>
|
||||
</ul>
|
||||
</VStack>
|
||||
<VStack space={2}>
|
||||
<SelectFile
|
||||
filePath={filePath ?? null}
|
||||
onChange={({ filePath }) => setFilePath(filePath)}
|
||||
/>
|
||||
{filePath && (
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={!filePath || isLoading}
|
||||
isLoading={isLoading}
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await importData(filePath);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Importing" : "Import"}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectFile}
|
||||
className={classNames(
|
||||
"w-full rounded-lg border border-dashed px-4 py-6",
|
||||
"flex flex-col items-center gap-1 text-center",
|
||||
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
|
||||
)}
|
||||
>
|
||||
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
||||
{/* Fixed height so the region doesn't resize between the empty and selected states */}
|
||||
<div className="h-6 w-full flex items-center justify-center">
|
||||
{filePath == null ? (
|
||||
<div className="text-text">
|
||||
<strong className="font-semibold">Choose a file</strong> or drag it here
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
|
||||
{fileName(filePath)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text-subtlest">
|
||||
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<VStack space={2}>
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={trimmedSource === "" || isLoading}
|
||||
isLoading={isLoading}
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
>
|
||||
{isLoading ? "Importing" : "Import"}
|
||||
</Button>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
@@ -36,12 +36,16 @@ import { fireAndForget } from "../../lib/fireAndForget";
|
||||
import { ErrorBoundary } from "../ErrorBoundary";
|
||||
import { Button } from "./Button";
|
||||
import { Hotkey } from "./Hotkey";
|
||||
import { IconButton } from "./IconButton";
|
||||
import type { SeparatorAction } from "./Separator";
|
||||
import { Separator } from "./Separator";
|
||||
|
||||
export type DropdownItemSeparator = {
|
||||
type: "separator";
|
||||
label?: ReactNode;
|
||||
hidden?: boolean;
|
||||
/** A control shown beside the label, eg. revealing the labelled file on disk. */
|
||||
action?: SeparatorAction;
|
||||
};
|
||||
|
||||
export type DropdownItemContent = {
|
||||
@@ -66,6 +70,12 @@ export type DropdownItemDefault = {
|
||||
submenu?: DropdownItem[];
|
||||
/** If true, submenu opens on click instead of hover */
|
||||
submenuOpenOnClick?: boolean;
|
||||
/**
|
||||
* How the submenu opens. "row" (default) opens it from the row itself (hover, or click
|
||||
* with submenuOpenOnClick). "button" keeps the row selectable via onSelect and renders
|
||||
* a dedicated button on the right that opens the submenu.
|
||||
*/
|
||||
submenuTrigger?: "row" | "button";
|
||||
icon?: IconProps["icon"];
|
||||
};
|
||||
|
||||
@@ -502,9 +512,15 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.keepOpenOnSelect) handleCloseAll();
|
||||
if (!item.keepOpenOnSelect) {
|
||||
handleCloseAll();
|
||||
} else if (isSubmenu) {
|
||||
// Keep the parent menu open, but close this submenu — its items may no
|
||||
// longer describe the row after the action (e.g. Pin → Unpin, Remove)
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleCloseAll, setSelectedIndex],
|
||||
[handleCloseAll, handleClose, isSubmenu, setSelectedIndex],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
@@ -629,7 +645,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
||||
const item = filteredItems[selectedIndex ?? -1];
|
||||
if (!item || item.type === "separator" || item.type === "content") return;
|
||||
e.preventDefault();
|
||||
if (item.submenu) {
|
||||
if (item.submenu && item.submenuTrigger !== "button") {
|
||||
const parent = document.activeElement as HTMLButtonElement;
|
||||
if (parent) {
|
||||
setActiveSubmenu({ item, parent, viaKeyboard: true });
|
||||
@@ -648,9 +664,11 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
||||
clearTimeout(submenuTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (item.submenu && !item.submenuOpenOnClick) {
|
||||
if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
|
||||
setActiveSubmenu({ item, parent });
|
||||
} else if (activeSubmenu) {
|
||||
} else if (activeSubmenu && activeSubmenu.item !== item) {
|
||||
// Hovering the row that owns the open submenu must not dismiss it — the
|
||||
// pointer travels across the row on its way to a button-triggered submenu
|
||||
submenuTimeoutRef.current = window.setTimeout(() => {
|
||||
const submenuEl = submenuRef.current;
|
||||
if (!submenuEl || !activeSubmenu) {
|
||||
@@ -776,6 +794,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
||||
// oxlint-disable-next-line no-array-index-key -- Nothing else available
|
||||
key={i}
|
||||
className={classNames("my-1.5", item.label ? "ml-2" : null)}
|
||||
action={item.action}
|
||||
>
|
||||
{item.label}
|
||||
</Separator>
|
||||
@@ -797,6 +816,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
||||
onFocus={handleFocus}
|
||||
onSelect={handleSelect}
|
||||
onHover={handleItemHover}
|
||||
onOpenSubmenu={(item, el) => setActiveSubmenu({ item, parent: el })}
|
||||
// oxlint-disable-next-line no-array-index-key -- It's fine
|
||||
key={i}
|
||||
item={item}
|
||||
@@ -868,6 +888,7 @@ interface MenuItemProps {
|
||||
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
|
||||
onFocus: (item: DropdownItemDefault) => void;
|
||||
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||
onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||
focused: boolean;
|
||||
isParentOfActiveSubmenu?: boolean;
|
||||
}
|
||||
@@ -879,6 +900,7 @@ function MenuItem({
|
||||
onHover,
|
||||
item,
|
||||
onSelect,
|
||||
onOpenSubmenu,
|
||||
isParentOfActiveSubmenu,
|
||||
...props
|
||||
}: MenuItemProps) {
|
||||
@@ -914,19 +936,22 @@ function MenuItem({
|
||||
e.currentTarget.focus();
|
||||
};
|
||||
|
||||
const rightSlot = item.submenu ? (
|
||||
<Icon icon="chevron_right" color="secondary" />
|
||||
) : (
|
||||
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
|
||||
);
|
||||
const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
|
||||
|
||||
return (
|
||||
const rightSlot =
|
||||
item.submenu && !hasButtonSubmenu ? (
|
||||
<Icon icon="chevron_right" color="secondary" />
|
||||
) : (
|
||||
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
ref={initRef}
|
||||
size="sm"
|
||||
tabIndex={-1}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={(e) => e.currentTarget.blur()}
|
||||
onMouseEnter={hasButtonSubmenu ? undefined : handleMouseEnter}
|
||||
onMouseLeave={hasButtonSubmenu ? undefined : (e) => e.currentTarget.blur()}
|
||||
disabled={item.disabled}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
@@ -947,6 +972,7 @@ function MenuItem({
|
||||
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
|
||||
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
|
||||
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
|
||||
hasButtonSubmenu && "pr-8",
|
||||
item.color === "danger" && "text-danger!",
|
||||
item.color === "primary" && "text-primary!",
|
||||
item.color === "success" && "text-success!",
|
||||
@@ -959,6 +985,52 @@ function MenuItem({
|
||||
<div className={classNames("truncate min-w-20")}>{item.label}</div>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!hasButtonSubmenu) {
|
||||
return button;
|
||||
}
|
||||
|
||||
// The submenu trigger overlays the row as a sibling (not a child) because the row is
|
||||
// itself a button and buttons cannot nest. Hover handling lives on this wrapper so the
|
||||
// row keeps its focus highlight while the mouse is over the trigger.
|
||||
return (
|
||||
<div
|
||||
className="relative grid group/menuitem"
|
||||
onMouseEnter={() => {
|
||||
const el = buttonRef.current;
|
||||
if (el == null) return;
|
||||
onHover(item, el);
|
||||
el.focus();
|
||||
}}
|
||||
onMouseLeave={() => buttonRef.current?.blur()}
|
||||
>
|
||||
{button}
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute right-1.5 inset-y-0 flex items-center",
|
||||
"opacity-0 group-hover/menuitem:opacity-100 group-focus-within/menuitem:opacity-100",
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
color="custom"
|
||||
size="2xs"
|
||||
tabIndex={-1}
|
||||
icon="ellipsis_vertical"
|
||||
iconColor="secondary"
|
||||
title="More actions"
|
||||
className="h-full! w-7!"
|
||||
onMouseDown={(e) => {
|
||||
// Prevent the trigger from stealing focus, which would unhighlight the row
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenSubmenu(item, e.currentTarget);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemHotKeyProps {
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { parse as jsonLintParse } from "@prantlf/jsonlint";
|
||||
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
||||
|
||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
||||
|
||||
// jsonc-parser reports error codes, so these are the words the editor shows for them
|
||||
const MESSAGES: Record<string, string> = {
|
||||
InvalidSymbol: "Invalid symbol",
|
||||
InvalidNumberFormat: "Invalid number format",
|
||||
PropertyNameExpected: "Property name expected",
|
||||
ValueExpected: "Value expected",
|
||||
ColonExpected: "Colon expected",
|
||||
CommaExpected: "Comma expected",
|
||||
CloseBraceExpected: "Closing brace expected",
|
||||
CloseBracketExpected: "Closing bracket expected",
|
||||
EndOfFileExpected: "End of file expected",
|
||||
InvalidCommentToken: "Comments are not allowed",
|
||||
UnexpectedEndOfComment: "Unexpected end of comment",
|
||||
UnexpectedEndOfString: "Unexpected end of string",
|
||||
UnexpectedEndOfNumber: "Unexpected end of number",
|
||||
InvalidUnicode: "Invalid unicode sequence",
|
||||
InvalidEscapeCharacter: "Invalid escape character",
|
||||
InvalidCharacter: "Invalid character",
|
||||
};
|
||||
|
||||
interface JsonLintOptions {
|
||||
allowComments?: boolean;
|
||||
allowTrailingCommas?: boolean;
|
||||
@@ -11,34 +31,28 @@ interface JsonLintOptions {
|
||||
|
||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
try {
|
||||
const doc = view.state.doc.toString();
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
jsonLintParse(escapedDoc, {
|
||||
mode: (options?.allowComments ?? true) ? "cjson" : "json",
|
||||
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
|
||||
});
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
} catch (err: any) {
|
||||
if (!("location" in err)) {
|
||||
return [];
|
||||
}
|
||||
const doc = view.state.doc.toString();
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
|
||||
// const line = location?.start?.line;
|
||||
// const column = location?.start?.column;
|
||||
if (err.location.start.offset) {
|
||||
return [
|
||||
{
|
||||
from: err.location.start.offset,
|
||||
to: err.location.start.offset,
|
||||
severity: "error",
|
||||
message: err.message,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
const errors: ParseError[] = [];
|
||||
parse(escapedDoc, errors, {
|
||||
allowTrailingComma: options?.allowTrailingCommas ?? false,
|
||||
disallowComments: !(options?.allowComments ?? true),
|
||||
});
|
||||
|
||||
// Later errors are mostly consequences of the first one, so only that one is shown
|
||||
const error = errors[0];
|
||||
if (error == null) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
from: error.offset,
|
||||
to: error.offset + error.length,
|
||||
severity: "error",
|
||||
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import type { IconProps } from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* A single control attached to a labelled separator, rendered between the label
|
||||
* and the rule.
|
||||
*
|
||||
* Declared rather than passed as a node so the separator keeps ownership of the
|
||||
* things that are easy to get wrong by hand: matching the label's colour, and
|
||||
* staying out of the rule's way when the label is long.
|
||||
*/
|
||||
export interface SeparatorAction {
|
||||
icon: IconProps["icon"];
|
||||
/** Tooltip and accessible name. Required — the control is icon-only. */
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
dashed?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
color?: Color;
|
||||
action?: SeparatorAction;
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
@@ -16,15 +34,31 @@ export function Separator({
|
||||
dashed,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
action,
|
||||
}: Props) {
|
||||
return (
|
||||
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
||||
{children && (
|
||||
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
||||
)}
|
||||
{action && (
|
||||
<IconButton
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
className="shrink-0 mr-2 -ml-1"
|
||||
// Forced, because the button itself sets `text-text` at full strength.
|
||||
iconClassName="text-text-subtlest!"
|
||||
icon={action.icon}
|
||||
title={action.title}
|
||||
onClick={action.onClick}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"opacity-60",
|
||||
// Keep a stub of the line visible no matter how long the label is —
|
||||
// `w-full` alone gets squeezed to nothing by a wide label.
|
||||
orientation === "horizontal" && "min-w-8",
|
||||
color == null && "border-border",
|
||||
color === "primary" && "border-primary",
|
||||
color === "secondary" && "border-secondary",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
@@ -11,6 +12,7 @@ import type { DropdownItem } from "../core/Dropdown";
|
||||
import { Dropdown } from "../core/Dropdown";
|
||||
import type { EditorProps } from "../core/Editor/Editor";
|
||||
import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
||||
import { RadioDropdown } from "../core/RadioDropdown";
|
||||
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
||||
@@ -18,6 +20,7 @@ import { Separator } from "../core/Separator";
|
||||
import { tryFormatGraphql } from "../../lib/formatters";
|
||||
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
||||
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
||||
import { revealInFinderText } from "../../lib/reveal";
|
||||
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
||||
|
||||
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
||||
@@ -28,6 +31,10 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
|
||||
|
||||
const OPERATION_NAME_NOT_SPECIFIED = "";
|
||||
|
||||
// How much of the end of a schema filename is pinned when middle-truncating it.
|
||||
// Enough to keep the extension and a little of the name before it.
|
||||
const FILE_NAME_TAIL_CHARS = 12;
|
||||
|
||||
export function GraphQLEditor(props: Props) {
|
||||
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
||||
// key on the request ID as a workaround for now.
|
||||
@@ -38,9 +45,41 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
||||
Record<string, boolean>
|
||||
>("graphQLAutoIntrospectDisabled", {});
|
||||
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {
|
||||
const {
|
||||
schema,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
clear,
|
||||
loadFromFile,
|
||||
reloadFromFile,
|
||||
removeSchemaFile,
|
||||
filePath,
|
||||
} = useIntrospectGraphQL(baseRequest, {
|
||||
disabled: autoIntrospectDisabled?.[baseRequest.id],
|
||||
});
|
||||
|
||||
// Last path segment, for display only. The host owns real path semantics; this
|
||||
// just needs something short enough to label the divider with.
|
||||
const fileName = useMemo(() => filePath?.split(/[/\\]/).pop() || filePath, [filePath]);
|
||||
|
||||
// Selecting a file is all it takes — the request's source becomes that file,
|
||||
// which is what keeps automatic introspection from overwriting it.
|
||||
const handleLoadFromFile = useCallback(async () => {
|
||||
const selected = await platform.dialog.open({
|
||||
title: "Load GraphQL Schema",
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "GraphQL Schema",
|
||||
extensions: ["graphql", "graphqls", "gql", "json"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return;
|
||||
|
||||
await loadFromFile(selected);
|
||||
}, [loadFromFile]);
|
||||
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
@@ -160,14 +199,37 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
...((schema != null
|
||||
? [
|
||||
{
|
||||
label: "Clear",
|
||||
label: "Clear Schema",
|
||||
onSelect: clear,
|
||||
color: "danger",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
},
|
||||
{ type: "separator" },
|
||||
]
|
||||
: []) satisfies DropdownItem[]),
|
||||
{
|
||||
// Labels the source actions below it, so the menu says where the
|
||||
// schema came from without spending a row on it.
|
||||
type: "separator",
|
||||
hidden: schema == null && filePath == null,
|
||||
label:
|
||||
fileName == null || filePath == null ? undefined : (
|
||||
// Middle truncation: the head shrinks and ellipsizes while the
|
||||
// tail is pinned, so the extension always survives. Full path
|
||||
// on hover.
|
||||
<div className="flex min-w-0 max-w-[16rem] font-mono text-xs" title={filePath}>
|
||||
<span className="truncate">{fileName.slice(0, -FILE_NAME_TAIL_CHARS)}</span>
|
||||
<span className="shrink-0">{fileName.slice(-FILE_NAME_TAIL_CHARS)}</span>
|
||||
</div>
|
||||
),
|
||||
action:
|
||||
filePath == null
|
||||
? undefined
|
||||
: {
|
||||
icon: "folder_symlink",
|
||||
title: revealInFinderText,
|
||||
onClick: () => platform.revealItemInDir(filePath),
|
||||
},
|
||||
},
|
||||
{
|
||||
hidden: !error,
|
||||
label: (
|
||||
@@ -210,25 +272,33 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
type: "content",
|
||||
},
|
||||
{
|
||||
hidden: schema == null,
|
||||
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
||||
leftSlot: <Icon icon="book_open_text" />,
|
||||
onSelect: () => {
|
||||
setGraphqlDocStateAtomValue((v) => ({
|
||||
...v,
|
||||
[request.id]: isDocOpen ? undefined : null,
|
||||
}));
|
||||
// One refresh action for either source: re-read the file, or
|
||||
// re-introspect the server.
|
||||
label: "Reload Schema",
|
||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||
keepOpenOnSelect: true,
|
||||
// Failures surface through the hook's error state either way.
|
||||
onSelect: async () => {
|
||||
if (filePath != null) await reloadFromFile();
|
||||
else await refetch();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Introspect Schema",
|
||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: refetch,
|
||||
label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
|
||||
leftSlot: <Icon icon="import" />,
|
||||
onSelect: handleLoadFromFile,
|
||||
},
|
||||
{ type: "separator", label: "Setting" },
|
||||
{
|
||||
label: "Automatic Introspection",
|
||||
hidden: filePath == null,
|
||||
label: "Stop Using File",
|
||||
leftSlot: <Icon icon="x" />,
|
||||
onSelect: removeSchemaFile,
|
||||
},
|
||||
{ type: "separator", label: "Settings" },
|
||||
{
|
||||
// Governs both sources: re-introspecting the server, and
|
||||
// re-reading the file when the request is opened.
|
||||
label: filePath == null ? "Automatic Introspection" : "Automatic Reload",
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: () => {
|
||||
setAutoIntrospectDisabled({
|
||||
@@ -261,6 +331,29 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>,
|
||||
// Sits after the schema control it depends on. Always rendered, disabled
|
||||
// without a schema, so the row never changes shape.
|
||||
<div key="documentation" className="opacity-100!">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
icon="book_open_text"
|
||||
disabled={schema == null}
|
||||
title={
|
||||
schema == null
|
||||
? "Documentation unavailable without a schema"
|
||||
: isDocOpen
|
||||
? "Hide Documentation"
|
||||
: "Show Documentation"
|
||||
}
|
||||
onClick={() => {
|
||||
setGraphqlDocStateAtomValue((v) => ({
|
||||
...v,
|
||||
[request.id]: isDocOpen ? undefined : null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
],
|
||||
[
|
||||
schema,
|
||||
@@ -272,6 +365,11 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
isLoading,
|
||||
operationNames,
|
||||
refetch,
|
||||
handleLoadFromFile,
|
||||
reloadFromFile,
|
||||
removeSchemaFile,
|
||||
filePath,
|
||||
fileName,
|
||||
autoIntrospectDisabled,
|
||||
baseRequest.id,
|
||||
setGraphqlDocStateAtomValue,
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
interface Props {
|
||||
bodyPath?: string;
|
||||
/** A URL for the body the host already stored. */
|
||||
bodyUrl?: string;
|
||||
data?: Uint8Array;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function AudioViewer({ bodyPath, data, mimeType }: Props) {
|
||||
export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath) {
|
||||
setSrc(platform.files.url(bodyPath));
|
||||
if (bodyUrl) {
|
||||
setSrc(bodyUrl);
|
||||
} else if (data) {
|
||||
// The type matters here in a way it doesn't for an image: a media element goes by what
|
||||
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
|
||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data, mimeType]);
|
||||
}, [bodyUrl, data, mimeType]);
|
||||
|
||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <audio className="w-full" controls src={src} />;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
||||
import { useResponseBodyText } from "../../hooks/useResponseBodyText";
|
||||
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
|
||||
import { useResponseFilter } from "../../hooks/useResponseFilter";
|
||||
import { useSaveResponse } from "../../hooks/useSaveResponse";
|
||||
import { languageFromContentType } from "../../lib/contentType";
|
||||
import { getContentTypeFromHeaders } from "../../lib/model_util";
|
||||
@@ -52,30 +54,25 @@ interface HttpTextViewerProps {
|
||||
}
|
||||
|
||||
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
||||
const [currentFilter, setCurrentFilter] = useState<string | null>(null);
|
||||
const filteredBody = useResponseBodyText({ response, filter: currentFilter });
|
||||
const queryClient = useQueryClient();
|
||||
const filter = useResponseFilter({
|
||||
stateKey: `response.body.${response.requestId}`,
|
||||
// Shares the display query's cache entry, so the verdict costs no extra RPC
|
||||
runFilter: useCallback(
|
||||
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
|
||||
[queryClient, response],
|
||||
),
|
||||
});
|
||||
const filteredBody = useResponseBodyText({ response, filter: filter.appliedFilter });
|
||||
const saveResponse = useSaveResponse(response);
|
||||
const copyResponse = useCopyHttpResponse(response);
|
||||
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
||||
|
||||
const filterCallback = useMemo(
|
||||
() => (filter: string) => {
|
||||
setCurrentFilter(filter);
|
||||
return {
|
||||
data: filteredBody.data,
|
||||
isPending: filteredBody.isPending,
|
||||
error: !!filteredBody.error,
|
||||
};
|
||||
},
|
||||
[filteredBody],
|
||||
);
|
||||
|
||||
return (
|
||||
<TextViewer
|
||||
text={text}
|
||||
language={language}
|
||||
stateKey={`response.body.${response.id}`}
|
||||
filterStateKey={`response.body.${response.requestId}`}
|
||||
pretty={pretty}
|
||||
className={className}
|
||||
footerActions={[
|
||||
@@ -98,7 +95,12 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
]}
|
||||
onFilter={filterCallback}
|
||||
filter={filter}
|
||||
filterResult={{
|
||||
data: filteredBody.data,
|
||||
isPending: filteredBody.isPending,
|
||||
error: !!filteredBody.error,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
type Props = { className?: string; mimeType?: string } & (
|
||||
| {
|
||||
bodyPath: string;
|
||||
/** A URL for the body the host already stored. */
|
||||
bodyUrl: string;
|
||||
}
|
||||
| {
|
||||
data: ArrayBuffer;
|
||||
@@ -13,21 +13,21 @@ type Props = { className?: string; mimeType?: string } & (
|
||||
|
||||
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
|
||||
const bodyUrl = "bodyUrl" in props ? props.bodyUrl : null;
|
||||
const data = "data" in props ? props.data : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath != null) {
|
||||
setSrc(platform.files.url(bodyPath));
|
||||
if (bodyUrl != null) {
|
||||
setSrc(bodyUrl);
|
||||
} else if (data != null) {
|
||||
const blob = new Blob([data], { type: mimeType ?? "image/png" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data, mimeType]);
|
||||
}, [bodyUrl, data, mimeType]);
|
||||
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useMemo, useRef, useState } from "react";
|
||||
import { Document, Page } from "react-pdf";
|
||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||
import { fireAndForget } from "../../lib/fireAndForget";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
fireAndForget(
|
||||
import("react-pdf").then(({ pdfjs }) => {
|
||||
@@ -18,7 +17,8 @@ fireAndForget(
|
||||
);
|
||||
|
||||
interface Props {
|
||||
bodyPath?: string;
|
||||
/** A URL for the body the host already stored. */
|
||||
bodyUrl?: string;
|
||||
data?: Uint8Array;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const options = {
|
||||
standardFontDataUrl: "/standard_fonts/",
|
||||
};
|
||||
|
||||
export function PdfViewer({ bodyPath, data }: Props) {
|
||||
export function PdfViewer({ bodyUrl, data }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [numPages, setNumPages] = useState<number>();
|
||||
|
||||
@@ -36,8 +36,8 @@ export function PdfViewer({ bodyPath, data }: Props) {
|
||||
// During render, not in an effect: an effect leaves the first paint with no file, and
|
||||
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
|
||||
const src = useMemo(() => {
|
||||
if (bodyPath) {
|
||||
return platform.files.url(bodyPath);
|
||||
if (bodyUrl) {
|
||||
return bodyUrl;
|
||||
}
|
||||
if (data) {
|
||||
// Create a copy to avoid "Buffer is already detached" errors
|
||||
@@ -45,7 +45,7 @@ export function PdfViewer({ bodyPath, data }: Props) {
|
||||
return { data: new Uint8Array(data) };
|
||||
}
|
||||
return undefined;
|
||||
}, [bodyPath, data]);
|
||||
}, [bodyUrl, data]);
|
||||
|
||||
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
||||
setNumPages(nextNumPages);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import type { RecentFilter } from "../../hooks/useRecentFilters";
|
||||
import { Dropdown, type DropdownItem } from "../core/Dropdown";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
|
||||
interface Props {
|
||||
recentFilters: RecentFilter[];
|
||||
activeFilter: string | null;
|
||||
onSelect: (value: string) => void;
|
||||
onRemove: (value: string) => void;
|
||||
onTogglePin: (value: string) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function RecentFiltersDropdown({
|
||||
recentFilters,
|
||||
activeFilter,
|
||||
onSelect,
|
||||
onRemove,
|
||||
onTogglePin,
|
||||
onClear,
|
||||
}: Props) {
|
||||
const pinned = recentFilters.filter((f) => f.pinned);
|
||||
const unpinned = recentFilters.filter((f) => !f.pinned);
|
||||
|
||||
const toItem = (filter: RecentFilter): DropdownItem => ({
|
||||
label: (
|
||||
<div className="font-mono text-sm truncate max-w-sm" title={filter.value}>
|
||||
{filter.value}
|
||||
</div>
|
||||
),
|
||||
leftSlot: <Icon icon={filter.value === activeFilter ? "check" : "empty"} />,
|
||||
onSelect: () => onSelect(filter.value),
|
||||
submenuTrigger: "button",
|
||||
submenu: [
|
||||
{
|
||||
label: filter.pinned ? "Unpin" : "Pin",
|
||||
icon: filter.pinned ? "unpin" : "pin",
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: () => onTogglePin(filter.value),
|
||||
},
|
||||
{
|
||||
label: "Remove",
|
||||
icon: "trash",
|
||||
color: "danger",
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: () => onRemove(filter.value),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const items: DropdownItem[] = [];
|
||||
|
||||
if (recentFilters.length === 0) {
|
||||
items.push({
|
||||
type: "content",
|
||||
label: (
|
||||
<span className="block px-4 py-1 text-sm text-text-subtle">
|
||||
Filters you use are remembered here
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (pinned.length > 0) {
|
||||
items.push({ type: "separator", label: "Pinned" }, ...pinned.map(toItem));
|
||||
}
|
||||
|
||||
if (unpinned.length > 0) {
|
||||
items.push({ type: "separator", label: "Recent" }, ...unpinned.map(toItem));
|
||||
}
|
||||
|
||||
if (recentFilters.length > 0) {
|
||||
items.push(
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Clear All",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
color: "danger",
|
||||
onSelect: onClear,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="filter"
|
||||
title="Recent filters"
|
||||
iconColor="secondary"
|
||||
className="w-8 ml-0.5 mr-1 h-auto!"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { Children, useCallback, useMemo } from "react";
|
||||
import { createGlobalState } from "react-use";
|
||||
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { useFormatText } from "../../hooks/useFormatText";
|
||||
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
||||
import { Button } from "../core/Button";
|
||||
import type { EditorProps } from "../core/Editor/Editor";
|
||||
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
||||
import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Input } from "../core/Input";
|
||||
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
||||
|
||||
const extraExtensions = [hyperlink];
|
||||
|
||||
@@ -16,57 +17,45 @@ interface Props {
|
||||
text: string;
|
||||
language: EditorProps["language"];
|
||||
stateKey: string | null;
|
||||
filterStateKey?: string | null;
|
||||
pretty?: boolean;
|
||||
className?: string;
|
||||
footerActions?: ReactNode;
|
||||
onFilter?: (filter: string) => {
|
||||
filter?: ResponseFilterApi;
|
||||
filterResult?: {
|
||||
data: string | null | undefined;
|
||||
isPending: boolean;
|
||||
error: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const useFilterText = createGlobalState<Record<string, string | null>>({});
|
||||
|
||||
export function TextViewer({
|
||||
language,
|
||||
text,
|
||||
stateKey,
|
||||
filterStateKey,
|
||||
pretty,
|
||||
className,
|
||||
footerActions,
|
||||
onFilter,
|
||||
filter,
|
||||
filterResult,
|
||||
}: Props) {
|
||||
const filterKey = filterStateKey ?? stateKey;
|
||||
const [filterTextMap, setFilterTextMap] = useFilterText();
|
||||
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
|
||||
const debouncedFilterText = useDebouncedValue(filterText);
|
||||
const setFilterText = useCallback(
|
||||
(v: string | null) => {
|
||||
if (!filterKey) return;
|
||||
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
|
||||
const canFilter =
|
||||
filter != null && (language === "json" || language === "xml" || language === "html");
|
||||
const isSearching = filter?.isSearching ?? false;
|
||||
const appliedFilter = filter?.appliedFilter ?? null;
|
||||
const resultError = filterResult?.error ?? false;
|
||||
|
||||
const handleFilterKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (filter == null) return;
|
||||
if (e.key === "Escape") {
|
||||
filter.toggleSearch();
|
||||
} else if (e.key === "Enter" && filter.filterText != null) {
|
||||
filter.applyFilter(filter.filterText);
|
||||
}
|
||||
},
|
||||
[filterKey, setFilterTextMap],
|
||||
[filter],
|
||||
);
|
||||
|
||||
const isSearching = filterText != null;
|
||||
const filteredResponse =
|
||||
onFilter && debouncedFilterText
|
||||
? onFilter(debouncedFilterText)
|
||||
: { data: null, isPending: false, error: false };
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
if (isSearching) {
|
||||
setFilterText(null);
|
||||
} else {
|
||||
setFilterText("");
|
||||
}
|
||||
}, [isSearching, setFilterText]);
|
||||
|
||||
const canFilter = onFilter && (language === "json" || language === "xml" || language === "html");
|
||||
|
||||
const actions = useMemo<ReactNode[]>(() => {
|
||||
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
||||
|
||||
@@ -76,8 +65,8 @@ export function TextViewer({
|
||||
nodes.push(
|
||||
<div key="input" className="w-full opacity-100!">
|
||||
<Input
|
||||
key={filterKey ?? "filter"}
|
||||
validate={!filteredResponse.error}
|
||||
key={filter.stateKey ?? "filter"}
|
||||
validate={!resultError}
|
||||
hideLabel
|
||||
autoFocus
|
||||
containerClassName="bg-surface"
|
||||
@@ -85,39 +74,62 @@ export function TextViewer({
|
||||
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
||||
label="Filter expression"
|
||||
name="filter"
|
||||
defaultValue={filterText}
|
||||
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
|
||||
onChange={setFilterText}
|
||||
stateKey={filterKey ? `filter.${filterKey}` : null}
|
||||
defaultValue={filter.filterText}
|
||||
forceUpdateKey={filter.filterUpdateKey}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
onChange={filter.setFilterText}
|
||||
stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
|
||||
leftSlot={
|
||||
<div className="py-0.5 flex">
|
||||
<RecentFiltersDropdown
|
||||
recentFilters={filter.recentFilters}
|
||||
activeFilter={filter.appliedFilter}
|
||||
onSelect={filter.replaceFilter}
|
||||
onRemove={filter.removeRecentFilter}
|
||||
onTogglePin={filter.togglePinRecentFilter}
|
||||
onClear={filter.clearRecentFilters}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
rightSlot={
|
||||
<div className="py-0.5 flex">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="x"
|
||||
title="Close filter"
|
||||
iconColor="secondary"
|
||||
onClick={filter.toggleSearch}
|
||||
className="w-8 mr-0.5 h-auto!"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
nodes.push(
|
||||
<IconButton
|
||||
key="icon"
|
||||
size="sm"
|
||||
isLoading={filterResult?.isPending ?? false}
|
||||
icon="filter"
|
||||
title="Filter response"
|
||||
onClick={filter.toggleSearch}
|
||||
className="border border-border-subtle!"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<IconButton
|
||||
key="icon"
|
||||
size="sm"
|
||||
isLoading={filteredResponse.isPending}
|
||||
icon={isSearching ? "x" : "filter"}
|
||||
title={isSearching ? "Close filter" : "Filter response"}
|
||||
onClick={toggleSearch}
|
||||
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")}
|
||||
/>,
|
||||
);
|
||||
|
||||
return nodes;
|
||||
}, [
|
||||
canFilter,
|
||||
footerActions,
|
||||
filterKey,
|
||||
filterText,
|
||||
filteredResponse.error,
|
||||
filteredResponse.isPending,
|
||||
filter,
|
||||
filterResult?.isPending,
|
||||
resultError,
|
||||
isSearching,
|
||||
language,
|
||||
setFilterText,
|
||||
toggleSearch,
|
||||
handleFilterKeyDown,
|
||||
]);
|
||||
|
||||
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
||||
@@ -126,11 +138,11 @@ export function TextViewer({
|
||||
}
|
||||
|
||||
let body: string;
|
||||
if (isSearching && filterText?.length > 0) {
|
||||
if (filteredResponse.error) {
|
||||
if (appliedFilter) {
|
||||
if (resultError) {
|
||||
body = "";
|
||||
} else {
|
||||
body = filteredResponse.data != null ? filteredResponse.data : "";
|
||||
body = filterResult?.data != null ? filterResult.data : "";
|
||||
}
|
||||
} else {
|
||||
body = formattedBody;
|
||||
@@ -143,15 +155,61 @@ export function TextViewer({
|
||||
}
|
||||
|
||||
return (
|
||||
<Editor
|
||||
readOnly
|
||||
className={className}
|
||||
defaultValue={body}
|
||||
language={language}
|
||||
actions={actions}
|
||||
extraExtensions={extraExtensions}
|
||||
stateKey={stateKey}
|
||||
/>
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||
{appliedFilter && filter != null ? (
|
||||
<AppliedFilterBar
|
||||
filter={appliedFilter}
|
||||
error={resultError}
|
||||
onClear={() => filter.replaceFilter("")}
|
||||
/>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Editor
|
||||
readOnly
|
||||
className={className}
|
||||
defaultValue={body}
|
||||
language={language}
|
||||
actions={actions}
|
||||
extraExtensions={extraExtensions}
|
||||
stateKey={stateKey}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows what's actually filtering the body, which the filter box below can't convey
|
||||
* once it holds an edited expression that hasn't been applied yet.
|
||||
*/
|
||||
function AppliedFilterBar({
|
||||
filter,
|
||||
error,
|
||||
onClear,
|
||||
}: {
|
||||
filter: string;
|
||||
error: boolean;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
|
||||
<HStack space={2} className="min-w-0">
|
||||
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
|
||||
<span className="truncate min-w-0" title={filter}>
|
||||
Response filtered by <InlineCode>{filter}</InlineCode>
|
||||
{error && " (invalid expression)"}
|
||||
</span>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color={error ? "danger" : "info"}
|
||||
className="ml-auto shrink-0"
|
||||
onClick={onClear}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
interface Props {
|
||||
bodyPath?: string;
|
||||
/** A URL for the body the host already stored. */
|
||||
bodyUrl?: string;
|
||||
data?: Uint8Array;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function VideoViewer({ bodyPath, data, mimeType }: Props) {
|
||||
export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath) {
|
||||
setSrc(platform.files.url(bodyPath));
|
||||
if (bodyUrl) {
|
||||
setSrc(bodyUrl);
|
||||
} else if (data) {
|
||||
// As in AudioViewer: a media element trusts the declared type instead of sniffing
|
||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data, mimeType]);
|
||||
}, [bodyUrl, data, mimeType]);
|
||||
|
||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <video className="w-full" controls src={src} />;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useKeyValue } from "./useKeyValue";
|
||||
|
||||
// The file a request's GraphQL schema is loaded from, or null when the schema
|
||||
// comes from an introspection request.
|
||||
//
|
||||
// This is the *source*, not the schema. The introspection row it produces is a
|
||||
// cache that expires on its own; this outlives it and regenerates it, the same
|
||||
// way gRPC keeps its proto file list separate from a reflection result.
|
||||
export function graphqlSchemaFileArgs(requestId: string | null) {
|
||||
return {
|
||||
namespace: "global" as const,
|
||||
key: ["graphql_schema_file", requestId ?? "n/a"],
|
||||
};
|
||||
}
|
||||
|
||||
export function useGraphQLSchemaFile(requestId: string | null) {
|
||||
return useKeyValue<string | null>({ ...graphqlSchemaFileArgs(requestId), fallback: null });
|
||||
}
|
||||
@@ -333,7 +333,9 @@ export function formatHotkeyString(trigger: string): string[] {
|
||||
} else if (p === "Alt") {
|
||||
labelParts.push("⌥");
|
||||
} else if (p === "Enter") {
|
||||
labelParts.push("↩");
|
||||
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
|
||||
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
|
||||
labelParts.push("↩︎");
|
||||
} else if (p === "Tab") {
|
||||
labelParts.push("⇥");
|
||||
} else if (p === "Backspace") {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
||||
import { buildClientSchema, getIntrospectionQuery } from "graphql";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { tryBuildIntrospectionFromFile } from "../lib/graphqlSchema";
|
||||
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
||||
import { getResponseBodyText } from "../lib/responseBody";
|
||||
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
|
||||
import { useActiveEnvironment } from "./useActiveEnvironment";
|
||||
import { useGraphQLSchemaFile } from "./useGraphQLSchemaFile";
|
||||
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
@@ -31,6 +32,11 @@ export function useIntrospectGraphQL(
|
||||
|
||||
const introspection = useIntrospectionResult(baseRequest);
|
||||
|
||||
// The schema's source. Outlives the introspection row it produces, so a
|
||||
// request configured with a file keeps working after the row is swept.
|
||||
const schemaFile = useGraphQLSchemaFile(baseRequest.id);
|
||||
const filePath = schemaFile.value ?? null;
|
||||
|
||||
const upsertIntrospection = useCallback(
|
||||
async (content: string | null) => {
|
||||
const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", {
|
||||
@@ -55,7 +61,7 @@ export function useIntrospectGraphQL(
|
||||
bodyType: "application/json",
|
||||
body: { text: introspectionRequestBody },
|
||||
};
|
||||
const response = await minPromiseMillis(
|
||||
const { response, body } = await minPromiseMillis(
|
||||
sendEphemeralRequest(args, activeEnvironment?.id ?? null),
|
||||
700,
|
||||
);
|
||||
@@ -64,14 +70,16 @@ export function useIntrospectGraphQL(
|
||||
return setError(response.error);
|
||||
}
|
||||
|
||||
const bodyText = await getResponseBodyText({ response, filter: null });
|
||||
// The send hands back the only copy of the body — an unsaved response has
|
||||
// nothing on disk and no row to read it back from
|
||||
const bodyText = new TextDecoder("utf-8").decode(new Uint8Array(body));
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return setError(
|
||||
`Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyText === null) {
|
||||
if (bodyText === "") {
|
||||
return setError("Empty body returned in response");
|
||||
}
|
||||
|
||||
@@ -91,15 +99,109 @@ export function useIntrospectGraphQL(
|
||||
return;
|
||||
}
|
||||
|
||||
refetch().catch(console.error);
|
||||
}, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]);
|
||||
// A request pointed at a file gets its schema from that file. Introspecting
|
||||
// here would overwrite it on the next URL edit.
|
||||
if (filePath != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
refetch().catch(console.error);
|
||||
}, [
|
||||
baseRequest.id,
|
||||
debouncedRequest.url,
|
||||
debouncedRequest.method,
|
||||
activeEnvironment?.id,
|
||||
filePath,
|
||||
]);
|
||||
|
||||
// Clears the schema, not the source. Removing a file source is a separate
|
||||
// action, because the source is what would rebuild this a moment later.
|
||||
const clear = useCallback(async () => {
|
||||
setError("");
|
||||
setSchema(null);
|
||||
await upsertIntrospection(null);
|
||||
}, [upsertIntrospection]);
|
||||
|
||||
// Reads a schema file and produces an introspection row from it, the same way
|
||||
// `refetch` produces one from a server. Does not touch the stored source.
|
||||
const introspectFromFile = useCallback(
|
||||
async (path: string): Promise<{ ok: true } | { ok: false; error: string }> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const fileContent = await platform.files.readText(path);
|
||||
const result = tryBuildIntrospectionFromFile(fileContent);
|
||||
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
return { ok: false, error: result.error };
|
||||
}
|
||||
|
||||
await upsertIntrospection(result.content);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
// The host rejects with a bare string for a missing or unreadable path,
|
||||
// so this can't assume an Error.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
return { ok: false, error: message };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[upsertIntrospection],
|
||||
);
|
||||
|
||||
// Points the request at a file and immediately builds its schema from it.
|
||||
const loadFromFile = useCallback(
|
||||
async (path: string) => {
|
||||
const result = await introspectFromFile(path);
|
||||
if (result.ok) await schemaFile.set(path);
|
||||
return result;
|
||||
},
|
||||
[introspectFromFile, schemaFile],
|
||||
);
|
||||
|
||||
const reloadFromFile = useCallback(async () => {
|
||||
if (filePath == null) return { ok: false as const, error: "No schema file to reload" };
|
||||
return introspectFromFile(filePath);
|
||||
}, [filePath, introspectFromFile]);
|
||||
|
||||
// The file-source counterpart of automatic introspection: re-read the file
|
||||
// when the request is opened, so an edited schema is picked up without asking.
|
||||
//
|
||||
// A missing row is repaired even with the setting off — that is recovering
|
||||
// from the 7-day sweep, not keeping the schema fresh, and skipping it would
|
||||
// make the schema disappear with no visible cause.
|
||||
const reloadedFor = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (filePath == null || introspection.isLoading) return;
|
||||
// Only attempt once per path, so an unreadable file doesn't spin.
|
||||
if (reloadedFor.current === filePath) return;
|
||||
|
||||
const hasContent = (introspection.data?.content ?? "") !== "";
|
||||
if (hasContent && options.disabled) return;
|
||||
|
||||
reloadedFor.current = filePath;
|
||||
introspectFromFile(filePath).catch(console.error);
|
||||
}, [
|
||||
filePath,
|
||||
introspection.data?.content,
|
||||
introspection.isLoading,
|
||||
introspectFromFile,
|
||||
options.disabled,
|
||||
]);
|
||||
|
||||
// Stops using the file. The schema goes with it, since the file is what
|
||||
// produced it; introspection repopulates if it's set to run automatically.
|
||||
const removeSchemaFile = useCallback(async () => {
|
||||
setError("");
|
||||
setSchema(null);
|
||||
await schemaFile.set(null);
|
||||
await upsertIntrospection(null);
|
||||
}, [schemaFile, upsertIntrospection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (introspection.data?.content == null || introspection.data.content === "") {
|
||||
return;
|
||||
@@ -113,7 +215,17 @@ export function useIntrospectGraphQL(
|
||||
}
|
||||
}, [introspection.data?.content]);
|
||||
|
||||
return { schema, isLoading, error, refetch, clear };
|
||||
return {
|
||||
schema,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
clear,
|
||||
loadFromFile,
|
||||
reloadFromFile,
|
||||
removeSchemaFile,
|
||||
filePath,
|
||||
};
|
||||
}
|
||||
|
||||
function useIntrospectionResult(request: HttpRequest) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback } from "react";
|
||||
import { useKeyValue } from "./useKeyValue";
|
||||
|
||||
export interface RecentFilter {
|
||||
value: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
const MAX_RECENT_FILTERS = 20;
|
||||
const kvKey = (filterStateKey: string) => `recent_filters::${filterStateKey}`;
|
||||
const namespace = "global";
|
||||
const fallback: RecentFilter[] = [];
|
||||
|
||||
export function useRecentFilters(filterStateKey: string | null) {
|
||||
const { value, set } = useKeyValue<RecentFilter[]>({
|
||||
key: kvKey(filterStateKey ?? "n/a"),
|
||||
namespace,
|
||||
fallback,
|
||||
});
|
||||
|
||||
const addFilter = useCallback(
|
||||
async (rawValue: string) => {
|
||||
const value = rawValue.trim();
|
||||
if (filterStateKey == null || value === "") return;
|
||||
await set((prev) => {
|
||||
// Returning the same reference skips the write, so re-committing the
|
||||
// expression already at the top (on every blur) costs nothing
|
||||
if (prev[0]?.value === value) return prev;
|
||||
const existing = prev.find((f) => f.value === value);
|
||||
const rest = prev.filter((f) => f.value !== value);
|
||||
return trim([{ value, pinned: existing?.pinned }, ...rest]);
|
||||
});
|
||||
},
|
||||
[filterStateKey, set],
|
||||
);
|
||||
|
||||
const removeFilter = useCallback(
|
||||
async (value: string) => set((prev) => prev.filter((f) => f.value !== value)),
|
||||
[set],
|
||||
);
|
||||
|
||||
const togglePin = useCallback(
|
||||
async (value: string) =>
|
||||
set((prev) => prev.map((f) => (f.value === value ? { ...f, pinned: !f.pinned } : f))),
|
||||
[set],
|
||||
);
|
||||
|
||||
const clearFilters = useCallback(async () => set([]), [set]);
|
||||
|
||||
return { recentFilters: value ?? fallback, addFilter, removeFilter, togglePin, clearFilters };
|
||||
}
|
||||
|
||||
/** Bound the list, evicting the oldest unpinned entries before any pinned ones */
|
||||
function trim(filters: RecentFilter[]): RecentFilter[] {
|
||||
const excess = filters.length - MAX_RECENT_FILTERS;
|
||||
if (excess <= 0) return filters;
|
||||
|
||||
const evicted = new Set<number>();
|
||||
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
|
||||
if (!filters[i]?.pinned) evicted.add(i);
|
||||
}
|
||||
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
|
||||
evicted.add(i);
|
||||
}
|
||||
|
||||
return filters.filter((_, i) => !evicted.has(i));
|
||||
}
|
||||
@@ -2,6 +2,25 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { getResponseBodyBytes, getResponseBodyText } from "../lib/responseBody";
|
||||
|
||||
export function responseBodyTextQuery({
|
||||
response,
|
||||
filter,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
filter: string | null;
|
||||
}) {
|
||||
return {
|
||||
queryKey: [
|
||||
"response_body_text",
|
||||
response.id,
|
||||
response.updatedAt,
|
||||
response.contentLength,
|
||||
filter ?? "",
|
||||
],
|
||||
queryFn: () => getResponseBodyText({ response, filter }),
|
||||
};
|
||||
}
|
||||
|
||||
export function useResponseBodyText({
|
||||
response,
|
||||
filter,
|
||||
@@ -11,14 +30,7 @@ export function useResponseBodyText({
|
||||
}) {
|
||||
return useQuery({
|
||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||
queryKey: [
|
||||
"response_body_text",
|
||||
response.id,
|
||||
response.updatedAt,
|
||||
response.contentLength,
|
||||
filter ?? "",
|
||||
],
|
||||
queryFn: () => getResponseBodyText({ response, filter }),
|
||||
...responseBodyTextQuery({ response, filter }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
/**
|
||||
* A URL for a stored response body, for the viewers that hand one to an element
|
||||
* instead of reading the bytes themselves.
|
||||
*
|
||||
* Resolved once here rather than inside each viewer: the host may have to ask
|
||||
* the backend where the body is, and a viewer that computes its source during
|
||||
* render (the PDF one, deliberately) needs it settled before it mounts.
|
||||
*
|
||||
* Null data means the response has no stored body.
|
||||
*/
|
||||
export function useResponseBodyUrl(response: HttpResponse | null) {
|
||||
const responseId = response?.id ?? null;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["response_body_url", responseId, response?.updatedAt ?? ""],
|
||||
enabled: responseId != null,
|
||||
// A response body is stored under the response's own id
|
||||
queryFn: () => (responseId == null ? null : platform.blobs.url(responseId)),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { createGlobalState } from "react-use";
|
||||
import type { RecentFilter } from "./useRecentFilters";
|
||||
import { useRecentFilters } from "./useRecentFilters";
|
||||
|
||||
/** What's typed in the filter box. `null` means the filter box is closed */
|
||||
const useFilterTextMap = createGlobalState<Record<string, string | null>>({});
|
||||
|
||||
/** What's actually applied to the response. Only changes on an explicit apply */
|
||||
const useAppliedFilterMap = createGlobalState<Record<string, string | null>>({});
|
||||
|
||||
export interface ResponseFilterApi {
|
||||
stateKey: string | null;
|
||||
/** Draft text in the filter box, or `null` when the box is closed */
|
||||
filterText: string | null;
|
||||
/** The expression currently filtering the response */
|
||||
appliedFilter: string | null;
|
||||
isSearching: boolean;
|
||||
/** The box holds an expression that isn't the one currently applied */
|
||||
isDirty: boolean;
|
||||
/** Bumped when the (uncontrolled) filter input must re-read its defaultValue */
|
||||
filterUpdateKey: number;
|
||||
setFilterText: (value: string | null) => void;
|
||||
/** Apply the expression to the response, recording it if the filter accepts it */
|
||||
applyFilter: (value: string) => void;
|
||||
/** Like applyFilter, but also replaces what's shown in the filter box */
|
||||
replaceFilter: (value: string) => void;
|
||||
toggleSearch: () => void;
|
||||
recentFilters: RecentFilter[];
|
||||
removeRecentFilter: (value: string) => void;
|
||||
togglePinRecentFilter: (value: string) => void;
|
||||
clearRecentFilters: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft/applied state and history for a response filter (JSONPath/XPath).
|
||||
*
|
||||
* History records at most one entry per apply gesture, and only after `runFilter`
|
||||
* confirms the plugin accepts the expression — the plugin is the sole judge of
|
||||
* validity. Because nothing but the gesture ever writes, refetches can't resurrect
|
||||
* deleted entries and a gesture can't record into another request's history.
|
||||
*/
|
||||
export function useResponseFilter({
|
||||
stateKey,
|
||||
runFilter,
|
||||
}: {
|
||||
stateKey: string | null;
|
||||
/** Evaluate an expression, rejecting if the filter plugin reports an error */
|
||||
runFilter: (filter: string) => Promise<unknown>;
|
||||
}): ResponseFilterApi {
|
||||
const [filterTextMap, setFilterTextMap] = useFilterTextMap();
|
||||
const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap();
|
||||
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
|
||||
const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null;
|
||||
|
||||
const setFilterText = useCallback(
|
||||
(v: string | null) => {
|
||||
if (!stateKey) return;
|
||||
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
|
||||
},
|
||||
[stateKey, setFilterTextMap],
|
||||
);
|
||||
|
||||
const setAppliedFilter = useCallback(
|
||||
(v: string | null) => {
|
||||
if (!stateKey) return;
|
||||
setAppliedFilterMap((m) => ({ ...m, [stateKey]: v }));
|
||||
},
|
||||
[stateKey, setAppliedFilterMap],
|
||||
);
|
||||
|
||||
const {
|
||||
recentFilters,
|
||||
addFilter,
|
||||
removeFilter: removeRecentFilter,
|
||||
togglePin: togglePinRecentFilter,
|
||||
clearFilters: clearRecentFilters,
|
||||
} = useRecentFilters(stateKey);
|
||||
|
||||
const applyFilter = useCallback(
|
||||
(value: string) => {
|
||||
setFilterText(value);
|
||||
const applied = value.trim() === "" ? null : value.trim();
|
||||
setAppliedFilter(applied);
|
||||
if (applied == null) return;
|
||||
runFilter(applied).then(
|
||||
() => addFilter(applied),
|
||||
() => {}, // Rejected by the filter plugin — don't record
|
||||
);
|
||||
},
|
||||
[setFilterText, setAppliedFilter, runFilter, addFilter],
|
||||
);
|
||||
|
||||
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
|
||||
const replaceFilter = useCallback(
|
||||
(value: string) => {
|
||||
applyFilter(value);
|
||||
setFilterUpdateKey((k) => k + 1);
|
||||
},
|
||||
[applyFilter],
|
||||
);
|
||||
|
||||
const isSearching = filterText != null;
|
||||
const toggleSearch = useCallback(() => {
|
||||
if (isSearching) {
|
||||
setFilterText(null);
|
||||
setAppliedFilter(null);
|
||||
} else {
|
||||
setFilterText("");
|
||||
}
|
||||
}, [isSearching, setFilterText, setAppliedFilter]);
|
||||
|
||||
return {
|
||||
stateKey,
|
||||
filterText,
|
||||
appliedFilter,
|
||||
isSearching,
|
||||
isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""),
|
||||
filterUpdateKey,
|
||||
setFilterText,
|
||||
applyFilter,
|
||||
replaceFilter,
|
||||
toggleSearch,
|
||||
recentFilters,
|
||||
removeRecentFilter,
|
||||
togglePinRecentFilter,
|
||||
clearRecentFilters,
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,10 @@ export function useSaveResponse(response: HttpResponse | null) {
|
||||
defaultPath: ext ? `${slug}.${ext}` : slug,
|
||||
title: "Save Response",
|
||||
});
|
||||
if (filepath == null) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
await rpc("cmd_save_response", { responseId: response.id, filepath });
|
||||
showToast({
|
||||
message: (
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { buildSchema, introspectionFromSchema } from "graphql";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { tryBuildIntrospectionFromFile } from "./graphqlSchema";
|
||||
|
||||
const sdl = `
|
||||
type Query {
|
||||
hello: String!
|
||||
user(id: ID!): User
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
name: String
|
||||
}
|
||||
`;
|
||||
|
||||
const introspection = introspectionFromSchema(buildSchema(sdl));
|
||||
|
||||
describe("tryBuildIntrospectionFromFile", () => {
|
||||
test("accepts introspection JSON wrapped in { data: ... }", () => {
|
||||
const input = JSON.stringify({ data: introspection });
|
||||
const result = tryBuildIntrospectionFromFile(input);
|
||||
|
||||
expect("schema" in result).toBe(true);
|
||||
if ("schema" in result) {
|
||||
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("hello");
|
||||
// Output content is the normalized, persistable shape.
|
||||
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts bare introspection JSON without a data wrapper", () => {
|
||||
const input = JSON.stringify(introspection);
|
||||
const result = tryBuildIntrospectionFromFile(input);
|
||||
|
||||
expect("schema" in result).toBe(true);
|
||||
if ("schema" in result) {
|
||||
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("user");
|
||||
// Bare input is wrapped on the way out.
|
||||
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts a GraphQL SDL string", () => {
|
||||
const result = tryBuildIntrospectionFromFile(sdl);
|
||||
|
||||
expect("schema" in result).toBe(true);
|
||||
if ("schema" in result) {
|
||||
const fields = result.schema.getQueryType()?.getFields() ?? {};
|
||||
expect(fields).toHaveProperty("hello");
|
||||
expect(fields).toHaveProperty("user");
|
||||
// SDL is converted to introspection JSON for storage.
|
||||
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns an error for JSON that is neither introspection nor SDL", () => {
|
||||
const result = tryBuildIntrospectionFromFile('{"unrelated":"value"}');
|
||||
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) {
|
||||
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns an error for content that is neither valid JSON nor valid SDL", () => {
|
||||
const result = tryBuildIntrospectionFromFile("not a schema!@#$");
|
||||
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) {
|
||||
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns an error when introspection JSON has a malformed __schema", () => {
|
||||
// Has the data.__schema shape but the contents are invalid for buildClientSchema.
|
||||
const input = JSON.stringify({ data: { __schema: { broken: true } } });
|
||||
const result = tryBuildIntrospectionFromFile(input);
|
||||
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) {
|
||||
expect(result.error).toMatch(/Failed to build schema from introspection JSON/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
||||
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
|
||||
|
||||
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
|
||||
// { __schema }) or an SDL string and normalizes both into the wrapped
|
||||
// { data: <introspection> } JSON shape used by the introspection store.
|
||||
export function tryBuildIntrospectionFromFile(
|
||||
fileContent: string,
|
||||
): { schema: GraphQLSchema; content: string } | { error: string } {
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(fileContent);
|
||||
} catch {
|
||||
parsedJson = undefined;
|
||||
}
|
||||
|
||||
if (parsedJson != null && typeof parsedJson === "object") {
|
||||
const candidates: unknown[] = [(parsedJson as { data?: unknown }).data, parsedJson];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
candidate != null &&
|
||||
typeof candidate === "object" &&
|
||||
"__schema" in (candidate as Record<string, unknown>)
|
||||
) {
|
||||
try {
|
||||
const schema = buildClientSchema(candidate as IntrospectionQuery, {});
|
||||
return { schema, content: JSON.stringify({ data: candidate }) };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: `Failed to build schema from introspection JSON: ${errorMessage(e)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const schema = buildSchema(fileContent);
|
||||
const introspection = introspectionFromSchema(schema);
|
||||
return { schema, content: JSON.stringify({ data: introspection }) };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: `Could not parse file as introspection JSON or GraphQL SDL: ${errorMessage(e)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
@@ -2,11 +2,9 @@ import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showAlert } from "./alert";
|
||||
import { showDialog } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { pluralizeCount } from "./pluralize";
|
||||
import { router } from "./router";
|
||||
import { rpc } from "./rpc";
|
||||
@@ -28,12 +26,9 @@ export const importData = createFastMutation({
|
||||
title: "Import Data",
|
||||
size: "sm",
|
||||
render: ({ hide }) => {
|
||||
const importAndHide = async (filePath: string) => {
|
||||
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
|
||||
try {
|
||||
const didImport = await performImport(filePath);
|
||||
if (!didImport) {
|
||||
return;
|
||||
}
|
||||
await finishImport(await runImport());
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
@@ -41,20 +36,23 @@ export const importData = createFastMutation({
|
||||
hide();
|
||||
}
|
||||
};
|
||||
return <ImportDataDialog importData={importAndHide} />;
|
||||
return (
|
||||
<ImportDataDialog
|
||||
importFile={(filePath) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
|
||||
}
|
||||
importUrl={(url) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function performImport(filePath: string): Promise<boolean> {
|
||||
const activeWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||
const imported = await rpc<BatchUpsertResult>("cmd_import_data", {
|
||||
filePath,
|
||||
workspaceId: activeWorkspace?.id,
|
||||
});
|
||||
|
||||
async function finishImport(imported: BatchUpsertResult): Promise<void> {
|
||||
const importedWorkspace = imported.workspaces[0];
|
||||
|
||||
showDialog({
|
||||
@@ -103,6 +101,4 @@ async function performImport(filePath: string): Promise<boolean> {
|
||||
search: { environment_id: environmentId },
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-in
|
||||
import { rpc } from "./rpc";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
/**
|
||||
* Reading a response body means naming the response, never the file it lives
|
||||
* in: the backend resolves an id against its own records, so nothing the UI
|
||||
* says can point a read somewhere else.
|
||||
*/
|
||||
|
||||
export async function getResponseBodyText({
|
||||
response,
|
||||
filter,
|
||||
@@ -13,7 +19,7 @@ export async function getResponseBodyText({
|
||||
filter: string | null;
|
||||
}): Promise<string | null> {
|
||||
const result = await rpc<FilterResponse>("cmd_http_response_body", {
|
||||
response,
|
||||
responseId: response.id,
|
||||
filter,
|
||||
});
|
||||
|
||||
@@ -27,10 +33,9 @@ export async function getResponseBodyText({
|
||||
export async function getResponseBodyEventSource(
|
||||
response: HttpResponse,
|
||||
): Promise<ServerSentEvent[]> {
|
||||
if (!response.bodyPath) return [];
|
||||
try {
|
||||
const events = await rpc<ServerSentEvent[]>("cmd_get_sse_events", {
|
||||
filePath: response.bodyPath,
|
||||
responseId: response.id,
|
||||
});
|
||||
if (events.length > 0) {
|
||||
return events;
|
||||
@@ -39,8 +44,9 @@ export async function getResponseBodyEventSource(
|
||||
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
|
||||
}
|
||||
|
||||
const bytes = await platform.files.readFile(response.bodyPath);
|
||||
const text = new TextDecoder("utf-8").decode(bytes);
|
||||
const text = await getResponseBodyDecoded(response);
|
||||
if (text == null) return [];
|
||||
|
||||
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
|
||||
data,
|
||||
eventType: "",
|
||||
@@ -53,16 +59,20 @@ export async function getResponseBodySseSummary(
|
||||
response: HttpResponse,
|
||||
resultKeyPath: string,
|
||||
): Promise<SseSummary> {
|
||||
if (!response.bodyPath) return { fragmentCount: 0, summary: "" };
|
||||
const text = await getResponseBodyDecoded(response);
|
||||
if (text == null) return { fragmentCount: 0, summary: "" };
|
||||
|
||||
const bytes = await platform.files.readFile(response.bodyPath);
|
||||
const text = new TextDecoder("utf-8").decode(bytes);
|
||||
return computeSseSummary(text, resultKeyPath);
|
||||
}
|
||||
|
||||
export async function getResponseBodyBytes(
|
||||
response: HttpResponse,
|
||||
): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
if (!response.bodyPath) return null;
|
||||
return platform.files.readFile(response.bodyPath);
|
||||
// A response body is stored under the response's own id
|
||||
return platform.blobs.read(response.id);
|
||||
}
|
||||
|
||||
async function getResponseBodyDecoded(response: HttpResponse): Promise<string | null> {
|
||||
const bytes = await getResponseBodyBytes(response);
|
||||
return bytes == null ? null : new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RpcPayload } from "@yaakapp-internal/platform";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { RpcSchema } from "@yaakapp-internal/tauri-client";
|
||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||
|
||||
/**
|
||||
* Every backend command the app can call: the generated wire schema, one field
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { HttpRequest, HttpResponse } from "@yaakapp-internal/models";
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import type { EphemeralHttpResponse } from "@yaakapp-internal/rpc-schema";
|
||||
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
export async function sendEphemeralRequest(
|
||||
request: HttpRequest,
|
||||
environmentId: string | null,
|
||||
): Promise<HttpResponse> {
|
||||
): Promise<EphemeralHttpResponse> {
|
||||
// Remove some things that we don't want to associate
|
||||
const newRequest = { ...request };
|
||||
return rpc("cmd_send_ephemeral_request", {
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.3",
|
||||
"@mjackson/multipart-parser": "^0.10.1",
|
||||
"@prantlf/jsonlint": "^16.0.0",
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
@@ -54,6 +53,7 @@
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.4.7",
|
||||
@@ -93,14 +93,12 @@
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
"@yaakapp-internal/ui": "^1.0.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"decompress": "^4.2.1",
|
||||
"internal-ip": "^8.0.0",
|
||||
"rollup": "^4.60.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plugin-static-copy": "^3.3.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vite-plugin-top-level-await": "^1.5.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plus": "^0.2.1"
|
||||
"vite-plus": "^0.2.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@ const WorkspacesWorkspaceIdRequestsRequestIdRoute =
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/workspaces': typeof WorkspacesIndexRoute
|
||||
'/workspaces/': typeof WorkspacesIndexRoute
|
||||
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
|
||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
|
||||
'/workspaces/$workspaceId/': typeof WorkspacesWorkspaceIdIndexRoute
|
||||
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -70,9 +70,9 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/workspaces'
|
||||
| '/workspaces/'
|
||||
| '/workspaces/$workspaceId/settings'
|
||||
| '/workspaces/$workspaceId'
|
||||
| '/workspaces/$workspaceId/'
|
||||
| '/workspaces/$workspaceId/requests/$requestId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -110,14 +110,14 @@ declare module '@tanstack/react-router' {
|
||||
'/workspaces/': {
|
||||
id: '/workspaces/'
|
||||
path: '/workspaces'
|
||||
fullPath: '/workspaces'
|
||||
fullPath: '/workspaces/'
|
||||
preLoaderRoute: typeof WorkspacesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces/$workspaceId/': {
|
||||
id: '/workspaces/$workspaceId/'
|
||||
path: '/workspaces/$workspaceId'
|
||||
fullPath: '/workspaces/$workspaceId'
|
||||
fullPath: '/workspaces/$workspaceId/'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import path from "node:path";
|
||||
import { defineConfig, normalizePath } from "vite-plus";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -17,9 +16,38 @@ const standardFontsDir = normalizePath(
|
||||
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Which host the platform package installs. `web` builds Yaak to run in a plain
|
||||
* browser tab, with its own IndexedDB store instead of the Rust engine; anything
|
||||
* else builds the desktop app exactly as before.
|
||||
*/
|
||||
const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => {
|
||||
return {
|
||||
resolve: {
|
||||
alias:
|
||||
yaakTarget === "web"
|
||||
? {
|
||||
// Resolve the platform package to its browser entry, so a web
|
||||
// build never pulls `@tauri-apps/*` into the graph at all. A
|
||||
// build-time branch inside the package would not manage that:
|
||||
// the dead branch folds away, but the imports it guarded stay.
|
||||
"@yaakapp-internal/platform": path.resolve(
|
||||
import.meta.dirname,
|
||||
"../../packages/platform/src/index.web.ts",
|
||||
),
|
||||
}
|
||||
: {},
|
||||
},
|
||||
// The browser host runs the model layer in a worker; that bundle needs the
|
||||
// same wasm handling as the main one. Top-level await needs no transform
|
||||
// because the build targets esnext.
|
||||
worker: {
|
||||
format: "es" as const,
|
||||
plugins: () => [wasm()],
|
||||
},
|
||||
plugins: [
|
||||
wasm(),
|
||||
tanstackRouter({
|
||||
@@ -30,7 +58,6 @@ export default defineConfig(async () => {
|
||||
}),
|
||||
svgr(),
|
||||
react(),
|
||||
topLevelAwait(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{ src: cMapsDir, dest: "" },
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||
"vite-plus": "^0.2.1"
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plus": "^0.2.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,11 @@ async fn dev(args: PluginPathArg) -> CommandResult {
|
||||
ui::info(&format!("Rebuilding plugin {display_path}"));
|
||||
}
|
||||
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
|
||||
match generate_plugin_metadata(&watch_root) {
|
||||
// Assets are staged on every rebuild, so a changed asset or
|
||||
// declaration is picked up without restarting.
|
||||
let result = copy_build_assets(&watch_root)
|
||||
.and_then(|()| generate_plugin_metadata(&watch_root));
|
||||
match result {
|
||||
Ok(()) => ui::success(&format!(
|
||||
"Generated plugin metadata at {}",
|
||||
watch_root.join("build/metadata.json").display()
|
||||
@@ -408,6 +412,7 @@ struct PublishResponse {
|
||||
|
||||
async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
|
||||
prepare_build_output_dir(plugin_dir)?;
|
||||
copy_build_assets(plugin_dir)?;
|
||||
let mut bundler = Bundler::new(bundler_options(plugin_dir, false))
|
||||
.map_err(|err| format!("Failed to initialize Rolldown: {err}"))?;
|
||||
let output = bundler.write().await.map_err(|err| format!("Plugin build failed:\n{err}"))?;
|
||||
@@ -498,6 +503,63 @@ fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
|
||||
.map_err(|e| format!("Failed to create build directory {}: {e}", build_dir.display()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PluginManifest {
|
||||
#[serde(default)]
|
||||
yaak: PluginManifestConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PluginManifestConfig {
|
||||
/// Files to place beside the bundle, as paths relative to the plugin
|
||||
/// directory. Publishing ships everything in `build/`, so these travel with
|
||||
/// the plugin.
|
||||
#[serde(default, rename = "buildAssets")]
|
||||
build_assets: Vec<String>,
|
||||
}
|
||||
|
||||
/// Copy the plugin's declared assets into `build/`.
|
||||
///
|
||||
/// This runs after the directory is cleared and before the bundle is written,
|
||||
/// because a bundle may read an asset from its own directory at import time and
|
||||
/// metadata generation imports the bundle.
|
||||
fn copy_build_assets(plugin_dir: &Path) -> CommandResult {
|
||||
let manifest_path = plugin_dir.join("package.json");
|
||||
let manifest: PluginManifest = serde_json::from_str(
|
||||
&fs::read_to_string(&manifest_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", manifest_path.display()))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to parse {}: {e}", manifest_path.display()))?;
|
||||
|
||||
let build_dir = plugin_dir.join("build");
|
||||
let mut names = HashSet::new();
|
||||
for asset in manifest.yaak.build_assets {
|
||||
let src = plugin_dir.join(&asset);
|
||||
let name = src
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("yaak.buildAssets entry is not a file path: {asset}"))?;
|
||||
// A copy that later gets overwritten would pass the build and fail on
|
||||
// load, so anything the build itself writes, or a second asset with
|
||||
// the same name, is rejected up front. Names are compared without
|
||||
// case, because a plugin is installed on case-insensitive filesystems
|
||||
// wherever it was built.
|
||||
let key = name.to_string_lossy().to_lowercase();
|
||||
if key == "index.js" || key == "metadata.json" {
|
||||
return Err(format!("Build asset {asset} would be overwritten by the build output"));
|
||||
}
|
||||
if !names.insert(key) {
|
||||
return Err(format!("Two build assets share the name {}", name.display()));
|
||||
}
|
||||
if !src.is_file() {
|
||||
return Err(format!("Build asset does not exist: {}", src.display()));
|
||||
}
|
||||
fs::copy(&src, build_dir.join(name))
|
||||
.map_err(|e| format!("Failed to copy build asset {}: {e}", src.display()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bundler_options(plugin_dir: &Path, watch: bool) -> BundlerOptions {
|
||||
BundlerOptions {
|
||||
input: Some(vec![InputItem { import: "./src/index.ts".to_string(), ..Default::default() }]),
|
||||
@@ -750,7 +812,10 @@ describe("Example Plugin", () => {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{create_publish_archive, generate_plugin_metadata};
|
||||
use super::{
|
||||
copy_build_assets, create_publish_archive, generate_plugin_metadata,
|
||||
prepare_build_output_dir,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
@@ -795,6 +860,100 @@ mod tests {
|
||||
assert!(!names.contains("ignored/secret.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_build_output_dir_clears_stale_output() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
let build = root.join("build");
|
||||
fs::create_dir_all(&build).expect("create build");
|
||||
fs::write(build.join("index.js"), "stale").expect("write index.js");
|
||||
fs::write(build.join("left-behind.js"), "stale").expect("write extra");
|
||||
|
||||
prepare_build_output_dir(root).expect("prepare build dir");
|
||||
|
||||
// Publishing ships everything under build/, so nothing may survive.
|
||||
assert!(build.is_dir());
|
||||
assert_eq!(fs::read_dir(&build).expect("read build").count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_places_declared_files_beside_the_bundle() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::create_dir_all(root.join("vendor")).expect("create vendor");
|
||||
fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
|
||||
copy_build_assets(root).expect("copy assets");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("build/core_bg.wasm")).expect("read copied asset"),
|
||||
"asset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_is_a_noop_without_declarations() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("package.json"), r#"{"name":"demo"}"#).expect("write package.json");
|
||||
|
||||
copy_build_assets(root).expect("copy assets");
|
||||
|
||||
assert_eq!(fs::read_dir(root.join("build")).expect("read build").count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_rejects_names_the_build_writes() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("index.js"), "asset").expect("write asset");
|
||||
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["index.js"]}}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("reserved name should fail");
|
||||
assert!(err.contains("overwritten by the build output"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_rejects_duplicate_names() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::create_dir_all(root.join("a")).expect("create a");
|
||||
fs::create_dir_all(root.join("b")).expect("create b");
|
||||
// Differ only by case: one file on macOS and Windows.
|
||||
fs::write(root.join("a/core.wasm"), "one").expect("write a");
|
||||
fs::write(root.join("b/Core.wasm"), "two").expect("write b");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"yaak":{"buildAssets":["a/core.wasm","b/Core.wasm"]}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("duplicate name should fail");
|
||||
assert!(err.contains("share the name"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_fails_on_a_missing_asset() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["nope.wasm"]}}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("missing asset should fail");
|
||||
assert!(err.contains("Build asset does not exist"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_plugin_metadata_detects_api_types() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::context::CliExecutionContext;
|
||||
use arboard::Clipboard;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use console::Term;
|
||||
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
|
||||
use serde_json::Value;
|
||||
@@ -12,6 +14,7 @@ use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::render::{render_grpc_request, render_http_request};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
@@ -131,6 +134,7 @@ async fn build_plugin_reply(
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
&host_context.query_manager,
|
||||
&FileResponseBodyStore::new(&host_context.query_manager),
|
||||
&event.payload,
|
||||
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
|
||||
) {
|
||||
@@ -223,7 +227,15 @@ async fn build_plugin_reply(
|
||||
.await
|
||||
{
|
||||
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
|
||||
SendHttpRequestResponse { http_response: result.response },
|
||||
SendHttpRequestResponse {
|
||||
http_response: result.response,
|
||||
// Nothing saved this body, so the reply is the only
|
||||
// place the plugin can get it.
|
||||
body: result
|
||||
.response_body
|
||||
.returned_bytes()
|
||||
.map(|b| BASE64_STANDARD.encode(b)),
|
||||
},
|
||||
)),
|
||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to send HTTP request in CLI: {err}"),
|
||||
|
||||
@@ -10,9 +10,9 @@ chrono = { workspace = true, features = ["serde"] }
|
||||
log = { workspace = true }
|
||||
include_dir = "0.7"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = "0.25.0"
|
||||
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
|
||||
r2d2_sqlite = "0.32"
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
ts-rs = { workspace = true, features = ["chrono-impl"] }
|
||||
|
||||
@@ -39,7 +39,7 @@ md5 = "0.8.0"
|
||||
notify = "8.0.0"
|
||||
pretty_graphql = "0.2"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = "0.25.0"
|
||||
r2d2_sqlite = "0.32"
|
||||
mime_guess = "2.0.5"
|
||||
rand = "0.9.0"
|
||||
reqwest = { workspace = true, features = [
|
||||
@@ -73,12 +73,14 @@ url = "2"
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
ts-rs = { workspace = true }
|
||||
yaak-rpc = { workspace = true }
|
||||
yaak-rpc-schema = { workspace = true }
|
||||
uuid = "1.12.1"
|
||||
yaak-api = { workspace = true }
|
||||
yaak-common = { workspace = true }
|
||||
yaak-tauri-utils = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak = { workspace = true }
|
||||
yaak-commands = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-fonts = { workspace = true }
|
||||
yaak-git = { workspace = true }
|
||||
|
||||
-4
@@ -1,7 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type GitWatchResult = { unlistenEvent: string, };
|
||||
|
||||
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
|
||||
|
||||
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
|
||||
@@ -12,8 +10,6 @@ export type UpdateResponse = { "type": "ack" } | { "type": "action", action: Upd
|
||||
|
||||
export type UpdateResponseAction = "install" | "skip";
|
||||
|
||||
export type WatchResult = { unlistenEvent: string, };
|
||||
|
||||
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
|
||||
|
||||
export type YaakNotificationAction = { label: string, url: string, };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ts-rs owns bindings/index.ts and rewrites it on export, so this hand-written
|
||||
// entry point is where the generated files come together.
|
||||
export * from "./bindings/gen_rpc";
|
||||
// ts-rs owns bindings/index.ts and rewrites it on export. What remains here
|
||||
// after the RPC schema moved to @yaakapp-internal/rpc-schema is the
|
||||
// desktop-only surface: updater and notification types.
|
||||
export * from "./bindings/index";
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::models::HttpRequestHeader;
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_plugins::events::GetThemesResponse;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::native_template_functions::{
|
||||
decrypt_secure_template_function, encrypt_secure_template_function,
|
||||
};
|
||||
|
||||
/// Extension trait for accessing the EncryptionManager from Tauri Manager types.
|
||||
pub trait EncryptionManagerExt<'a, R> {
|
||||
fn crypto(&'a self) -> State<'a, EncryptionManager>;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> EncryptionManagerExt<'a, R> for M {
|
||||
fn crypto(&'a self) -> State<'a, EncryptionManager> {
|
||||
self.state::<EncryptionManager>()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_decrypt_template<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
template: &str,
|
||||
) -> Result<String> {
|
||||
let encryption_manager = window.app_handle().state::<EncryptionManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
Ok(decrypt_secure_template_function(&encryption_manager, &plugin_context, template)?)
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_secure_template<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
template: &str,
|
||||
) -> Result<String> {
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let plugin_context = window.plugin_context();
|
||||
Ok(encrypt_secure_template_function(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&plugin_context,
|
||||
template,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_get_themes<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(plugin_manager.get_themes(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_enable_encryption<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().ensure_workspace_key(workspace_id)?;
|
||||
window.crypto().reveal_workspace_key(workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_reveal_workspace_key<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<String> {
|
||||
Ok(window.crypto().reveal_workspace_key(workspace_id)?)
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
key: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().set_human_key(workspace_id, key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().disable_encryption(workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
|
||||
default_headers()
|
||||
}
|
||||
@@ -41,6 +41,9 @@ pub enum Error {
|
||||
#[error(transparent)]
|
||||
YaakError(#[from] yaak::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
CommandError(#[from] yaak_commands::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::error::{Error, Result};
|
||||
use chrono::Utc;
|
||||
use log::{debug, error, warn};
|
||||
use notify::Watcher;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
@@ -10,18 +9,11 @@ use tauri::{AppHandle, Listener, Runtime};
|
||||
use tokio::select;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::sleep;
|
||||
use ts_rs::TS;
|
||||
use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
|
||||
use yaak_rpc_schema::GitWatchResult;
|
||||
|
||||
const GIT_STATUS_COALESCE_WINDOW: Duration = Duration::from_millis(250);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub(crate) struct GitWatchResult {
|
||||
unlisten_event: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn watch_git_worktree_status<R, F>(
|
||||
app_handle: AppHandle<R>,
|
||||
dir: &Path,
|
||||
|
||||
@@ -2,7 +2,6 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use KeyAndValueRef::{Ascii, Binary};
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
||||
@@ -21,22 +20,6 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String>
|
||||
entries
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_grpc_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &GrpcRequest,
|
||||
) -> Result<(GrpcRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_grpc_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let metadata = window.db().resolve_metadata_for_grpc_request(request)?;
|
||||
new_request.metadata = metadata;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_metadata<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
|
||||
use tokio::sync::watch::Receiver;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak::send::{ResponseBody, SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::manager::HttpConnectionManager;
|
||||
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
|
||||
@@ -62,6 +62,12 @@ impl<R: Runtime> ResponseContext<R> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a send produced: the response, and where its body went.
|
||||
pub struct SentHttpRequest {
|
||||
pub response: HttpResponse,
|
||||
pub body: ResponseBody,
|
||||
}
|
||||
|
||||
pub async fn send_http_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
unrendered_request: &HttpRequest,
|
||||
@@ -69,7 +75,7 @@ pub async fn send_http_request<R: Runtime>(
|
||||
environment: Option<Environment>,
|
||||
cookie_jar: Option<CookieJar>,
|
||||
cancelled_rx: &mut Receiver<bool>,
|
||||
) -> Result<HttpResponse> {
|
||||
) -> Result<SentHttpRequest> {
|
||||
send_http_request_with_context(
|
||||
window,
|
||||
unrendered_request,
|
||||
@@ -90,7 +96,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
||||
cookie_jar: Option<CookieJar>,
|
||||
cancelled_rx: &Receiver<bool>,
|
||||
plugin_context: &PluginContext,
|
||||
) -> Result<HttpResponse> {
|
||||
) -> Result<SentHttpRequest> {
|
||||
let app_handle = window.app_handle().clone();
|
||||
let update_source = UpdateSource::from_window_label(window.label());
|
||||
let mut response_ctx =
|
||||
@@ -110,7 +116,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(response) => Ok(response),
|
||||
Ok(sent) => Ok(sent),
|
||||
Err(e) => {
|
||||
let error = e.to_string();
|
||||
let elapsed = start.elapsed().as_millis() as i32;
|
||||
@@ -123,7 +129,12 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
||||
}
|
||||
r.error = Some(error);
|
||||
});
|
||||
Ok(response_ctx.response().clone())
|
||||
// The send failed, so whatever body exists is the partial one
|
||||
// already on disk under the response's id.
|
||||
Ok(SentHttpRequest {
|
||||
response: response_ctx.response().clone(),
|
||||
body: ResponseBody::Stored,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +147,7 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
cancelled_rx: &Receiver<bool>,
|
||||
plugin_context: &PluginContext,
|
||||
response_ctx: &mut ResponseContext<R>,
|
||||
) -> Result<HttpResponse> {
|
||||
) -> Result<SentHttpRequest> {
|
||||
let app_handle = window.app_handle().clone();
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
@@ -165,22 +176,6 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| GenericError(e.to_string()))?;
|
||||
|
||||
Ok(result.response)
|
||||
Ok(SentHttpRequest { response: result.response, body: result.response_body })
|
||||
}
|
||||
|
||||
pub fn resolve_http_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &HttpRequest,
|
||||
) -> Result<(HttpRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_http_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let headers = window.db().resolve_headers_for_http_request(request)?;
|
||||
new_request.headers = headers;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::fs::read_to_string;
|
||||
use std::io::ErrorKind;
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak::import::{self, ImportDataParams};
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
@@ -13,10 +14,25 @@ use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
pub(crate) async fn import_data<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let contents = read_import_file(file_path)?;
|
||||
import_contents(window, &contents).await
|
||||
}
|
||||
|
||||
pub(crate) async fn import_url<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
url: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let contents = fetch_import_url(window, url).await?;
|
||||
import_contents(window, &contents).await
|
||||
}
|
||||
|
||||
async fn import_contents<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
contents: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let plugin_manager = window.state::<PluginManager>();
|
||||
let query_manager = window.db_manager();
|
||||
let file = read_import_file(file_path)?;
|
||||
let plugin_context = window.plugin_context();
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id: window.workspace_id(),
|
||||
@@ -30,11 +46,57 @@ pub(crate) async fn import_data<R: Runtime>(
|
||||
plugin_manager: &plugin_manager,
|
||||
plugin_context: &plugin_context,
|
||||
workspace_context,
|
||||
contents: &file,
|
||||
contents,
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
|
||||
/// pipeline as a file on disk.
|
||||
///
|
||||
/// This uses Yaak's own API client, which follows the OS proxy but not the workspace's proxy,
|
||||
/// client certificate, or certificate-validation settings. Requests are unauthenticated, so
|
||||
/// specs behind auth must still be downloaded manually and imported as a file.
|
||||
async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> Result<String> {
|
||||
let url = normalize_import_url(url)?;
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let response = yaak_api_client(ApiClientKind::App, &app_version)?
|
||||
.get(&url)
|
||||
// The API client defaults to JSON, but specs are just as often YAML
|
||||
.header("Accept", "*/*")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| Error::GenericError(format!("Failed to fetch {url}: {err}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(Error::GenericError(format!("Failed to fetch {url}: responded with {status}")));
|
||||
}
|
||||
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
|
||||
}
|
||||
|
||||
fn normalize_import_url(url: &str) -> Result<String> {
|
||||
let url = url.trim();
|
||||
if url.is_empty() {
|
||||
return Err(Error::GenericError("Import URL must not be empty".to_string()));
|
||||
}
|
||||
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
return Ok(url.to_string());
|
||||
}
|
||||
|
||||
match url.split_once("://") {
|
||||
Some((scheme, _)) => {
|
||||
Err(Error::GenericError(format!("Import URL must be http or https, but got {scheme}")))
|
||||
}
|
||||
None => Ok(format!("https://{url}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_import_file(file_path: &str) -> Result<String> {
|
||||
read_to_string(file_path).map_err(|err| {
|
||||
if err.kind() == ErrorKind::InvalidData {
|
||||
@@ -71,4 +133,22 @@ mod tests {
|
||||
|
||||
remove_file(path).expect("remove binary fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_import_url_defaults_to_https() {
|
||||
assert_eq!(
|
||||
normalize_import_url(" example.com/openapi.yaml ").unwrap(),
|
||||
"https://example.com/openapi.yaml"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_import_url("http://example.com/openapi.yaml").unwrap(),
|
||||
"http://example.com/openapi.yaml"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_import_url_rejects_other_schemes() {
|
||||
assert!(normalize_import_url("file:///tmp/openapi.yaml").is_err());
|
||||
assert!(normalize_import_url(" ").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,18 @@ extern crate core;
|
||||
use crate::encoding::read_response_body;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};
|
||||
use crate::http_request::{resolve_http_request, send_http_request};
|
||||
use crate::import::import_data;
|
||||
use crate::grpc::{build_metadata, metadata_to_map};
|
||||
use crate::http_request::send_http_request;
|
||||
use crate::import::{import_data, import_url};
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::render::{render_grpc_request, render_json_value, render_template};
|
||||
use crate::render::{render_grpc_request, render_template};
|
||||
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
|
||||
use crate::uri_scheme::handle_deep_link;
|
||||
use error::Result as YaakResult;
|
||||
use eventsource_client::{EventParser, SSE};
|
||||
use log::{debug, error, info, warn};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -29,40 +28,32 @@ use tauri_plugin_log::{Builder, Target, TargetKind, log};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::time;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak::send::ResponseBody;
|
||||
use yaak_commands::responses::locate_response_body;
|
||||
use yaak_commands::resolve::resolve_grpc_request;
|
||||
use yaak_common::command::new_checked_command;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||
use yaak_grpc::{Code, ServiceDefinition};
|
||||
use yaak_mac_window::AppHandleMacWindowExt;
|
||||
use yaak_models::models::{
|
||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
|
||||
WorkspaceMeta,
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
|
||||
CallWorkspaceActionRequest, Color, FilterResponse, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, InternalEvent,
|
||||
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
|
||||
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
||||
RenderPurpose, ShowToastRequest,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_templates::format_json::format_json;
|
||||
use yaak_templates::strip_json_comments::strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
|
||||
mod commands;
|
||||
mod encoding;
|
||||
mod error;
|
||||
mod feedback;
|
||||
@@ -182,22 +173,6 @@ impl<R: Runtime> PluginContextExt<R> for WebviewWindow<R> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, ts_rs::TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct AppMetaData {
|
||||
is_dev: bool,
|
||||
version: String,
|
||||
cli_version: Option<String>,
|
||||
name: String,
|
||||
app_data_dir: String,
|
||||
app_log_dir: String,
|
||||
vendored_plugin_dir: String,
|
||||
default_project_dir: String,
|
||||
feature_updater: bool,
|
||||
feature_license: bool,
|
||||
}
|
||||
|
||||
async fn cmd_metadata<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<AppMetaData> {
|
||||
let app_data_dir = app_handle.path().app_data_dir()?;
|
||||
let app_log_dir = app_handle.path().app_log_dir()?;
|
||||
@@ -237,56 +212,6 @@ async fn detect_cli_version_for_binary(program: &str) -> Option<String> {
|
||||
Some(parts.next().unwrap_or(line).to_string())
|
||||
}
|
||||
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
tokens: Tokens,
|
||||
) -> YaakResult<String> {
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
|
||||
RenderPurpose::Preview,
|
||||
);
|
||||
let new_tokens = transform_args(tokens, &cb)?;
|
||||
Ok(new_tokens.to_string())
|
||||
}
|
||||
|
||||
async fn cmd_render_template<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
template: &str,
|
||||
workspace_id: &str,
|
||||
environment_id: Option<&str>,
|
||||
purpose: Option<RenderPurpose>,
|
||||
ignore_error: Option<bool>,
|
||||
) -> YaakResult<String> {
|
||||
let environment_chain =
|
||||
app_handle.db().resolve_environments(workspace_id, None, environment_id)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let result = render_template(
|
||||
template,
|
||||
environment_chain,
|
||||
&PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
|
||||
purpose.unwrap_or(RenderPurpose::Preview),
|
||||
),
|
||||
&RenderOptions {
|
||||
error_behavior: match ignore_error {
|
||||
Some(true) => RenderErrorBehavior::ReturnEmpty,
|
||||
_ => RenderErrorBehavior::Throw,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn cmd_send_feedback<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
feature: String,
|
||||
@@ -313,7 +238,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
||||
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
||||
) -> YaakResult<Vec<ServiceDefinition>> {
|
||||
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
||||
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_grpc_request(&window.db(), &unrendered_request)?;
|
||||
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&unrendered_request.workspace_id,
|
||||
@@ -373,7 +299,8 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
||||
) -> YaakResult<String> {
|
||||
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
||||
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_grpc_request(&window.db(), &unrendered_request)?;
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&unrendered_request.workspace_id,
|
||||
unrendered_request.folder_id.as_deref(),
|
||||
@@ -981,13 +908,18 @@ async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send without saving anything.
|
||||
///
|
||||
/// The response never reaches the database, so its body cannot be read back by
|
||||
/// id later the way a saved response's can. It comes back here instead, which
|
||||
/// is the only copy the caller gets.
|
||||
async fn cmd_send_ephemeral_request<R: Runtime>(
|
||||
mut request: HttpRequest,
|
||||
environment_id: Option<&str>,
|
||||
cookie_jar_id: Option<&str>,
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
) -> YaakResult<HttpResponse> {
|
||||
) -> YaakResult<EphemeralHttpResponse> {
|
||||
let response = HttpResponse::default();
|
||||
request.id = "".to_string();
|
||||
let environment = match environment_id {
|
||||
@@ -1006,11 +938,18 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
|
||||
}
|
||||
});
|
||||
|
||||
send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx).await
|
||||
}
|
||||
let sent =
|
||||
send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx)
|
||||
.await?;
|
||||
|
||||
async fn cmd_format_json(text: &str) -> YaakResult<String> {
|
||||
Ok(format_json(text, " "))
|
||||
// Blanking the request id above is what makes this send unsaved, so the
|
||||
// engine always hands the body back. Failing loudly beats returning an
|
||||
// empty body that reads as "the server sent nothing".
|
||||
let ResponseBody::Returned(body) = sent.body else {
|
||||
return Err(GenericError("Unsaved response did not return a body".to_string()));
|
||||
};
|
||||
|
||||
Ok(EphemeralHttpResponse { response: sent.response, body })
|
||||
}
|
||||
|
||||
async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
|
||||
@@ -1023,24 +962,15 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
|
||||
async fn cmd_http_response_body<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
response: HttpResponse,
|
||||
response_id: &str,
|
||||
filter: Option<&str>,
|
||||
) -> YaakResult<FilterResponse> {
|
||||
let body_path = match response.body_path {
|
||||
None => {
|
||||
return Ok(FilterResponse { content: String::new(), error: None });
|
||||
}
|
||||
Some(p) => p,
|
||||
let location = locate_response_body(&window.db(), response_id)?;
|
||||
let Some(body_path) = location.path else {
|
||||
return Ok(FilterResponse { content: String::new(), error: None });
|
||||
};
|
||||
|
||||
let content_type = response
|
||||
.headers
|
||||
.iter()
|
||||
.find_map(|h| {
|
||||
if h.name.eq_ignore_ascii_case("content-type") { Some(h.value.as_str()) } else { None }
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let content_type = location.content_type.as_str();
|
||||
let body = read_response_body(&body_path, content_type)
|
||||
.await
|
||||
.ok_or(GenericError("Failed to find response body".to_string()))?;
|
||||
@@ -1053,24 +983,15 @@ async fn cmd_http_response_body<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_http_request_body<R: Runtime>(
|
||||
async fn cmd_get_sse_events<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
response_id: &str,
|
||||
) -> YaakResult<Option<Vec<u8>>> {
|
||||
let body_id = format!("{}.request", response_id);
|
||||
let chunks = app_handle.blobs().get_chunks(&body_id)?;
|
||||
) -> YaakResult<Vec<ServerSentEvent>> {
|
||||
let Some(body_path) = locate_response_body(&app_handle.db(), response_id)?.path else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
if chunks.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Concatenate all chunks
|
||||
let body: Vec<u8> = chunks.into_iter().flat_map(|c| c.data).collect();
|
||||
Ok(Some(body))
|
||||
}
|
||||
|
||||
async fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>> {
|
||||
let body = fs::read(file_path)?;
|
||||
let body = fs::read(body_path)?;
|
||||
let mut event_parser = EventParser::new();
|
||||
event_parser.process_bytes(body.into())?;
|
||||
|
||||
@@ -1089,14 +1010,6 @@ async fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>>
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_response_events<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
response_id: &str,
|
||||
) -> YaakResult<Vec<HttpResponseEvent>> {
|
||||
let events: Vec<HttpResponseEvent> = app_handle.db().list_http_response_events(response_id)?;
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
async fn cmd_import_data<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
@@ -1104,299 +1017,26 @@ async fn cmd_import_data<R: Runtime>(
|
||||
import_data(&window, file_path).await
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(
|
||||
async fn cmd_import_url<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_http_request_actions(&window.plugin_context()).await?)
|
||||
url: &str,
|
||||
) -> YaakResult<BatchUpsertResult> {
|
||||
import_url(&window, url).await
|
||||
}
|
||||
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_websocket_request_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let websocket_request = window.db().get_websocket_request(&req.args.websocket_request.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_websocket_request_action(
|
||||
&window.plugin_context(),
|
||||
CallWebsocketRequestActionRequest {
|
||||
args: CallWebsocketRequestActionArgs { websocket_request },
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_workspace_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(plugin_manager.get_workspace_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_workspace_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallWorkspaceActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let workspace = window.db().get_workspace(&req.args.workspace.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_workspace_action(
|
||||
&window.plugin_context(),
|
||||
CallWorkspaceActionRequest { args: CallWorkspaceActionArgs { workspace }, ..req },
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_folder_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetFolderActionsResponse>> {
|
||||
Ok(plugin_manager.get_folder_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_folder_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallFolderActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let folder = window.db().get_folder(&req.args.folder.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_folder_action(
|
||||
&window.plugin_context(),
|
||||
CallFolderActionRequest { args: CallFolderActionArgs { folder }, ..req },
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_grpc_request_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_summaries<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
let results = plugin_manager.get_template_function_summaries(&window.plugin_context()).await?;
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_config<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
function_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
_environment_id: Option<&str>,
|
||||
) -> YaakResult<GetTemplateFunctionConfigResponse> {
|
||||
Ok(plugin_manager
|
||||
.get_template_function_config(&window.plugin_context(), function_name, values, model.id())
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results =
|
||||
plugin_manager.get_http_authentication_summaries(&window.plugin_context()).await?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
encryption_manager: State<'_, EncryptionManager>,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
) -> YaakResult<GetHttpAuthenticationConfigResponse> {
|
||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
||||
let (workspace_id, folder_id) = match &model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
_ => return Err(GenericError("Unsupported model type for authentication config".into())),
|
||||
};
|
||||
|
||||
// Resolve environment chain and render the values for token lookup
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&workspace_id,
|
||||
folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager_arc,
|
||||
encryption_manager_arc,
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Preview,
|
||||
);
|
||||
|
||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
||||
let rendered_json =
|
||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::return_empty())
|
||||
.await?;
|
||||
|
||||
// Convert back to HashMap<String, JsonPrimitive>
|
||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
||||
|
||||
Ok(plugin_manager
|
||||
.get_http_authentication_config(
|
||||
&window.plugin_context(),
|
||||
auth_name,
|
||||
rendered_values,
|
||||
model.id(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallHttpRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(plugin_manager
|
||||
.call_http_request_action(
|
||||
&window.plugin_context(),
|
||||
CallHttpRequestActionRequest {
|
||||
args: CallHttpRequestActionArgs {
|
||||
http_request: resolve_http_request(&window, &req.args.http_request)?.0,
|
||||
..req.args
|
||||
},
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(plugin_manager
|
||||
.call_grpc_request_action(
|
||||
&window.plugin_context(),
|
||||
CallGrpcRequestActionRequest {
|
||||
args: CallGrpcRequestActionArgs {
|
||||
grpc_request: resolve_grpc_request(&window, &req.args.grpc_request)?.0,
|
||||
..req.args
|
||||
},
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
encryption_manager: State<'_, EncryptionManager>,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
) -> YaakResult<()> {
|
||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
||||
let (workspace_id, folder_id) = match &model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
_ => return Err(GenericError("Unsupported model type for authentication action".into())),
|
||||
};
|
||||
|
||||
// Resolve environment chain and render the values
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&workspace_id,
|
||||
folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager_arc,
|
||||
encryption_manager_arc,
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Send,
|
||||
);
|
||||
|
||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
||||
let rendered_json =
|
||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
|
||||
|
||||
// Convert back to HashMap<String, JsonPrimitive>
|
||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
||||
|
||||
Ok(plugin_manager
|
||||
.call_http_authentication_action(
|
||||
&window.plugin_context(),
|
||||
auth_name,
|
||||
action_index,
|
||||
rendered_values,
|
||||
&model.id(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_curl_to_request<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
command: &str,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
workspace_id: &str,
|
||||
) -> YaakResult<HttpRequest> {
|
||||
let import_result = plugin_manager.import_data(&window.plugin_context(), command).await?;
|
||||
|
||||
Ok(import_result
|
||||
.resources
|
||||
.http_requests
|
||||
.get(0)
|
||||
.ok_or(GenericError("No curl command found".to_string()))
|
||||
.map(|r| {
|
||||
let mut request = r.clone();
|
||||
request.workspace_id = workspace_id.into();
|
||||
request.id = "".to_string();
|
||||
request
|
||||
})?)
|
||||
}
|
||||
|
||||
async fn cmd_export_data<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
export_path: &str,
|
||||
workspace_ids: Vec<&str>,
|
||||
include_private_environments: bool,
|
||||
) -> YaakResult<()> {
|
||||
let version = app_handle.package_info().version.to_string();
|
||||
Ok(export::export_data(ExportDataParams {
|
||||
query_manager: &app_handle.db_manager(),
|
||||
yaak_version: &version,
|
||||
export_path: Path::new(export_path),
|
||||
workspace_ids,
|
||||
include_private_environments,
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Decodes base64 and writes the bytes to a file the user picked.
|
||||
///
|
||||
@@ -1421,20 +1061,6 @@ async fn cmd_save_base64_to_binary<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_save_response<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
response_id: &str,
|
||||
filepath: &str,
|
||||
) -> YaakResult<()> {
|
||||
let response = app_handle.db().get_http_response(response_id)?;
|
||||
|
||||
let body_path =
|
||||
response.body_path.ok_or(GenericError("Response does not have a body".to_string()))?;
|
||||
fs::copy(body_path, filepath).map_err(|e| GenericError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_send_http_request<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
@@ -1488,7 +1114,7 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Ok(sent) => sent.response,
|
||||
Err(e) => {
|
||||
let resp = app_handle.db().get_http_response(&response.id)?;
|
||||
app_handle.db().upsert_http_response(
|
||||
@@ -1506,101 +1132,6 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
async fn cmd_reload_plugins<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<(String, String)>> {
|
||||
let plugins = app_handle.db().list_plugins()?;
|
||||
let plugin_context =
|
||||
PluginContext::new(Some(window.label().to_string()), window.workspace_id());
|
||||
let errors = plugin_manager.initialize_all_plugins(plugins, &plugin_context).await;
|
||||
Ok(errors)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_info<R: Runtime>(
|
||||
id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<PluginMetadata> {
|
||||
let plugin = app_handle.db().get_plugin(id)?;
|
||||
if let Some(plugin_handle) = plugin_manager
|
||||
.get_plugin_by_dir(plugin.directory.as_str())
|
||||
.await
|
||||
{
|
||||
return Ok(plugin_handle.info());
|
||||
}
|
||||
|
||||
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
|
||||
return Ok(metadata);
|
||||
}
|
||||
|
||||
Ok(fallback_plugin_metadata(&plugin.directory))
|
||||
}
|
||||
|
||||
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
|
||||
let display_name = PathBuf::from(directory)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(directory)
|
||||
.to_string();
|
||||
|
||||
PluginMetadata {
|
||||
version: "Unavailable".to_string(),
|
||||
name: directory.to_string(),
|
||||
display_name,
|
||||
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
|
||||
homepage_url: None,
|
||||
repository_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_grpc_connections<R: Runtime>(
|
||||
request_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(app_handle.db().delete_all_grpc_connections_for_request(
|
||||
request_id,
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_send_history<R: Runtime>(
|
||||
workspace_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(app_handle.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
tx.delete_all_http_responses_for_workspace(workspace_id, source)?;
|
||||
tx.delete_all_grpc_connections_for_workspace(workspace_id, source)?;
|
||||
tx.delete_all_websocket_connections_for_workspace(workspace_id, source)?;
|
||||
Ok(())
|
||||
})?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_http_responses<R: Runtime>(
|
||||
request_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> YaakResult<()> {
|
||||
app_handle.db().delete_all_http_responses_for_request(
|
||||
request_id,
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_get_workspace_meta<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
workspace_id: &str,
|
||||
) -> YaakResult<WorkspaceMeta> {
|
||||
let db = app_handle.db();
|
||||
let workspace = db.get_workspace(workspace_id)?;
|
||||
Ok(db.get_or_create_workspace_meta(&workspace.id)?)
|
||||
}
|
||||
|
||||
async fn cmd_new_child_window<R: Runtime>(
|
||||
parent_window: WebviewWindow<R>,
|
||||
@@ -1920,6 +1451,7 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
|
||||
let ev = match ev {
|
||||
Ok(Some(ev)) => ev,
|
||||
// Nothing to say, or the reply comes later from somewhere else.
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
warn!("Failed to handle plugin event: {e:?}");
|
||||
@@ -1932,7 +1464,10 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
timeout: Some(30000),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
// Tell the plugin as well as the user. It is awaiting a
|
||||
// reply, and a toast it cannot see would leave it
|
||||
// waiting for one that never comes.
|
||||
InternalEventPayload::ErrorResponse(ErrorResponse { error: e.to_string() })
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,10 +12,8 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::error::Result;
|
||||
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
||||
@@ -123,216 +121,12 @@ impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
|
||||
/// Extension trait for accessing the BlobManager from Tauri Manager types.
|
||||
pub trait BlobManagerExt<'a, R> {
|
||||
fn blob_manager(&'a self) -> State<'a, BlobManager>;
|
||||
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> BlobManagerExt<'a, R> for M {
|
||||
fn blob_manager(&'a self) -> State<'a, BlobManager> {
|
||||
self.state::<BlobManager>()
|
||||
}
|
||||
|
||||
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext {
|
||||
let manager = self.state::<BlobManager>();
|
||||
manager.inner().connect()
|
||||
}
|
||||
}
|
||||
|
||||
// Commands for yaak-models
|
||||
use tauri::WebviewWindow;
|
||||
|
||||
pub(crate) fn models_upsert<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
let db = window.db();
|
||||
let blobs = window.blob_manager();
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, &blobs)?.id,
|
||||
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
|
||||
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
|
||||
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
|
||||
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a
|
||||
// blocking thread instead of stalling the main thread and all other IPC.
|
||||
pub(crate) async fn models_delete<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let blobs = window.blob_manager();
|
||||
// Use transaction for deletions because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
|
||||
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
|
||||
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
|
||||
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, &blobs)?.id,
|
||||
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
|
||||
};
|
||||
Ok(id)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("Delete task failed: {e}")))?
|
||||
}
|
||||
|
||||
pub(crate) fn models_duplicate<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model_type: String,
|
||||
model_id: String,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
// Use transaction for duplications because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
// Fetch the model fresh from the DB so the duplicate doesn't come from
|
||||
// a stale frontend snapshot
|
||||
let id = match model_type.as_str() {
|
||||
"environment" => {
|
||||
tx.duplicate_environment(&tx.get_environment(&model_id)?, source)?.id
|
||||
}
|
||||
"folder" => tx.duplicate_folder(&tx.get_folder(&model_id)?, source)?.id,
|
||||
"grpc_request" => {
|
||||
tx.duplicate_grpc_request(&tx.get_grpc_request(&model_id)?, source)?.id
|
||||
}
|
||||
"http_request" => {
|
||||
tx.duplicate_http_request(&tx.get_http_request(&model_id)?, source)?.id
|
||||
}
|
||||
"websocket_request" => {
|
||||
tx.duplicate_websocket_request(&tx.get_websocket_request(&model_id)?, source)?.id
|
||||
}
|
||||
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn models_websocket_events<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(app_handle.db().list_websocket_events(connection_id)?)
|
||||
}
|
||||
|
||||
pub(crate) fn models_grpc_events<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<GrpcEvent>> {
|
||||
Ok(app_handle.db().list_grpc_events(connection_id)?)
|
||||
}
|
||||
|
||||
pub(crate) fn models_get_settings<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Result<Settings> {
|
||||
Ok(app_handle.db().get_settings())
|
||||
}
|
||||
|
||||
pub(crate) fn models_get_graphql_introspection<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
request_id: &str,
|
||||
) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(app_handle.db().get_graphql_introspection(request_id))
|
||||
}
|
||||
|
||||
pub(crate) fn models_upsert_graphql_introspection<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
request_id: &str,
|
||||
workspace_id: &str,
|
||||
content: Option<String>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
let source = UpdateSource::from_window_label(window.label());
|
||||
Ok(app_handle.db().upsert_graphql_introspection(workspace_id, request_id, content, &source)?)
|
||||
}
|
||||
|
||||
pub(crate) async fn models_workspace_models<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: Option<&str>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<String> {
|
||||
let mut l: Vec<AnyModel> = Vec::new();
|
||||
|
||||
// Add the global models
|
||||
{
|
||||
let db = window.db();
|
||||
l.push(db.get_settings().into());
|
||||
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
let plugins = {
|
||||
let db = window.db();
|
||||
db.list_plugins()?
|
||||
};
|
||||
|
||||
let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;
|
||||
l.append(&mut plugins.into_iter().map(Into::into).collect());
|
||||
|
||||
// Add the workspace children
|
||||
if let Some(wid) = workspace_id {
|
||||
let db = window.db();
|
||||
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
let j = serde_json::to_string(&l)?;
|
||||
|
||||
Ok(escape_str_for_webview(&j))
|
||||
}
|
||||
|
||||
fn escape_str_for_webview(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| {
|
||||
let code = c as u32;
|
||||
// ASCII
|
||||
if code <= 0x7F {
|
||||
c.to_string()
|
||||
// BMP characters encoded normally
|
||||
} else if code < 0xFFFF {
|
||||
format!("\\u{:04X}", code)
|
||||
// Beyond BMP encoded a surrogate pairs
|
||||
} else {
|
||||
let high = ((code - 0x10000) >> 10) + 0xD800;
|
||||
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
|
||||
format!("\\u{:04X}\\u{:04X}", high, low)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Initialize database managers as a plugin (for initialization order).
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::{
|
||||
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
|
||||
workspace_from_window,
|
||||
};
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use chrono::Utc;
|
||||
use log::error;
|
||||
use std::sync::Arc;
|
||||
@@ -16,6 +18,7 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
use yaak_models::models::{HttpResponse, Plugin};
|
||||
@@ -54,6 +57,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
app_handle.db_manager().inner(),
|
||||
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
|
||||
&event.payload,
|
||||
SharedPluginEventContext {
|
||||
plugin_name: &plugin_name,
|
||||
@@ -313,8 +317,13 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// An ad-hoc request saves nothing, so the engine hands the body
|
||||
// back and this reply is the only place the plugin can get it.
|
||||
let body = http_response.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b));
|
||||
|
||||
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
|
||||
http_response,
|
||||
http_response: http_response.response,
|
||||
body,
|
||||
})))
|
||||
}
|
||||
HostRequest::OpenWindow(req) => {
|
||||
|
||||
@@ -194,12 +194,6 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
|
||||
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
|
||||
}
|
||||
|
||||
pub async fn cmd_plugin_init_errors(
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(plugin_manager.take_init_errors().await)
|
||||
}
|
||||
|
||||
pub async fn cmd_plugins_updates<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<PluginUpdatesResponse> {
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
use serde_json::Value;
|
||||
//! One import path for rendering, wherever the pieces actually live.
|
||||
//!
|
||||
//! The request renderers are engine code; the template renderers moved to
|
||||
//! `yaak-commands` when the template commands did. Callers in this crate do not
|
||||
//! need to track which is which.
|
||||
|
||||
pub use yaak::render::{render_grpc_request, render_http_request};
|
||||
use yaak_models::models::Environment;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
pub async fn render_template<T: TemplateCallback>(
|
||||
template: &str,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
parse_and_render(template, vars, cb, &opt).await
|
||||
}
|
||||
|
||||
pub async fn render_json_value<T: TemplateCallback>(
|
||||
value: Value,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<Value> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
pub use yaak_commands::render::{render_json_value, render_template};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,10 @@ use crate::error::Result;
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use chrono::Utc;
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Listener, Runtime};
|
||||
use tokio::sync::watch;
|
||||
use ts_rs::TS;
|
||||
use yaak_rpc_schema::WatchResult;
|
||||
use yaak_sync::error::Error::InvalidSyncDirectory;
|
||||
use yaak_sync::sync::{
|
||||
FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,
|
||||
@@ -57,13 +56,6 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub(crate) struct WatchResult {
|
||||
unlisten_event: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_watch<R, F>(
|
||||
app_handle: AppHandle<R>,
|
||||
sync_dir: &Path,
|
||||
|
||||
@@ -18,7 +18,7 @@ use yaak_http::cookies::CookieStore;
|
||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||
use yaak_models::models::{
|
||||
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
|
||||
WebsocketEventType, WebsocketRequest,
|
||||
WebsocketEventType,
|
||||
};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||
@@ -27,19 +27,9 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
use yaak_commands::resolve::resolve_websocket_request;
|
||||
use yaak_ws::{WebsocketManager, render_websocket_request};
|
||||
|
||||
pub async fn cmd_ws_delete_connections<R: Runtime>(
|
||||
request_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<()> {
|
||||
Ok(app_handle.db().delete_all_websocket_connections_for_request(
|
||||
request_id,
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_ws_send<R: Runtime>(
|
||||
connection_id: &str,
|
||||
environment_id: Option<&str>,
|
||||
@@ -86,7 +76,7 @@ async fn send_websocket_message<R: Runtime>(
|
||||
environment_id,
|
||||
)?;
|
||||
let (resolved_request, _auth_context_id) =
|
||||
resolve_websocket_request(&window, &unrendered_request)?;
|
||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
@@ -165,7 +155,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
|
||||
let settings = app_handle.db().get_settings();
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_websocket_request(&window, &unrendered_request)?;
|
||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
@@ -465,23 +455,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
/// Resolve inherited authentication and headers for a websocket request
|
||||
fn resolve_websocket_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &WebsocketRequest,
|
||||
) -> Result<(WebsocketRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_websocket_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let headers = window.db().resolve_headers_for_websocket_request(request)?;
|
||||
new_request.headers = headers;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
/// Convert WS URL to HTTP URL for cookie filtering
|
||||
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
|
||||
|
||||
@@ -7,3 +7,4 @@ publish = false
|
||||
[dependencies]
|
||||
tauri = { workspace = true }
|
||||
regex = "1.11.0"
|
||||
yaak-core = { workspace = true }
|
||||
|
||||
@@ -1,38 +1,53 @@
|
||||
use regex::Regex;
|
||||
use tauri::{Runtime, WebviewWindow};
|
||||
use tauri::{Runtime, Url, WebviewWindow};
|
||||
use yaak_core::WorkspaceContext;
|
||||
|
||||
pub trait WorkspaceWindowTrait {
|
||||
fn workspace_id(&self) -> Option<String>;
|
||||
fn cookie_jar_id(&self) -> Option<String>;
|
||||
fn environment_id(&self) -> Option<String>;
|
||||
fn request_id(&self) -> Option<String>;
|
||||
/// All four at once, from a single read of the window URL.
|
||||
fn workspace_context(&self) -> WorkspaceContext;
|
||||
}
|
||||
|
||||
impl<R: Runtime> WorkspaceWindowTrait for WebviewWindow<R> {
|
||||
fn workspace_id(&self) -> Option<String> {
|
||||
let url = self.url().unwrap();
|
||||
let re = Regex::new(r"/workspaces/(?<id>\w+)").unwrap();
|
||||
match re.captures(url.as_str()) {
|
||||
None => None,
|
||||
Some(captures) => captures.name("id").map(|c| c.as_str().to_string()),
|
||||
}
|
||||
workspace_id_from_url(&self.url().unwrap())
|
||||
}
|
||||
|
||||
fn cookie_jar_id(&self) -> Option<String> {
|
||||
let url = self.url().unwrap();
|
||||
let mut query_pairs = url.query_pairs();
|
||||
query_pairs.find(|(k, _v)| k == "cookie_jar_id").map(|(_k, v)| v.to_string())
|
||||
query_param(&self.url().unwrap(), "cookie_jar_id")
|
||||
}
|
||||
|
||||
fn environment_id(&self) -> Option<String> {
|
||||
let url = self.url().unwrap();
|
||||
let mut query_pairs = url.query_pairs();
|
||||
query_pairs.find(|(k, _v)| k == "environment_id").map(|(_k, v)| v.to_string())
|
||||
query_param(&self.url().unwrap(), "environment_id")
|
||||
}
|
||||
|
||||
fn request_id(&self) -> Option<String> {
|
||||
query_param(&self.url().unwrap(), "request_id")
|
||||
}
|
||||
|
||||
fn workspace_context(&self) -> WorkspaceContext {
|
||||
let url = self.url().unwrap();
|
||||
let mut query_pairs = url.query_pairs();
|
||||
query_pairs.find(|(k, _v)| k == "request_id").map(|(_k, v)| v.to_string())
|
||||
WorkspaceContext {
|
||||
workspace_id: workspace_id_from_url(&url),
|
||||
environment_id: query_param(&url, "environment_id"),
|
||||
cookie_jar_id: query_param(&url, "cookie_jar_id"),
|
||||
request_id: query_param(&url, "request_id"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_id_from_url(url: &Url) -> Option<String> {
|
||||
let re = Regex::new(r"/workspaces/(?<id>\w+)").unwrap();
|
||||
match re.captures(url.as_str()) {
|
||||
None => None,
|
||||
Some(captures) => captures.name("id").map(|c| c.as_str().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn query_param(url: &Url, key: &str) -> Option<String> {
|
||||
let mut query_pairs = url.query_pairs();
|
||||
query_pairs.find(|(k, _v)| k == key).map(|(_k, v)| v.to_string())
|
||||
}
|
||||
|
||||
@@ -9,12 +9,20 @@ chrono = { version = "0.4.38", features = ["serde"] }
|
||||
include_dir = "0.7"
|
||||
log = { workspace = true }
|
||||
nanoid = "0.4.0"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = { version = "0.25.0" }
|
||||
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
|
||||
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
|
||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ts-rs = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = { version = "0.32" }
|
||||
|
||||
# nanoid pulls getrandom, which needs to be told how to reach the browser's
|
||||
# CSPRNG on wasm32-unknown-unknown. Native targets are unaffected.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
uuid = { version = "1", features = ["js"] }
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use r2d2::PooledConnection;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use crate::pool::SqliteConn;
|
||||
use rusqlite::{Connection, Statement, ToSql, Transaction};
|
||||
|
||||
pub enum ConnectionOrTx<'a> {
|
||||
Connection(PooledConnection<SqliteConnectionManager>),
|
||||
Connection(SqliteConn),
|
||||
Transaction(&'a Transaction<'a>),
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::error::Error::ModelNotFound;
|
||||
use crate::error::Result;
|
||||
use crate::traits::UpsertModelInfo;
|
||||
use crate::update_source::UpdateSource;
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{
|
||||
Asterisk, Expr, Func, IntoColumnRef, IntoIden, OnConflict, Query, SimpleExpr,
|
||||
SqliteQueryBuilder,
|
||||
|
||||
@@ -7,7 +7,7 @@ pub enum Error {
|
||||
SqlError(#[from] rusqlite::Error),
|
||||
|
||||
#[error("SQL Pool error: {0}")]
|
||||
SqlPoolError(#[from] r2d2::Error),
|
||||
SqlPoolError(#[from] crate::pool::PoolError),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod connection_or_tx;
|
||||
pub mod db_context;
|
||||
pub mod error;
|
||||
pub mod migrate;
|
||||
pub mod pool;
|
||||
pub mod traits;
|
||||
pub mod update_source;
|
||||
pub mod util;
|
||||
@@ -11,13 +12,15 @@ pub use connection_or_tx::ConnectionOrTx;
|
||||
pub use db_context::DbContext;
|
||||
pub use error::{Error, Result};
|
||||
pub use migrate::run_migrations;
|
||||
pub use pool::{PoolError, SqliteConn, SqlitePool};
|
||||
pub use traits::{UpsertModelInfo, upsert_date};
|
||||
pub use update_source::{ModelChangeEvent, UpdateSource};
|
||||
pub use util::{generate_id, generate_id_of_length, generate_prefixed_id};
|
||||
|
||||
// Re-export pool types that consumers will need
|
||||
// Re-export types that consumers will need
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use r2d2;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use r2d2_sqlite;
|
||||
pub use rusqlite;
|
||||
pub use sea_query;
|
||||
pub use sea_query_rusqlite;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::error::Result;
|
||||
use crate::pool::SqlitePool;
|
||||
use include_dir::Dir;
|
||||
use log::{debug, info};
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
const TRACKING_TABLE: &str = "_sqlx_migrations";
|
||||
@@ -11,7 +10,7 @@ const TRACKING_TABLE: &str = "_sqlx_migrations";
|
||||
///
|
||||
/// Migrations are sorted by filename (use timestamp prefixes like `00000001_init.sql`).
|
||||
/// Applied migrations are tracked in `_sqlx_migrations`.
|
||||
pub fn run_migrations(pool: &Pool<SqliteConnectionManager>, dir: &Dir<'_>) -> Result<()> {
|
||||
pub fn run_migrations(pool: &SqlitePool, dir: &Dir<'_>) -> Result<()> {
|
||||
info!("Running migrations");
|
||||
|
||||
// Create tracking table
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Where connections come from.
|
||||
//!
|
||||
//! Every query in the model layer asks a pool for a connection, uses it, and
|
||||
//! hands it back. That is the whole contract, and it is the one place the
|
||||
//! desktop and the browser genuinely differ: the desktop has threads and wants
|
||||
//! an r2d2 pool; a browser tab has one thread, no way to spawn another, and one
|
||||
//! connection is exactly enough. Everything above this module is identical on
|
||||
//! both.
|
||||
//!
|
||||
//! On native targets `SqlitePool` *is* `r2d2::Pool` — a type alias, so nothing
|
||||
//! that already builds pools changes. On wasm it is one connection that every
|
||||
//! `get()` hands out a shared handle to.
|
||||
//!
|
||||
//! A `SqliteConn` only ever derefs immutably. The code above this layer opens
|
||||
//! transactions with [`rusqlite::Transaction::new_unchecked`], which takes
|
||||
//! `&Connection`; the `&mut` that `Connection::transaction` demands is a
|
||||
//! compile-time guard against nesting a transaction on one connection, and it
|
||||
//! is what would have forced the wasm pool to lend its connection exclusively.
|
||||
//! The model layer nests connections freely — a helper that already holds one
|
||||
//! calls another that asks for its own — so an exclusive lend would panic on
|
||||
//! the second ask. Sharing the handle instead makes nested *reads* work the way
|
||||
//! they do on the desktop; nested *write transactions* fail on both, only
|
||||
//! differently (here SQLite refuses the inner `BEGIN`; natively the inner
|
||||
//! connection blocks on `busy_timeout` and then fails).
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod imp {
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
|
||||
pub type SqlitePool = r2d2::Pool<SqliteConnectionManager>;
|
||||
pub type SqliteConn = r2d2::PooledConnection<SqliteConnectionManager>;
|
||||
pub type PoolError = r2d2::Error;
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod imp {
|
||||
use rusqlite::Connection;
|
||||
use std::ops::Deref;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// One connection, shared by everyone who asks.
|
||||
///
|
||||
/// `Rc` rather than `Arc` because a `Connection` is `!Sync`, so wrapping
|
||||
/// it in an `Arc` would buy no `Send`/`Sync` anyway — and there is one
|
||||
/// thread here to be honest about.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SqlitePool {
|
||||
conn: Rc<Connection>,
|
||||
}
|
||||
|
||||
impl SqlitePool {
|
||||
pub fn single(conn: Connection) -> Self {
|
||||
Self { conn: Rc::new(conn) }
|
||||
}
|
||||
|
||||
/// Another handle to the connection. Cannot fail; the `Result` keeps
|
||||
/// the signature identical to r2d2's so callers are written once.
|
||||
pub fn get(&self) -> Result<SqliteConn, PoolError> {
|
||||
Ok(SqliteConn(self.conn.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a `get()` would return if it could. It can't, so this has no
|
||||
/// variants; it exists so `Error::SqlPoolError` has the same shape on both
|
||||
/// targets.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PoolError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SqliteConn(Rc<Connection>);
|
||||
|
||||
impl Deref for SqliteConn {
|
||||
type Target = Connection;
|
||||
fn deref(&self) -> &Connection {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use imp::*;
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "yaak-rpc-schema"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
ts-rs = { workspace = true }
|
||||
yaak-git = { workspace = true }
|
||||
yaak-grpc = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-sse = { workspace = true }
|
||||
yaak-sync = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
yaak-ws = { workspace = true }
|
||||
@@ -0,0 +1,44 @@
|
||||
# yaak-rpc-schema
|
||||
|
||||
The wire schema for the app's RPC surface: every command name, its request
|
||||
payload, and its response type, declared once.
|
||||
|
||||
Every host that serves the Yaak UI — the desktop app today, the browser bridge
|
||||
and anything after it — imports these types and implements the commands against
|
||||
them. That is what keeps a request's shape from drifting between hosts, and it
|
||||
is why the TypeScript bindings (`bindings/gen_rpc.ts`, exposed to the frontend
|
||||
as `@yaakapp-internal/rpc-schema`) are generated from one place.
|
||||
|
||||
Nothing here depends on Tauri or on any host. Request structs are plain data,
|
||||
and so are the few response types declared here rather than in an engine crate.
|
||||
Command *bodies* live with the host that runs them.
|
||||
|
||||
## Adding a command
|
||||
|
||||
1. Add its request struct and an entry in `with_commands!` in `src/lib.rs`.
|
||||
2. Write the adapter in each host — the desktop's live in
|
||||
`crates-tauri/yaak-app-client/src/rpc_ext.rs`. A host that does not support
|
||||
the command still has to say so; a missing adapter fails to compile.
|
||||
3. Regenerate the bindings: `cargo test -p yaak-rpc-schema` writes
|
||||
`bindings/gen_rpc.ts`, which is committed.
|
||||
|
||||
## How hosts consume the list
|
||||
|
||||
`with_commands!` takes the name of a `macro_rules!` macro and calls it with the
|
||||
full `name(Req) -> Res` list. Each host writes a small macro that receives that
|
||||
list and builds its router:
|
||||
|
||||
```rust
|
||||
macro_rules! register_commands {
|
||||
( $( $name:ident ( $req:ty ) -> $res:ty ),* $(,)? ) => {
|
||||
pub fn build_router() -> RpcRouter<MyCtx> {
|
||||
let mut router = RpcRouter::new();
|
||||
$( router.register(stringify!($name), rpc_handler_async!($name)); )*
|
||||
router
|
||||
}
|
||||
};
|
||||
}
|
||||
yaak_rpc_schema::with_commands!(register_commands);
|
||||
```
|
||||
|
||||
The schema decides *what* commands exist; the host decides *how* each one runs.
|
||||
+1
-1
@@ -55,7 +55,7 @@ urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
|
||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||
|
||||
crates-tauri/yaak-app-client/bindings/gen_rpc.ts → crates/common/yaak-rpc-schema/bindings/gen_rpc.ts
Generated
+27
-8
File diff suppressed because one or more lines are too long
Generated
Generated
@@ -0,0 +1,4 @@
|
||||
// The RPC wire schema, generated by ts-rs from the Rust declarations in
|
||||
// src/lib.rs. `RpcSchema` maps every command name to its (request, response)
|
||||
// pair; the app's `rpc()` helper derives its command union from it.
|
||||
export * from "./bindings/gen_rpc";
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/rpc-schema",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "yaak-commands"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
yaak = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-rpc-schema = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
@@ -0,0 +1,157 @@
|
||||
//! The actions plugins contribute to the UI, and the calls that run them.
|
||||
//!
|
||||
//! Listing is a plain question for the plugin runtime. Calling is not: the
|
||||
//! frontend sends back the model it was showing, and a plugin must act on what
|
||||
//! that model *actually is* — re-read from the database, with inheritance
|
||||
//! resolved — not on a snapshot the UI has been holding. That re-reading is the
|
||||
//! work these handlers do.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::PluginHost;
|
||||
use crate::resolve::{resolve_grpc_request, resolve_http_request};
|
||||
use yaak_models::models::HttpRequest;
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
|
||||
CallWorkspaceActionRequest, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpRequestActionsResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
// -- Listing --
|
||||
|
||||
pub async fn cmd_http_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdHttpRequestActionsReq,
|
||||
) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
host.http_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_websocket_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdWebsocketRequestActionsReq,
|
||||
) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
host.websocket_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_grpc_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGrpcRequestActionsReq,
|
||||
) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
host.grpc_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_workspace_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdWorkspaceActionsReq,
|
||||
) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
host.workspace_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_folder_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdFolderActionsReq,
|
||||
) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
host.folder_actions().await
|
||||
}
|
||||
|
||||
// -- Calling --
|
||||
|
||||
pub async fn cmd_call_http_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallHttpRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let http_request = resolve_http_request(&host.db(), &inner.args.http_request)?.0;
|
||||
host.call_http_request_action(CallHttpRequestActionRequest {
|
||||
args: CallHttpRequestActionArgs { http_request },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_grpc_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallGrpcRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let grpc_request = resolve_grpc_request(&host.db(), &inner.args.grpc_request)?.0;
|
||||
host.call_grpc_request_action(CallGrpcRequestActionRequest {
|
||||
args: CallGrpcRequestActionArgs { grpc_request, ..inner.args },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_websocket_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallWebsocketRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let websocket_request = host.db().get_websocket_request(&inner.args.websocket_request.id)?;
|
||||
host.call_websocket_request_action(CallWebsocketRequestActionRequest {
|
||||
args: CallWebsocketRequestActionArgs { websocket_request },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_workspace_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallWorkspaceActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let workspace = host.db().get_workspace(&inner.args.workspace.id)?;
|
||||
host.call_workspace_action(CallWorkspaceActionRequest {
|
||||
args: CallWorkspaceActionArgs { workspace },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_folder_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallFolderActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let folder = host.db().get_folder(&inner.args.folder.id)?;
|
||||
host.call_folder_action(CallFolderActionRequest {
|
||||
args: CallFolderActionArgs { folder },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Other things the plugin runtime does --
|
||||
|
||||
/// Turn a `curl` command line into an unsaved request, by handing it to the
|
||||
/// same importer plugins that read files.
|
||||
pub async fn cmd_curl_to_request<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCurlToRequestReq,
|
||||
) -> Result<HttpRequest> {
|
||||
let imported = host.import_data(&req.command).await?;
|
||||
|
||||
let request = imported
|
||||
.resources
|
||||
.http_requests
|
||||
.first()
|
||||
.ok_or_else(|| Error::Generic("No curl command found".to_string()))?;
|
||||
|
||||
// Belongs to the workspace the user is importing into, and is not saved
|
||||
// until they say so — hence the blank id.
|
||||
let mut request = request.clone();
|
||||
request.workspace_id = req.workspace_id;
|
||||
request.id = String::new();
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Restart every plugin, returning whatever failed to come back up.
|
||||
pub async fn cmd_reload_plugins<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdReloadPluginsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let plugins = host.db().list_plugins()?;
|
||||
Ok(host.reload_plugins(plugins).await)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Authentication config forms and their actions.
|
||||
//!
|
||||
//! Both commands here do the same preparation: the frontend sends the model
|
||||
//! whose auth is being edited plus the values currently in the form, and those
|
||||
//! values may contain templates. They have to be rendered against the model's
|
||||
//! own environment chain before a plugin sees them, or an auth plugin receives
|
||||
//! `${[ api_key ]}` where it expected a key.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_json_value;
|
||||
use std::collections::HashMap;
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_plugins::events::{
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::RenderOptions;
|
||||
|
||||
pub async fn cmd_get_http_authentication_summaries<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGetHttpAuthenticationSummariesReq,
|
||||
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
host.http_authentication_summaries().await
|
||||
}
|
||||
|
||||
pub async fn cmd_get_http_authentication_config<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdGetHttpAuthenticationConfigReq,
|
||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
// A config form is being displayed, so a template that cannot resolve
|
||||
// should show as blank rather than refuse to open the form.
|
||||
let values = render_auth_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
req.values,
|
||||
RenderPurpose::Preview,
|
||||
&RenderOptions::return_empty(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
host.http_authentication_config(&req.auth_name, values, req.model.id()).await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallHttpAuthenticationActionReq,
|
||||
) -> Result<()> {
|
||||
// An action actually uses these values, so an unresolvable template is an
|
||||
// error rather than an empty string that would silently authenticate wrong.
|
||||
let values = render_auth_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
req.values,
|
||||
RenderPurpose::Send,
|
||||
&RenderOptions::throw(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Render the form's values against the environment chain the model sits in.
|
||||
///
|
||||
/// The chain depends on where the model lives — a request inherits through its
|
||||
/// folder, a workspace has only its own — so the model is what decides which
|
||||
/// variables are in scope.
|
||||
async fn render_auth_values<H: PluginHost>(
|
||||
host: &H,
|
||||
model: &AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
purpose: RenderPurpose,
|
||||
options: &RenderOptions,
|
||||
) -> Result<HashMap<String, JsonPrimitive>> {
|
||||
let (workspace_id, folder_id) = match model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
other => {
|
||||
return Err(Error::Generic(format!(
|
||||
"Cannot resolve authentication for a {}",
|
||||
other.model()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||
|
||||
let cb = host.template_callback(purpose);
|
||||
let rendered =
|
||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||
|
||||
Ok(serde_json::from_value(rendered)?)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Export and formatting.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::Host;
|
||||
use std::path::Path;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::format_json::format_json;
|
||||
|
||||
pub async fn cmd_export_data<H: Host>(host: H, req: CmdExportDataReq) -> Result<()> {
|
||||
let version = host.app_version();
|
||||
Ok(export::export_data(ExportDataParams {
|
||||
query_manager: host.query_manager(),
|
||||
yaak_version: &version,
|
||||
export_path: Path::new(&req.export_path),
|
||||
workspace_ids: req.workspace_ids.iter().map(|s| s.as_str()).collect(),
|
||||
include_private_environments: req.include_private_environments,
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn cmd_format_json<H: Host>(_host: H, req: CmdFormatJsonReq) -> Result<String> {
|
||||
Ok(format_json(&req.text, " "))
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Workspace encryption keys and the `secure()` template function.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::{Host, PluginHost};
|
||||
use yaak_plugins::native_template_functions::decrypt_secure_template_function;
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
pub async fn cmd_enable_encryption<H: Host>(host: H, req: CmdEnableEncryptionReq) -> Result<()> {
|
||||
host.encryption_manager().ensure_workspace_key(&req.workspace_id)?;
|
||||
host.encryption_manager().reveal_workspace_key(&req.workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_reveal_workspace_key<H: Host>(
|
||||
host: H,
|
||||
req: CmdRevealWorkspaceKeyReq,
|
||||
) -> Result<String> {
|
||||
Ok(host.encryption_manager().reveal_workspace_key(&req.workspace_id)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_set_workspace_key<H: Host>(host: H, req: CmdSetWorkspaceKeyReq) -> Result<()> {
|
||||
host.encryption_manager().set_human_key(&req.workspace_id, &req.key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_disable_encryption<H: Host>(host: H, req: CmdDisableEncryptionReq) -> Result<()> {
|
||||
host.encryption_manager().disable_encryption(&req.workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_decrypt_template<H: Host>(host: H, req: CmdDecryptTemplateReq) -> Result<String> {
|
||||
let plugin_context = host.plugin_context();
|
||||
Ok(decrypt_secure_template_function(host.encryption_manager(), &plugin_context, &req.template)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_secure_template<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdSecureTemplateReq,
|
||||
) -> Result<String> {
|
||||
host.encrypt_secure_template(&req.template).await
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Yaak(#[from] yaak::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Model(#[from] yaak_models::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Plugin(#[from] yaak_plugins::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Crypto(#[from] yaak_crypto::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Template(#[from] yaak_templates::error::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("{0}")]
|
||||
Generic(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,223 @@
|
||||
//! What a command needs from whatever is running it.
|
||||
//!
|
||||
//! A command handler is invoked on behalf of one client (a desktop window today)
|
||||
//! and needs a handful of things from its surroundings: the shared engine
|
||||
//! managers, who the client is, what the client is looking at, and a little
|
||||
//! about the app. `Host` is that handful and nothing more. The desktop
|
||||
//! implements it over a `WebviewWindow`; a server would implement it over a
|
||||
//! connection. Handlers are generic over it, so the same handler body runs
|
||||
//! under either without knowing which.
|
||||
//!
|
||||
//! The surface grows only when a handler being moved here needs something new,
|
||||
//! and stays as narrow as those handlers allow. What is deliberately *not* here
|
||||
//! is anything only a desktop can do — open a native window, run the updater,
|
||||
//! show a native dialog — those handlers stay with the desktop.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::blob_manager::{BlobContext, BlobManager};
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::Plugin;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
|
||||
PluginContext, RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_templates::TemplateCallback;
|
||||
|
||||
/// Only `Clone` is required here. `Send`/`Sync`/`'static` are deliberately
|
||||
/// *not*: a browser host is single-threaded and its connection pool is an
|
||||
/// `Rc<Connection>` — `rusqlite::Connection` is not `Sync` to begin with — so a
|
||||
/// thread-safety bound on the trait would lock that host out of implementing it
|
||||
/// at all. The router needs those bounds and states them itself, which is where
|
||||
/// they belong: they are a property of a particular transport, not of a command.
|
||||
pub trait Host: Clone {
|
||||
/// Stable identity of the client this call is for. On the desktop this is
|
||||
/// the window label. It rides on every model write so the client that made
|
||||
/// a change can tell its own echo from everyone else's.
|
||||
fn client_id(&self) -> &str;
|
||||
|
||||
/// What the client is currently looking at: workspace, environment, cookie
|
||||
/// jar, request. Read at call time, since the client can navigate between
|
||||
/// calls (and during one).
|
||||
fn session(&self) -> WorkspaceContext;
|
||||
|
||||
/// The app version, as reported to the Yaak API and stamped on exports.
|
||||
fn app_version(&self) -> String;
|
||||
|
||||
fn query_manager(&self) -> &QueryManager;
|
||||
fn blob_manager(&self) -> &BlobManager;
|
||||
fn encryption_manager(&self) -> &EncryptionManager;
|
||||
|
||||
// -- Conveniences derived from the above; hosts do not override these --
|
||||
|
||||
fn update_source(&self) -> UpdateSource {
|
||||
UpdateSource::from_window_label(self.client_id())
|
||||
}
|
||||
|
||||
fn plugin_context(&self) -> PluginContext {
|
||||
PluginContext::new(Some(self.client_id().to_string()), self.session().workspace_id)
|
||||
}
|
||||
|
||||
fn db(&self) -> ClientDb<'_> {
|
||||
self.query_manager().connect()
|
||||
}
|
||||
|
||||
fn blobs(&self) -> BlobContext {
|
||||
self.blob_manager().connect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A host that can also reach plugins.
|
||||
///
|
||||
/// Separate from [`Host`] so that a command which only touches the database
|
||||
/// never demands a plugin runtime it does not call: a host with no plugins
|
||||
/// still serves those, and only handlers bounded on `PluginHost` are closed to
|
||||
/// it.
|
||||
///
|
||||
/// These are *operations*, not a handle. Handing back a `&PluginManager` would
|
||||
/// have been shorter, but that type is specifically "spawn a Node sidecar and
|
||||
/// talk to it over a socket", and a browser host runs plugins in a Worker it
|
||||
/// reaches by message — it can answer any of the questions below and can never
|
||||
/// produce that type. Naming the questions instead of the answerer is what lets
|
||||
/// both hosts exist.
|
||||
///
|
||||
/// Same rule as [`Host`]: this grows only when a migrated handler needs
|
||||
/// something new, and stays as narrow as those handlers allow. Today it is the
|
||||
/// four things batch 1 asks for.
|
||||
///
|
||||
/// The types crossing this boundary still come from `yaak-plugins` — fine on
|
||||
/// the desktop, and once its plain data types are split out from its runtime
|
||||
/// that becomes an import-path change here rather than an interface one.
|
||||
pub trait PluginHost: Host {
|
||||
/// What the running plugin runtime knows about the plugin installed in
|
||||
/// `directory`, or `None` if it has not loaded one from there. Callers fall
|
||||
/// back to reading the plugin's manifest off disk.
|
||||
fn loaded_plugin_metadata(
|
||||
&self,
|
||||
directory: &str,
|
||||
) -> impl Future<Output = Option<PluginMetadata>>;
|
||||
|
||||
/// Failures from plugin initialization, drained — reporting them clears
|
||||
/// them, so a caller that drops these has lost them.
|
||||
fn take_plugin_init_errors(&self) -> impl Future<Output = Vec<(String, String)>>;
|
||||
|
||||
/// The plugin rows as the runtime sees them: the database says what is
|
||||
/// installed, the runtime knows which are bundled and what version actually
|
||||
/// loaded. A host without a runtime can return them untouched.
|
||||
fn resolve_plugins(&self, plugins: Vec<Plugin>) -> impl Future<Output = Vec<Plugin>>;
|
||||
|
||||
/// The template functions this host can run, as a callback the renderer
|
||||
/// drives. This is the *only* thing the plugin runtime uniquely provides to
|
||||
/// a render — the variables come from the environment chain, which is an
|
||||
/// ordinary database read — so handing back the callback keeps the rest of
|
||||
/// rendering shared instead of pushing whole commands behind this trait.
|
||||
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback;
|
||||
|
||||
/// Every template function the installed plugins expose, for the
|
||||
/// autocomplete menu.
|
||||
fn template_function_summaries(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetTemplateFunctionSummaryResponse>>>;
|
||||
|
||||
/// The form a template function wants to show for the given values.
|
||||
fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<GetTemplateFunctionConfigResponse>>;
|
||||
|
||||
/// Themes contributed by plugins.
|
||||
fn themes(&self) -> impl Future<Output = crate::Result<Vec<GetThemesResponse>>>;
|
||||
|
||||
// -- Actions plugins contribute to the UI --
|
||||
|
||||
fn http_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetHttpRequestActionsResponse>>>;
|
||||
fn websocket_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetWebsocketRequestActionsResponse>>>;
|
||||
fn grpc_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetGrpcRequestActionsResponse>>>;
|
||||
fn workspace_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetWorkspaceActionsResponse>>>;
|
||||
fn folder_actions(&self) -> impl Future<Output = crate::Result<Vec<GetFolderActionsResponse>>>;
|
||||
|
||||
/// Running an action. The request in each of these has already been
|
||||
/// re-read and had its inheritance resolved by the handler; a host must
|
||||
/// pass it through untouched.
|
||||
fn call_http_request_action(
|
||||
&self,
|
||||
req: CallHttpRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_grpc_request_action(
|
||||
&self,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_websocket_request_action(
|
||||
&self,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_workspace_action(
|
||||
&self,
|
||||
req: CallWorkspaceActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_folder_action(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
|
||||
// -- Authentication --
|
||||
|
||||
fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetHttpAuthenticationSummaryResponse>>>;
|
||||
|
||||
/// The form an auth plugin wants to show. `values` arrive already rendered.
|
||||
fn http_authentication_config(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<GetHttpAuthenticationConfigResponse>>;
|
||||
|
||||
fn call_http_authentication_action(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
|
||||
// -- The importers, and the runtime itself --
|
||||
|
||||
/// Hand arbitrary text to the importer plugins and take what they make of
|
||||
/// it. Used for files, URLs and pasted `curl` commands alike.
|
||||
fn import_data(&self, content: &str) -> impl Future<Output = crate::Result<ImportResponse>>;
|
||||
|
||||
/// Restart every plugin, returning `(plugin, error)` for those that failed.
|
||||
fn reload_plugins(&self, plugins: Vec<Plugin>) -> impl Future<Output = Vec<(String, String)>>;
|
||||
|
||||
/// Re-encrypt the `secure(...)` values in a template.
|
||||
///
|
||||
/// Whole operation rather than its pieces because the encryption is only
|
||||
/// half of it: the value is also run through the plugin template functions,
|
||||
/// so this needs the plugin runtime and not just a key.
|
||||
fn encrypt_secure_template(
|
||||
&self,
|
||||
template: &str,
|
||||
) -> impl Future<Output = crate::Result<String>>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Command handlers for the RPC surface, written against [`Host`] instead of
|
||||
//! any particular host.
|
||||
//!
|
||||
//! `yaak_rpc_schema` declares what each command is called and what it takes
|
||||
//! and returns; this crate is where the bodies live. Every handler has the
|
||||
//! shape the router wants — `async fn(host, Req) -> Result<Res>` — so a host
|
||||
//! registers one with a one-line adapter (or none at all), and never
|
||||
//! redeclares a command.
|
||||
//!
|
||||
//! Not every command is here yet. Handlers move in as they are freed of
|
||||
//! host-specific types; the ones that stay behind are the ones only a desktop
|
||||
//! can serve (native windows, the updater, dialogs) or that still lean on it.
|
||||
|
||||
pub mod actions;
|
||||
pub mod auth;
|
||||
pub mod data;
|
||||
pub mod encryption;
|
||||
pub mod error;
|
||||
pub mod host;
|
||||
pub mod models;
|
||||
pub mod plugins;
|
||||
pub mod render;
|
||||
pub mod resolve;
|
||||
pub mod responses;
|
||||
pub mod templates;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use host::{Host, PluginHost};
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Reads and writes of models, keyed by the client's identity so the frontend
|
||||
//! can suppress its own echoes.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::{Host, PluginHost};
|
||||
use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, Settings, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
pub async fn models_upsert<H: Host>(host: H, req: ModelsUpsertReq) -> Result<String> {
|
||||
let db = host.db();
|
||||
let blobs = host.blob_manager();
|
||||
let source = host.update_source();
|
||||
Ok(yaak_models::models_ops::upsert_model(&db, blobs, req.model, &source)?)
|
||||
}
|
||||
|
||||
/// Deletes cascade — a workspace can hold thousands of requests — and run in a
|
||||
/// transaction, which holds a raw connection for the duration.
|
||||
///
|
||||
/// Whether that wants a blocking thread is the *host's* question, not the
|
||||
/// delete's: a desktop with a multi-threaded runtime should keep this off the
|
||||
/// runtime (see its adapter), while a single-threaded host has nothing to move
|
||||
/// it to and runs it here. So this is the plain version, and a host that wants
|
||||
/// to relocate it calls [`models_delete_blocking`] itself.
|
||||
pub async fn models_delete<H: Host>(host: H, req: ModelsDeleteReq) -> Result<String> {
|
||||
models_delete_blocking(&host, req)
|
||||
}
|
||||
|
||||
/// The body of [`models_delete`], callable from a blocking context.
|
||||
pub fn models_delete_blocking<H: Host>(host: &H, req: ModelsDeleteReq) -> Result<String> {
|
||||
let source = host.update_source();
|
||||
Ok(host.query_manager().with_tx(|tx| {
|
||||
yaak_models::models_ops::delete_model(tx, host.blob_manager(), req.model, &source)
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Duplicates recurse, so this runs in a transaction too.
|
||||
pub async fn models_duplicate<H: Host>(host: H, req: ModelsDuplicateReq) -> Result<String> {
|
||||
let source = host.update_source();
|
||||
Ok(host.query_manager().with_tx(|tx| {
|
||||
yaak_models::models_ops::duplicate_model(tx, &req.model_type, &req.model_id, &source)
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn models_websocket_events<H: Host>(
|
||||
host: H,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(host.db().list_websocket_events(&req.connection_id)?)
|
||||
}
|
||||
|
||||
pub async fn models_grpc_events<H: Host>(
|
||||
host: H,
|
||||
req: ModelsGrpcEventsReq,
|
||||
) -> Result<Vec<GrpcEvent>> {
|
||||
Ok(host.db().list_grpc_events(&req.connection_id)?)
|
||||
}
|
||||
|
||||
pub async fn models_get_settings<H: Host>(host: H, _req: ModelsGetSettingsReq) -> Result<Settings> {
|
||||
Ok(host.db().get_settings())
|
||||
}
|
||||
|
||||
pub async fn models_get_graphql_introspection<H: Host>(
|
||||
host: H,
|
||||
req: ModelsGetGraphqlIntrospectionReq,
|
||||
) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(host.db().get_graphql_introspection(&req.request_id))
|
||||
}
|
||||
|
||||
pub async fn models_upsert_graphql_introspection<H: Host>(
|
||||
host: H,
|
||||
req: ModelsUpsertGraphqlIntrospectionReq,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
let source = host.update_source();
|
||||
Ok(host.db().upsert_graphql_introspection(
|
||||
&req.workspace_id,
|
||||
&req.request_id,
|
||||
req.content,
|
||||
&source,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Everything the frontend's model store needs to boot, as one JSON string.
|
||||
///
|
||||
/// A string rather than a `Vec<AnyModel>` because the desktop has to escape
|
||||
/// this payload before it crosses into the webview (see its adapter), and the
|
||||
/// frontend `JSON.parse`s either form the same way.
|
||||
pub async fn models_workspace_models<H: PluginHost>(
|
||||
host: H,
|
||||
req: ModelsWorkspaceModelsReq,
|
||||
) -> Result<String> {
|
||||
let mut l: Vec<AnyModel> = Vec::new();
|
||||
|
||||
// Add the global models
|
||||
{
|
||||
let db = host.db();
|
||||
l.push(db.get_settings().into());
|
||||
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
let plugins = {
|
||||
let db = host.db();
|
||||
db.list_plugins()?
|
||||
};
|
||||
|
||||
let plugins = host.resolve_plugins(plugins).await;
|
||||
l.append(&mut plugins.into_iter().map(Into::into).collect());
|
||||
|
||||
// Add the workspace children
|
||||
if let Some(wid) = req.workspace_id.as_deref() {
|
||||
let db = host.db();
|
||||
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
Ok(serde_json::to_string(&l)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_get_workspace_meta<H: Host>(
|
||||
host: H,
|
||||
req: CmdGetWorkspaceMetaReq,
|
||||
) -> Result<WorkspaceMeta> {
|
||||
let db = host.db();
|
||||
let workspace = db.get_workspace(&req.workspace_id)?;
|
||||
Ok(db.get_or_create_workspace_meta(&workspace.id)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_delete_all_grpc_connections<H: Host>(
|
||||
host: H,
|
||||
req: CmdDeleteAllGrpcConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(host.db().delete_all_grpc_connections_for_request(&req.request_id, &host.update_source())?)
|
||||
}
|
||||
|
||||
pub async fn cmd_delete_all_http_responses<H: Host>(
|
||||
host: H,
|
||||
req: CmdDeleteAllHttpResponsesReq,
|
||||
) -> Result<()> {
|
||||
host.db().delete_all_http_responses_for_request(&req.request_id, &host.update_source())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_ws_delete_connections<H: Host>(
|
||||
host: H,
|
||||
req: CmdWsDeleteConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(host
|
||||
.db()
|
||||
.delete_all_websocket_connections_for_request(&req.request_id, &host.update_source())?)
|
||||
}
|
||||
|
||||
pub async fn cmd_delete_send_history<H: Host>(host: H, req: CmdDeleteSendHistoryReq) -> Result<()> {
|
||||
Ok(host.query_manager().with_tx(|tx| {
|
||||
let source = &host.update_source();
|
||||
tx.delete_all_http_responses_for_workspace(&req.workspace_id, source)?;
|
||||
tx.delete_all_grpc_connections_for_workspace(&req.workspace_id, source)?;
|
||||
tx.delete_all_websocket_connections_for_workspace(&req.workspace_id, source)?;
|
||||
Ok::<(), yaak_models::error::Error>(())
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn cmd_default_headers<H: Host>(
|
||||
_host: H,
|
||||
_req: CmdDefaultHeadersReq,
|
||||
) -> Result<Vec<HttpRequestHeader>> {
|
||||
Ok(default_headers())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Plugin queries: what the runtime has loaded, and what failed to load.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::PluginHost;
|
||||
use std::path::PathBuf;
|
||||
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
pub async fn cmd_plugin_info<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdPluginInfoReq,
|
||||
) -> Result<PluginMetadata> {
|
||||
let plugin = host.db().get_plugin(&req.id)?;
|
||||
if let Some(metadata) = host.loaded_plugin_metadata(&plugin.directory).await {
|
||||
return Ok(metadata);
|
||||
}
|
||||
|
||||
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
|
||||
return Ok(metadata);
|
||||
}
|
||||
|
||||
Ok(fallback_plugin_metadata(&plugin.directory))
|
||||
}
|
||||
|
||||
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
|
||||
let display_name = PathBuf::from(directory)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(directory)
|
||||
.to_string();
|
||||
|
||||
PluginMetadata {
|
||||
version: "Unavailable".to_string(),
|
||||
name: directory.to_string(),
|
||||
display_name,
|
||||
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
|
||||
homepage_url: None,
|
||||
repository_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cmd_plugin_init_errors<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdPluginInitErrorsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(host.take_plugin_init_errors().await)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Rendering a template against an environment chain.
|
||||
//!
|
||||
//! The variables come from the chain, the functions come from the host's
|
||||
//! template callback. Neither of these knows which host it is running under —
|
||||
//! that is the whole point of taking the callback as a parameter.
|
||||
|
||||
use serde_json::Value;
|
||||
use yaak_models::models::Environment;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
pub async fn render_template<T: TemplateCallback>(
|
||||
template: &str,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
parse_and_render(template, vars, cb, opt).await
|
||||
}
|
||||
|
||||
pub async fn render_json_value<T: TemplateCallback>(
|
||||
value: Value,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<Value> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Filling in what a request inherits from its folders and workspace.
|
||||
//!
|
||||
//! A request stored in the database records only what is set *on it*;
|
||||
//! authentication and headers can come from any ancestor. Anything that acts on
|
||||
//! a request as the user sees it — sending it, handing it to a plugin — has to
|
||||
//! resolve that chain first, which is why this is shared rather than living
|
||||
//! next to any one caller.
|
||||
|
||||
use crate::error::Result;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::{GrpcRequest, HttpRequest, WebsocketRequest};
|
||||
|
||||
/// The request with inherited auth and headers filled in, plus the id of the
|
||||
/// model the authentication was inherited *from* — plugins key their token
|
||||
/// caches on it, so it must be the ancestor's id and not the request's.
|
||||
pub fn resolve_http_request(db: &ClientDb, request: &HttpRequest) -> Result<(HttpRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_http_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.headers = db.resolve_headers_for_http_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub fn resolve_grpc_request(db: &ClientDb, request: &GrpcRequest) -> Result<(GrpcRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_grpc_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.metadata = db.resolve_metadata_for_grpc_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub fn resolve_websocket_request(
|
||||
db: &ClientDb,
|
||||
request: &WebsocketRequest,
|
||||
) -> Result<(WebsocketRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_websocket_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.headers = db.resolve_headers_for_websocket_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Reading back what a send left behind: response events, request bodies, and
|
||||
//! where a response body lives.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::Host;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::HttpResponseEvent;
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
/// Where a response's body is, and what it is meant to be read as.
|
||||
pub struct ResponseBodyLocation {
|
||||
/// None when the response has no stored body.
|
||||
pub path: Option<PathBuf>,
|
||||
/// The response's declared `Content-Type`, empty when it has none.
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
/// Find a response's body from its id alone.
|
||||
///
|
||||
/// The frontend hands back an id and never a path, so the only bodies reachable
|
||||
/// here are ones the engine wrote and the database still knows about. A
|
||||
/// response that was never saved has no entry, and its body came back from the
|
||||
/// send that made it.
|
||||
pub fn locate_response_body(db: &ClientDb, response_id: &str) -> Result<ResponseBodyLocation> {
|
||||
let response = db.get_http_response(response_id)?;
|
||||
|
||||
Ok(ResponseBodyLocation {
|
||||
path: response.body_path.map(PathBuf::from),
|
||||
content_type: response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
|
||||
.map(|h| h.value.clone())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn cmd_get_http_response_events<H: Host>(
|
||||
host: H,
|
||||
req: CmdGetHttpResponseEventsReq,
|
||||
) -> Result<Vec<HttpResponseEvent>> {
|
||||
let events: Vec<HttpResponseEvent> = host.db().list_http_response_events(&req.response_id)?;
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// The body's path on this machine, for the desktop host to read or hand to the
|
||||
/// webview's asset protocol.
|
||||
///
|
||||
/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
|
||||
/// the path, and only because it is about to open the file itself. Hosts
|
||||
/// without a filesystem serve the same bytes over HTTP instead.
|
||||
pub async fn cmd_http_response_body_path<H: Host>(
|
||||
host: H,
|
||||
req: CmdHttpResponseBodyPathReq,
|
||||
) -> Result<Option<String>> {
|
||||
let location = locate_response_body(&host.db(), &req.response_id)?;
|
||||
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
pub async fn cmd_http_request_body<H: Host>(
|
||||
host: H,
|
||||
req: CmdHttpRequestBodyReq,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let body_id = format!("{}.request", req.response_id);
|
||||
let chunks = host.blobs().get_chunks(&body_id)?;
|
||||
|
||||
if chunks.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Concatenate all chunks
|
||||
let body: Vec<u8> = chunks.into_iter().flat_map(|c| c.data).collect();
|
||||
Ok(Some(body))
|
||||
}
|
||||
|
||||
pub async fn cmd_save_response<H: Host>(host: H, req: CmdSaveResponseReq) -> Result<()> {
|
||||
let response = host.db().get_http_response(&req.response_id)?;
|
||||
|
||||
let body_path =
|
||||
response.body_path.ok_or(Error::Generic("Response does not have a body".to_string()))?;
|
||||
fs::copy(body_path, &req.filepath).map_err(|e| Error::Generic(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Templates, the functions plugins put in them, and themes.
|
||||
//!
|
||||
//! Everything here needs the plugin runtime, but only for the one thing it
|
||||
//! uniquely provides: running a template function. Resolving the environment
|
||||
//! chain and deciding what a render should do about errors are ordinary work
|
||||
//! and stay here, where every host gets them the same.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_template;
|
||||
use yaak_plugins::events::{
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions, transform_args};
|
||||
|
||||
pub async fn cmd_render_template<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdRenderTemplateReq,
|
||||
) -> Result<String> {
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
|
||||
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview));
|
||||
let options = RenderOptions {
|
||||
// A preview that throws would show the user an error where they expect
|
||||
// to see the value so far, so callers rendering *into the UI* ask for
|
||||
// empties instead.
|
||||
error_behavior: match req.ignore_error {
|
||||
Some(true) => RenderErrorBehavior::ReturnEmpty,
|
||||
_ => RenderErrorBehavior::Throw,
|
||||
},
|
||||
};
|
||||
Ok(render_template(&req.template, environment_chain, &cb, &options).await?)
|
||||
}
|
||||
|
||||
/// Render only the *arguments* of a template's function calls, leaving the
|
||||
/// calls themselves intact. This is what turns a parsed template back into
|
||||
/// something displayable without evaluating it.
|
||||
pub async fn cmd_template_tokens_to_string<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateTokensToStringReq,
|
||||
) -> Result<String> {
|
||||
let cb = host.template_callback(RenderPurpose::Preview);
|
||||
Ok(transform_args(req.tokens, &cb)?.to_string())
|
||||
}
|
||||
|
||||
pub async fn cmd_template_function_summaries<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdTemplateFunctionSummariesReq,
|
||||
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
host.template_function_summaries().await
|
||||
}
|
||||
|
||||
pub async fn cmd_template_function_config<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateFunctionConfigReq,
|
||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
host.template_function_config(&req.function_name, req.values, req.model.id()).await
|
||||
}
|
||||
|
||||
pub async fn cmd_get_themes<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGetThemesReq,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
host.themes().await
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! A host that is nothing but the trait: a temp database, a fixed client id,
|
||||
//! a fixed session. It exists to prove that the handlers really do run without
|
||||
//! a desktop around them, and that the client's identity reaches the writes.
|
||||
//!
|
||||
//! Neither host here has a plugin runtime — no `PluginManager`, no sidecar.
|
||||
//! `TestHost` implements `Host` alone, so a handler that reaches for plugins
|
||||
//! would not compile against it. `SingleThreadedHost` goes further and answers
|
||||
//! `PluginHost` too, without one, which is only possible because that trait
|
||||
//! names operations rather than handing back a manager.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
use yaak_commands::auth::cmd_get_http_authentication_config;
|
||||
use yaak_commands::models::{
|
||||
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
|
||||
models_workspace_models,
|
||||
};
|
||||
use yaak_commands::templates::cmd_render_template;
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{AnyModel, Environment, EnvironmentVariable, Plugin, Workspace};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_rpc_schema::{
|
||||
CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, CmdRenderTemplateReq, ModelsDeleteReq,
|
||||
ModelsUpsertReq, ModelsWorkspaceModelsReq,
|
||||
};
|
||||
use yaak_templates::TemplateCallback;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestHost {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
_dir: TempDir,
|
||||
query_manager: QueryManager,
|
||||
blob_manager: BlobManager,
|
||||
encryption_manager: EncryptionManager,
|
||||
/// Every model write the database reported, so a test can check who it
|
||||
/// says made them.
|
||||
writes: Mutex<Vec<ModelPayload>>,
|
||||
rx: Mutex<std::sync::mpsc::Receiver<ModelPayload>>,
|
||||
}
|
||||
|
||||
impl TestHost {
|
||||
fn new() -> Self {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let (query_manager, blob_manager, rx) = yaak_models::init_standalone(
|
||||
dir.path().join("db.sqlite"),
|
||||
dir.path().join("blobs.sqlite"),
|
||||
)
|
||||
.expect("init db");
|
||||
let encryption_manager = EncryptionManager::new(query_manager.clone(), "app.yaak.test");
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
_dir: dir,
|
||||
query_manager,
|
||||
blob_manager,
|
||||
encryption_manager,
|
||||
writes: Mutex::new(Vec::new()),
|
||||
rx: Mutex::new(rx),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_writes(&self) -> Vec<ModelPayload> {
|
||||
let rx = self.inner.rx.lock().unwrap();
|
||||
let mut writes = self.inner.writes.lock().unwrap();
|
||||
while let Ok(payload) = rx.try_recv() {
|
||||
writes.push(payload);
|
||||
}
|
||||
writes.drain(..).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Host for TestHost {
|
||||
fn client_id(&self) -> &str {
|
||||
"test-client"
|
||||
}
|
||||
|
||||
fn session(&self) -> WorkspaceContext {
|
||||
WorkspaceContext::new().with_workspace("wk_test")
|
||||
}
|
||||
|
||||
fn app_version(&self) -> String {
|
||||
"0.0.0-test".to_string()
|
||||
}
|
||||
|
||||
fn query_manager(&self) -> &QueryManager {
|
||||
&self.inner.query_manager
|
||||
}
|
||||
|
||||
fn blob_manager(&self) -> &BlobManager {
|
||||
&self.inner.blob_manager
|
||||
}
|
||||
|
||||
fn encryption_manager(&self) -> &EncryptionManager {
|
||||
&self.inner.encryption_manager
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn writes_carry_the_client_id() {
|
||||
let host = TestHost::new();
|
||||
|
||||
let workspace = Workspace { name: "From a test".to_string(), ..Default::default() };
|
||||
let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) })
|
||||
.await
|
||||
.expect("upsert");
|
||||
assert!(id.starts_with("wk_"), "unexpected id {id}");
|
||||
|
||||
let writes = host.drain_writes();
|
||||
assert_eq!(writes.len(), 1);
|
||||
assert!(
|
||||
matches!(&writes[0].update_source, UpdateSource::Window { label } if label == "test-client"),
|
||||
"the write should be attributed to the calling client, got {:?}",
|
||||
writes[0].update_source,
|
||||
);
|
||||
|
||||
let meta =
|
||||
cmd_get_workspace_meta(host.clone(), CmdGetWorkspaceMetaReq { workspace_id: id.clone() })
|
||||
.await
|
||||
.expect("workspace meta");
|
||||
assert_eq!(meta.workspace_id, id);
|
||||
|
||||
// Deletes cascade inside a transaction; make sure that path works with no
|
||||
// host doing anything special around it.
|
||||
let workspace = host.db().get_workspace(&id).expect("get workspace");
|
||||
let deleted =
|
||||
models_delete(host.clone(), ModelsDeleteReq { model: AnyModel::Workspace(workspace) })
|
||||
.await
|
||||
.expect("delete");
|
||||
assert_eq!(deleted, id);
|
||||
assert!(host.db().get_workspace(&id).is_err(), "workspace should be gone");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_free_handlers_need_no_state() {
|
||||
let host = TestHost::new();
|
||||
let headers = cmd_default_headers(host, CmdDefaultHeadersReq {}).await.expect("headers");
|
||||
assert!(!headers.is_empty());
|
||||
}
|
||||
|
||||
/// A host that is deliberately **not** `Send` or `Sync`: it keeps its state in
|
||||
/// an `Rc`, the way a single-threaded browser host has to, since
|
||||
/// `rusqlite::Connection` is not `Sync` to begin with. It also has no plugin
|
||||
/// runtime of any kind — no `PluginManager`, no sidecar, nothing to spawn.
|
||||
///
|
||||
/// Nothing here asserts much at runtime; the test is largely that it compiles.
|
||||
/// A `Host` demanding thread-safety, or a `PluginHost` handing back a
|
||||
/// `&PluginManager`, would shut such a host out of the traits entirely and this
|
||||
/// file would stop building.
|
||||
#[derive(Clone)]
|
||||
struct SingleThreadedHost {
|
||||
inner: Rc<Inner>,
|
||||
/// The values the last auth-config call arrived with, so a test can check
|
||||
/// they were rendered before the host ever saw them.
|
||||
auth_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||
}
|
||||
|
||||
impl Host for SingleThreadedHost {
|
||||
fn client_id(&self) -> &str {
|
||||
"tab-1"
|
||||
}
|
||||
|
||||
fn session(&self) -> WorkspaceContext {
|
||||
WorkspaceContext::new()
|
||||
}
|
||||
|
||||
fn app_version(&self) -> String {
|
||||
"0.0.0-web".to_string()
|
||||
}
|
||||
|
||||
fn query_manager(&self) -> &QueryManager {
|
||||
&self.inner.query_manager
|
||||
}
|
||||
|
||||
fn blob_manager(&self) -> &BlobManager {
|
||||
&self.inner.blob_manager
|
||||
}
|
||||
|
||||
fn encryption_manager(&self) -> &EncryptionManager {
|
||||
&self.inner.encryption_manager
|
||||
}
|
||||
}
|
||||
|
||||
/// A template callback with no plugins behind it: variables still resolve,
|
||||
/// function calls have nothing to run them. A browser host would put a Worker
|
||||
/// round-trip where this returns an error.
|
||||
struct NoTemplateFunctions;
|
||||
|
||||
impl TemplateCallback for NoTemplateFunctions {
|
||||
async fn run(
|
||||
&self,
|
||||
fn_name: &str,
|
||||
_args: HashMap<String, serde_json::Value>,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
Err(yaak_templates::error::Error::RenderError(format!(
|
||||
"no plugin runtime to run {fn_name}()"
|
||||
)))
|
||||
}
|
||||
|
||||
fn transform_arg(
|
||||
&self,
|
||||
_fn_name: &str,
|
||||
_arg_name: &str,
|
||||
arg_value: &str,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
Ok(arg_value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Answering plugin questions with no plugin runtime behind them. A browser
|
||||
/// host would put a `postMessage` round-trip to its Worker where these return
|
||||
/// constants; the shape of the trait is what makes either possible.
|
||||
impl PluginHost for SingleThreadedHost {
|
||||
async fn loaded_plugin_metadata(&self, _directory: &str) -> Option<PluginMetadata> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
|
||||
// No runtime to enrich them with; the database rows are still the truth
|
||||
// about what is installed.
|
||||
plugins
|
||||
}
|
||||
|
||||
async fn encrypt_secure_template(&self, _template: &str) -> yaak_commands::Result<String> {
|
||||
Err(yaak_commands::Error::Generic("no plugin runtime on this host".into()))
|
||||
}
|
||||
|
||||
fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback {
|
||||
NoTemplateFunctions
|
||||
}
|
||||
|
||||
async fn template_function_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
_values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||
Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()")))
|
||||
}
|
||||
|
||||
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
// No plugins, so nothing contributes actions and nothing can run one.
|
||||
|
||||
async fn http_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn websocket_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn grpc_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn call_http_request_action(
|
||||
&self,
|
||||
_req: CallHttpRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_grpc_request_action(
|
||||
&self,
|
||||
_req: CallGrpcRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_websocket_request_action(
|
||||
&self,
|
||||
_req: CallWebsocketRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_workspace_action(
|
||||
&self,
|
||||
_req: CallWorkspaceActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_folder_action(&self, _req: CallFolderActionRequest) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn http_authentication_config(
|
||||
&self,
|
||||
_auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
|
||||
*self.auth_values.borrow_mut() = Some(values);
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_http_authentication_action(
|
||||
&self,
|
||||
_auth_name: &str,
|
||||
_action_index: i32,
|
||||
_values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn import_data(&self, _content: &str) -> yaak_commands::Result<ImportResponse> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn reload_plugins(&self, _plugins: Vec<Plugin>) -> Vec<(String, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn no_plugins() -> yaak_commands::Error {
|
||||
yaak_commands::Error::Generic("no plugin runtime on this host".into())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_single_threaded_host_can_implement_the_trait() {
|
||||
let TestHost { inner } = TestHost::new();
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
|
||||
let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) })
|
||||
.await
|
||||
.expect("upsert");
|
||||
|
||||
// A `PluginHost` command, on a host with no plugin runtime at all. This is
|
||||
// the one that could not be written when the trait handed back a
|
||||
// `&PluginManager`.
|
||||
let json = models_workspace_models(
|
||||
host.clone(),
|
||||
ModelsWorkspaceModelsReq { workspace_id: Some(id.clone()) },
|
||||
)
|
||||
.await
|
||||
.expect("workspace models");
|
||||
assert!(json.contains(&id), "the workspace should be in its own bootstrap payload");
|
||||
|
||||
// Rendering, on a host whose template callback has no plugins behind it.
|
||||
// Resolving the environment chain is a database read and the render is
|
||||
// shared code; only the callback came from the host. Rendering a real
|
||||
// variable is what proves the chain was resolved rather than skipped.
|
||||
let environment = host
|
||||
.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: id.clone(),
|
||||
name: "Test env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "greeting".to_string(),
|
||||
value: "hello".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("seed environment");
|
||||
|
||||
let rendered = cmd_render_template(
|
||||
host.clone(),
|
||||
CmdRenderTemplateReq {
|
||||
template: "${[ greeting ]} world".to_string(),
|
||||
workspace_id: id.clone(),
|
||||
environment_id: Some(environment.id.clone()),
|
||||
purpose: None,
|
||||
ignore_error: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("render");
|
||||
assert_eq!(rendered, "hello world", "the environment chain should have been resolved");
|
||||
|
||||
// The delete path too, since it is the one that used to reach for a
|
||||
// blocking thread this host does not have.
|
||||
let workspace = host.db().get_workspace(&id).expect("get workspace");
|
||||
let deleted = models_delete(host, ModelsDeleteReq { model: AnyModel::Workspace(workspace) })
|
||||
.await
|
||||
.expect("delete");
|
||||
assert_eq!(deleted, id);
|
||||
}
|
||||
|
||||
/// Auth form values may contain templates, and a plugin must never see one
|
||||
/// unrendered. The rendering happens in the shared handler, so this checks the
|
||||
/// host received a resolved value rather than `${[ ... ]}`.
|
||||
#[tokio::test]
|
||||
async fn auth_values_are_rendered_before_the_host_sees_them() {
|
||||
let TestHost { inner } = TestHost::new();
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = host
|
||||
.db()
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Auth".to_string(), ..Default::default() },
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("workspace");
|
||||
host.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "token".to_string(),
|
||||
value: "s3cret".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("environment");
|
||||
let environment =
|
||||
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
|
||||
|
||||
let mut values = HashMap::new();
|
||||
values.insert("password".to_string(), JsonPrimitive::String("${[ token ]}".to_string()));
|
||||
|
||||
// The host refuses the call itself — it has no plugins — but only after the
|
||||
// handler has rendered and handed over the values, which is what matters.
|
||||
let _ = cmd_get_http_authentication_config(
|
||||
host.clone(),
|
||||
yaak_rpc_schema::CmdGetHttpAuthenticationConfigReq {
|
||||
auth_name: "basic".to_string(),
|
||||
values,
|
||||
model: AnyModel::Workspace(workspace),
|
||||
environment_id: Some(environment.id),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let seen = host.auth_values.borrow().clone().expect("the host should have been called");
|
||||
assert!(
|
||||
matches!(seen.get("password"), Some(JsonPrimitive::String(v)) if v == "s3cret"),
|
||||
"the template should have been rendered before reaching the host, got {:?}",
|
||||
seen.get("password"),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Context for a workspace operation.
|
||||
///
|
||||
/// In Tauri, this is extracted from the WebviewWindow URL.
|
||||
@@ -37,20 +35,3 @@ impl WorkspaceContext {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Application context trait for accessing app-level resources.
|
||||
///
|
||||
/// This abstracts over Tauri's `AppHandle` for path resolution and app identity.
|
||||
/// Implemented by Tauri's AppHandle and by CLI's own context struct.
|
||||
pub trait AppContext: Send + Sync + Clone {
|
||||
/// Returns the path to the application data directory.
|
||||
/// This is where the database and other persistent data are stored.
|
||||
fn app_data_dir(&self) -> PathBuf;
|
||||
|
||||
/// Returns the application identifier (e.g., "app.yaak.desktop").
|
||||
/// Used for keyring access and other platform-specific features.
|
||||
fn app_identifier(&self) -> &str;
|
||||
|
||||
/// Returns true if running in development mode.
|
||||
fn is_dev(&self) -> bool;
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
mod context;
|
||||
mod error;
|
||||
|
||||
pub use context::{AppContext, WorkspaceContext};
|
||||
pub use context::WorkspaceContext;
|
||||
pub use error::{Error, Result};
|
||||
|
||||
@@ -4,7 +4,9 @@ use log::{debug, info, warn};
|
||||
use reqwest::{Client, ClientBuilder, Proxy, redirect};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use yaak_models::models::DnsOverride;
|
||||
use yaak_tls::{ClientCertificateConfig, get_tls_config, load_client_identity_pkcs12};
|
||||
use yaak_tls::{
|
||||
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
|
||||
};
|
||||
|
||||
pub const HTTP2_MAX_RESPONSE_HEADER_LIST_SIZE: u32 = 1024 * 1024;
|
||||
|
||||
@@ -61,12 +63,19 @@ static IDENTITY_IMPORT: Mutex<()> = Mutex::new(());
|
||||
fn build_native_tls_identity(
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
) -> Result<Option<native_tls::Identity>> {
|
||||
let Some((pkcs12, password)) = load_client_identity_pkcs12(client_cert)? else {
|
||||
let Some(material) = load_native_client_identity(client_cert)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let _guard = IDENTITY_IMPORT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
Ok(Some(native_tls::Identity::from_pkcs12(&pkcs12, &password)?))
|
||||
Ok(Some(match material {
|
||||
NativeClientIdentity::Pkcs12 { data, password } => {
|
||||
native_tls::Identity::from_pkcs12(&data, &password)?
|
||||
}
|
||||
NativeClientIdentity::Pkcs8 { chain_pem, key_pem } => {
|
||||
native_tls::Identity::from_pkcs8(&chain_pem, &key_pem)?
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user