Compare commits

..
Author SHA1 Message Date
Gregory Schier c14f6ff3bb Prevent dismissing active imports 2026-08-16 23:08:11 -07:00
Gregory SchierandGitHub 7d83b5cf3a Merge branch 'main' into agent/stage-imports-before-commit 2026-08-16 22:59:01 -07:00
Gregory Schier 2d2a390bfd Use clang-18 from apt.llvm.org for the wasm build on 22.04 runners
clang-15 got past the C23 [[noreturn]] error but still fails compiling
sqlite-wasm-rs for wasm32: its stdint.h falls through to host glibc headers
(bits/libc-header-start.h not found). clang-18 handles wasm32 as freestanding
and compiles it (it is what ubuntu-24.04 uses). 22.04's repos stop at clang-15,
so install 18 from apt.llvm.org. Runners stay on 22.04 to keep the glibc floor.
2026-08-16 22:54:34 -07:00
Gregory Schier bc27e5eee7 Update CLI import pipeline 2026-08-16 22:50:24 -07:00
Gregory SchierandGitHub 395959f84c Merge branch 'main' into agent/stage-imports-before-commit 2026-08-16 22:33:14 -07:00
Gregory Schier 5cce23566a Allow manual worktree setup 2026-08-16 22:32:33 -07:00
Gregory SchierandGitHub b5b1ab4de8 Merge branch 'main' into agent/stage-imports-before-commit 2026-08-16 22:27:38 -07:00
Gregory Schier e471d73c34 Stage imports before committing 2026-08-16 22:25:04 -07:00
Gregory SchierandGitHub e99f6d2bc7 Gate the titlebar inset on a windowChrome capability instead of osType (#570) 2026-08-16 22:21:38 -07:00
Gregory SchierandGitHub bea58b16b4 Force text presentation for the Enter hotkey symbol (#569) 2026-08-16 22:21:04 -07:00
Gregory SchierandGitHub 93fba4d9b4 Restore ubuntu-22.04 release runners; use clang-15 for the wasm build (#568) 2026-08-16 21:52:56 -07:00
Gregory Schier b9071eafe0 Revert "Guard app releases against missing artifacts"
This reverts commit 778c74c635.
2026-08-16 18:19:47 -07:00
Gregory Schier 778c74c635 Guard app releases against missing artifacts 2026-08-16 17:30:10 -07:00
Gregory Schier 0f434361a7 Fix Linux WASM release builds 2026-08-16 15:41:00 -07:00
Gregory SchierandGitHub d27d11af7c Move plugin actions and authentication onto PluginHost (#563) 2026-08-16 11:41:03 -07:00
Gregory SchierandGitHub 1a19a06a23 Add native OpenAPI importer (#486) 2026-08-16 11:40:34 -07:00
Gregory SchierandGitHub 07a9a6c6c0 Update NTLM auth tests for the new send() response shape (#567) 2026-08-16 11:35:17 -07:00
Gregory SchierandGitHub e54240d579 Let plugins declare assets to place beside the bundle (#565) 2026-08-16 11:22:43 -07:00
Gregory SchierandGitHub 8bca013ab4 Fix plugin runtime build and JSON linter crash (#566) 2026-08-16 11:22:02 -07:00
Gregory SchierandGitHub 10e962a0e6 Add a plugin API for reading HTTP response bodies (#560) 2026-08-16 11:10:14 -07:00
Gregory SchierandGitHub 78954e10c8 Fix 23 Dependabot alerts (#562) 2026-08-16 10:27:28 -07:00
Gregory SchierandGitHub 9eb7a001da Move template rendering and themes onto PluginHost (#559) 2026-08-16 09:22:46 -07:00
Gregory SchierandGitHub 6a02cbe525 Add a Host trait and move DB/model commands off Tauri (#558) 2026-08-16 08:41:35 -07:00
Gregory SchierandGitHub 6f91f76064 Run the desktop's model layer in the browser (#557) 2026-08-16 07:34:48 -07:00
Gregory SchierandGitHub 32e92d484b Let yaak-models compile for wasm32-unknown-unknown (#556) 2026-08-15 14:55:54 -07:00
148 changed files with 17198 additions and 5605 deletions
+12
View File
@@ -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
+361 -77
View File
@@ -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,8 +11196,10 @@ name = "yaak"
version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"log 0.4.29",
"md5 0.8.0",
"rusqlite",
"serde_json",
"tempfile",
"thiserror 2.0.17",
@@ -10996,7 +11242,7 @@ dependencies = [
"pretty_graphql",
"r2d2",
"r2d2_sqlite",
"rand 0.9.1",
"rand 0.9.5",
"reqwest 0.12.20",
"rlimit",
"serde",
@@ -11023,6 +11269,7 @@ dependencies = [
"uuid",
"yaak",
"yaak-api",
"yaak-commands",
"yaak-common",
"yaak-core",
"yaak-crypto",
@@ -11080,7 +11327,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",
@@ -11102,6 +11349,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"
@@ -11136,6 +11400,7 @@ name = "yaak-database"
version = "0.1.0"
dependencies = [
"chrono",
"getrandom 0.2.16",
"include_dir",
"log 0.4.29",
"nanoid",
@@ -11148,6 +11413,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.17",
"ts-rs",
"uuid",
]
[[package]]
@@ -11269,7 +11535,7 @@ dependencies = [
"csscolorparser",
"log 0.4.29",
"objc",
"rand 0.9.1",
"rand 0.9.5",
"tauri",
"tauri-plugin",
]
@@ -11311,7 +11577,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",
@@ -11431,6 +11697,7 @@ version = "0.1.0"
dependencies = [
"regex 1.11.1",
"tauri",
"yaak-core",
]
[[package]]
@@ -11465,13 +11732,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",
+3
View File
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"crates/yaak",
"crates/yaak-commands",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
@@ -20,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
@@ -69,6 +71,7 @@ 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" }
+19
View File
@@ -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:
+14 -5
View File
@@ -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 },
+180 -21
View File
@@ -1,17 +1,28 @@
import type { Folder, ImportDestination, ImportPlan, Workspace } from "@yaakapp-internal/models";
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
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 { pluralizeCount } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import { RadioCards } from "./core/RadioCards";
interface Props {
importFile: (filePath: string) => Promise<void>;
importUrl: (url: string) => Promise<void>;
currentWorkspace: Workspace | null;
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
}
type DestinationChoice = "new_workspace" | "current_workspace";
/**
* 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).
@@ -31,8 +42,21 @@ function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
export function ImportDataDialog({ importFile, importUrl }: Props) {
export function ImportDataDialog({
currentWorkspace,
selectedFolder,
planFile,
planUrl,
commit,
cancel,
onError,
}: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [destinationChoice, setDestinationChoice] = useState<DestinationChoice>(
currentWorkspace == null ? "new_workspace" : "current_workspace",
);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != 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);
@@ -71,19 +95,110 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
selectSource(selected);
};
const handleImport = async () => {
const destination = (): ImportDestination => {
if (destinationChoice === "current_workspace" && currentWorkspace != null) {
return {
type: "current_workspace",
workspaceId: currentWorkspace.id,
folderId: targetSelectedFolder ? selectedFolder?.id : undefined,
};
}
return { type: "new_workspace" };
};
const handlePreview = async () => {
setIsLoading(true);
try {
if (filePath != null) {
await importFile(filePath);
} else {
await importUrl(trimmedSource);
}
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
setPlan(nextPlan);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit(plan);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
if (plan != null) {
const counts = [
["Workspace", plan.resources.workspaces.length],
["Environment", plan.resources.environments.length],
["Folder", plan.resources.folders.length],
["HTTP Request", plan.resources.httpRequests.length],
["gRPC Request", plan.resources.grpcRequests.length],
["WebSocket Request", plan.resources.websocketRequests.length],
] as const;
const destinationLabel =
plan.destination.type === "new_workspace"
? "New workspace"
: selectedFolder != null && plan.destination.folderId === selectedFolder.id
? `${currentWorkspace?.name ?? "Current workspace"} / ${selectedFolder.name}`
: (currentWorkspace?.name ?? "Current workspace");
return (
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label="Destination" value={destinationLabel} />
</div>
<div>
<div className="text-sm font-semibold mb-1">Resources</div>
<ul className="list-disc pl-6 text-sm text-text-subtle">
{counts
.filter(([, count]) => count > 0)
.map(([label, count]) => (
<li key={label}>{pluralizeCount(label, count)}</li>
))}
</ul>
</div>
{plan.warnings.length > 0 && (
<div>
<div className="text-sm font-semibold mb-1">Import details</div>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
{plan.warnings.map((warning) => (
<div
key={`${warning.title}:${warning.detail}`}
className="flex items-start gap-2.5 px-3 py-2.5"
>
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</div>
))}
</div>
</div>
)}
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading ? "Importing" : "Confirm Import"}
</Button>
</HStack>
</VStack>
);
}
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
@@ -115,25 +230,69 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<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}
<div className="text-sm font-semibold">Import destination</div>
<RadioCards
name="import-destination"
value={destinationChoice}
onChange={setDestinationChoice}
options={[
{
value: "new_workspace",
label: "New workspace",
description: "Create imported resources in a separate workspace.",
},
...(currentWorkspace == null
? []
: [
{
value: "current_workspace" as const,
label: currentWorkspace.name,
description: "Add resources without changing this workspace's settings.",
},
]),
]}
/>
{destinationChoice === "current_workspace" && selectedFolder != null && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={trimmedSource === "" || isLoading}
isLoading={isLoading}
size="sm"
onClick={handleImport}
onClick={handlePreview}
>
{isLoading ? "Importing" : "Import"}
{isLoading ? "Analyzing" : "Preview Import"}
</Button>
</VStack>
</HStack>
</VStack>
);
}
function PreviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
<span className="text-text-subtle">{label}</span>
<span className="text-right font-medium">{value}</span>
</div>
);
}
+9 -3
View File
@@ -10,6 +10,7 @@ export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
disableClose?: boolean;
disableBackdropClose?: boolean;
title?: ReactNode;
description?: ReactNode;
@@ -27,6 +28,7 @@ export function Dialog({
size = "full",
open,
onClose,
disableClose,
disableBackdropClose,
title,
description,
@@ -42,7 +44,11 @@ export function Dialog({
);
return (
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
<Overlay
open={open}
onClose={disableClose || disableBackdropClose ? undefined : onClose}
portalName="dialog"
>
<div
role="dialog"
className={classNames(
@@ -58,7 +64,7 @@ export function Dialog({
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
onClose?.();
if (!disableClose) onClose?.();
e.stopPropagation();
e.preventDefault();
}
@@ -110,7 +116,7 @@ export function Dialog({
</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!hideX && (
{!hideX && !disableClose && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
@@ -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",
},
];
};
}
+3 -1
View File
@@ -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") {
+29 -14
View File
@@ -1,10 +1,13 @@
import type { BatchUpsertResult } from "@yaakapp-internal/models";
import type { BatchUpsertResult, ImportDestination, ImportPlan } from "@yaakapp-internal/models";
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeFolderAtom } from "../hooks/useActiveFolder";
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";
@@ -21,29 +24,41 @@ export const importData = createFastMutation({
},
mutationFn: async () => {
return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const selectedFolder = jotaiStore.get(activeFolderAtom);
showDialog({
id: "import",
title: "Import Data",
size: "sm",
disableClose: true,
render: ({ hide }) => {
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
try {
await finishImport(await runImport());
resolve();
} catch (err) {
reject(err);
} finally {
hide();
}
const cancel = () => {
hide();
resolve();
};
const fail = (err: unknown) => {
hide();
reject(err);
};
const commit = async (plan: ImportPlan) => {
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
hide();
await finishImport(imported);
resolve();
};
return (
<ImportDataDialog
importFile={(filePath) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
currentWorkspace={currentWorkspace}
selectedFolder={selectedFolder}
planFile={(filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination })
}
importUrl={(url) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
planUrl={(url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination })
}
commit={commit}
cancel={cancel}
onError={fail}
/>
);
},
+3 -5
View File
@@ -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"
}
}
+6 -6
View File
@@ -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
}
+29 -2
View File
@@ -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: "" },
+2 -2
View File
@@ -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"
}
}
@@ -5,8 +5,7 @@ use std::fs;
use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams};
use yaak::import;
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination};
use yaak_plugins::events::{ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -51,6 +50,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.import_data(&plugin_context, &file_contents)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let importer = import_result.importer;
let resources = import_result.resources;
let workspace_id = args.workspace_id;
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
@@ -59,13 +59,13 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.to_string(),
);
}
let workspace_context = WorkspaceContext {
workspace_id,
environment_id: None,
cookie_jar_id: None,
request_id: None,
let destination = match workspace_id {
Some(workspace_id) => ImportDestination::CurrentWorkspace { workspace_id, folder_id: None },
None => ImportDestination::NewWorkspace,
};
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
let plan = import::plan_import_resources(ctx.query_manager(), importer, destination, resources)
.map_err(|e| format!("Failed to plan import: {e}"))?;
let imported = import::commit_import_plan(ctx.query_manager(), plan)
.map_err(|e| format!("Failed to import data: {e}"))?;
Ok(imported)
}
+161 -2
View File
@@ -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");
+13 -1
View File
@@ -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}"),
@@ -81,14 +81,21 @@ fn import_reads_yaak_workspace_file() {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
assert_eq!(
db.get_workspace("wrk_import").expect("workspace imported").name,
"Imported Workspace"
);
assert_eq!(
db.get_http_request("req_import").expect("request imported").url,
"https://example.com"
);
let workspaces = db.list_workspaces().expect("list imported workspaces");
let workspace = workspaces
.iter()
.find(|workspace| workspace.name == "Imported Workspace")
.expect("workspace imported");
assert_ne!(workspace.id, "wrk_import");
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
let request = requests
.iter()
.find(|request| request.name == "Imported Request")
.expect("request imported");
assert_ne!(request.id, "req_import");
assert_eq!(request.workspace_id, workspace.id);
assert_eq!(request.url, "https://example.com");
}
fn write_postman_environment_fixture(path: &std::path::Path) {
+3 -3
View File
@@ -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"] }
+2 -1
View File
@@ -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 = [
@@ -80,6 +80,7 @@ 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 }
@@ -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),
-17
View File
@@ -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>,
@@ -179,19 +179,3 @@ async fn send_http_request_inner<R: Runtime>(
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))
}
+29 -19
View File
@@ -4,53 +4,63 @@ use crate::models_ext::QueryManagerExt;
use std::fs::read_to_string;
use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, ImportDataParams};
use yaak::import::{self, PlanImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_plugins::manager::PluginManager;
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
let plan = plan_import_data(window, file_path, ImportDestination::NewWorkspace).await?;
commit_import(window, plan)
}
pub(crate) async fn import_url<R: Runtime>(
pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination).await
}
pub(crate) async fn plan_import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
) -> Result<BatchUpsertResult> {
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = fetch_import_url(window, url).await?;
import_contents(window, &contents).await
plan_import_contents(window, &contents, destination).await
}
async fn import_contents<R: Runtime>(
async fn plan_import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
) -> Result<BatchUpsertResult> {
destination: ImportDestination,
) -> Result<ImportPlan> {
let plugin_manager = window.state::<PluginManager>();
let query_manager = window.db_manager();
let plugin_context = window.plugin_context();
let workspace_context = WorkspaceContext {
workspace_id: window.workspace_id(),
environment_id: window.environment_id(),
cookie_jar_id: window.cookie_jar_id(),
request_id: None,
};
Ok(import::import_data(ImportDataParams {
Ok(import::plan_import_data(PlanImportDataParams {
query_manager: &query_manager,
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
workspace_context,
destination,
contents,
})
.await?)
}
pub(crate) fn commit_import<R: Runtime>(
window: &WebviewWindow<R>,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
}
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
/// pipeline as a file on disk.
///
+36 -544
View File
@@ -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, import_url};
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
use crate::import::{commit_import, plan_import_data, plan_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,42 +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_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, 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;
@@ -223,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,
@@ -299,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,
@@ -359,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(),
@@ -1011,10 +952,6 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
Ok(EphemeralHttpResponse { response: sent.response, body })
}
async fn cmd_format_json(text: &str) -> YaakResult<String> {
Ok(format_json(text, " "))
}
async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
match pretty_graphql::format_text(text, &Default::default()) {
Ok(formatted) => Ok(formatted),
@@ -1022,44 +959,13 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
}
}
/// Where a response's body is, and what it is meant to be read as.
struct ResponseBodyLocation {
/// None when the response has no stored body.
path: Option<PathBuf>,
/// The response's declared `Content-Type`, empty when it has none.
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.
fn locate_response_body<R: Runtime>(
app_handle: &AppHandle<R>,
response_id: &str,
) -> YaakResult<ResponseBodyLocation> {
let response = app_handle.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(),
})
}
async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
response_id: &str,
filter: Option<&str>,
) -> YaakResult<FilterResponse> {
let location = locate_response_body(window.app_handle(), response_id)?;
let location = locate_response_body(&window.db(), response_id)?;
let Some(body_path) = location.path else {
return Ok(FilterResponse { content: String::new(), error: None });
};
@@ -1077,41 +983,11 @@ async fn cmd_http_response_body<R: Runtime>(
}
}
/// 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.
async fn cmd_http_response_body_path<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Option<String>> {
let location = locate_response_body(&app_handle, response_id)?;
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
}
async fn cmd_http_request_body<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)?;
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<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Vec<ServerSentEvent>> {
let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else {
let Some(body_path) = locate_response_body(&app_handle.db(), response_id)?.path else {
return Ok(Vec::new());
};
@@ -1134,321 +1010,42 @@ async fn cmd_get_sse_events<R: Runtime>(
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,
) -> YaakResult<BatchUpsertResult> {
import_data(&window, file_path).await
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_data(&window, file_path, destination).await
}
async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_url(&window, url, destination).await
}
async fn cmd_commit_import<R: Runtime>(
window: WebviewWindow<R>,
plan: ImportPlan,
) -> YaakResult<BatchUpsertResult> {
import_url(&window, url).await
commit_import(&window, plan)
}
async fn cmd_http_request_actions<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<Vec<GetHttpRequestActionsResponse>> {
Ok(plugin_manager.get_http_request_actions(&window.plugin_context()).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.
///
@@ -1473,20 +1070,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>,
@@ -1558,101 +1141,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>,
@@ -1972,6 +1460,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:?}");
@@ -1984,7 +1473,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,163 +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> {
let db = window.db();
let blobs = window.blob_manager();
let source = &UpdateSource::from_window_label(window.label());
yaak::models_ops::upsert_model(&db, &blobs, model, source)
}
// 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());
yaak::models_ops::delete_model(tx, &blobs, model, source)
})
})
.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 transaction for duplications because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
yaak::models_ops::duplicate_model(tx, &model_type, &model_id, source)
})
}
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.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> {
+7 -24
View File
@@ -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};
+339 -73
View File
@@ -8,10 +8,13 @@
//! is the only way the frontend reaches any of it — one envelope,
//! `{ cmd, payload }`, exactly like the proxy app.
//!
//! Adapters exist so command implementations keep their natural Tauri
//! signatures (window, app handle, managed state) while the wire format stays
//! transport-agnostic: another host builds its router from the same schema with
//! a different context type and its own adapters, and the frontend cannot tell.
//! Command bodies live in one of two places. Host-independent ones are in
//! `yaak_commands`, written against its `Host` trait, which `ClientCtx`
//! implements below; their adapters are one line. The rest still have their
//! natural Tauri signatures (window, app handle, managed state) and their
//! adapters unpack the request for them. Either way the wire format stays
//! transport-agnostic: another host builds its router from the same schema
//! with its own `Host`, and the frontend cannot tell.
use crate::error::Result;
use crate::notifications::YaakNotifier;
@@ -19,7 +22,11 @@ use crate::updates::YaakUpdater;
use log::warn;
use serde::Serialize;
use tauri::{Manager, Runtime, State, WebviewWindow};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use yaak_commands::{Host, PluginHost};
use yaak_core::WorkspaceContext;
use yaak_crypto::manager::EncryptionManager;
use yaak_git::{
BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
@@ -27,13 +34,17 @@ use yaak_git::{
};
use yaak_grpc::manager::GrpcHandle;
use yaak_grpc::ServiceDefinition;
use yaak_models::blob_manager::BlobManager;
use yaak_models::models::{
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::util::BatchUpsertResult;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, ImportPlan};
use yaak_plugins::events::{
FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
@@ -41,11 +52,15 @@ use yaak_plugins::events::{
};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::native_template_functions::encrypt_secure_template_function;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_rpc::RpcRouter;
use yaak_rpc_schema::*;
use yaak_sse::sse::ServerSentEvent;
use yaak_sync::sync::SyncOp;
use yaak_templates::TemplateCallback;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_ws::WebsocketManager;
/// Per-call context: the window a command was invoked from.
@@ -65,6 +80,215 @@ impl<R: Runtime> Clone for ClientCtx<R> {
}
}
/// The desktop is a host: the client is the window, the session is the
/// window's URL, and the shared managers are Tauri managed state.
impl<R: Runtime> Host for ClientCtx<R> {
fn client_id(&self) -> &str {
self.window.label()
}
fn session(&self) -> WorkspaceContext {
self.window.workspace_context()
}
fn app_version(&self) -> String {
self.window.package_info().version.to_string()
}
fn query_manager(&self) -> &QueryManager {
self.window.state::<QueryManager>().inner()
}
fn blob_manager(&self) -> &BlobManager {
self.window.state::<BlobManager>().inner()
}
fn encryption_manager(&self) -> &EncryptionManager {
self.window.state::<EncryptionManager>().inner()
}
}
impl<R: Runtime> ClientCtx<R> {
/// The plugin runtime this window talks to. Only the `PluginHost` impl
/// below uses it; everything else goes through the trait.
fn pm(&self) -> State<'_, PluginManager> {
self.window.state::<PluginManager>()
}
}
/// The desktop answers all of these out of the `PluginManager` it already
/// runs — the Node sidecar. Each is a delegation, which is the point: the
/// operations are what the handlers need, and this is one host's way of
/// providing them.
impl<R: Runtime> PluginHost for ClientCtx<R> {
async fn loaded_plugin_metadata(&self, directory: &str) -> Option<PluginMetadata> {
let handle = self.pm().get_plugin_by_dir(directory).await?;
Some(handle.info())
}
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
self.pm().take_init_errors().await
}
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
self.pm().resolve_plugins_for_runtime_from_db(plugins).await
}
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback {
PluginTemplateCallback::new(
Arc::new((*self.pm()).clone()),
Arc::new(self.encryption_manager().clone()),
&self.plugin_context(),
purpose,
)
}
async fn template_function_summaries(
&self,
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
Ok(self
.window
.state::<PluginManager>()
.get_template_function_summaries(&self.plugin_context())
.await?)
}
async fn template_function_config(
&self,
function_name: &str,
values: HashMap<String, JsonPrimitive>,
model_id: &str,
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
Ok(self
.window
.state::<PluginManager>()
.get_template_function_config(&self.plugin_context(), function_name, values, model_id)
.await?)
}
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
Ok(self.pm().get_themes(&self.plugin_context()).await?)
}
async fn http_request_actions(
&self,
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
Ok(self.pm().get_http_request_actions(&self.plugin_context()).await?)
}
async fn websocket_request_actions(
&self,
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
Ok(self.pm().get_websocket_request_actions(&self.plugin_context()).await?)
}
async fn grpc_request_actions(
&self,
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
Ok(self.pm().get_grpc_request_actions(&self.plugin_context()).await?)
}
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
Ok(self.pm().get_workspace_actions(&self.plugin_context()).await?)
}
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
Ok(self.pm().get_folder_actions(&self.plugin_context()).await?)
}
async fn call_http_request_action(
&self,
req: CallHttpRequestActionRequest,
) -> yaak_commands::Result<()> {
Ok(self.pm().call_http_request_action(&self.plugin_context(), req).await?)
}
async fn call_grpc_request_action(
&self,
req: CallGrpcRequestActionRequest,
) -> yaak_commands::Result<()> {
Ok(self.pm().call_grpc_request_action(&self.plugin_context(), req).await?)
}
async fn call_websocket_request_action(
&self,
req: CallWebsocketRequestActionRequest,
) -> yaak_commands::Result<()> {
Ok(self.pm().call_websocket_request_action(&self.plugin_context(), req).await?)
}
async fn call_workspace_action(
&self,
req: CallWorkspaceActionRequest,
) -> yaak_commands::Result<()> {
Ok(self.pm().call_workspace_action(&self.plugin_context(), req).await?)
}
async fn call_folder_action(
&self,
req: CallFolderActionRequest,
) -> yaak_commands::Result<()> {
Ok(self.pm().call_folder_action(&self.plugin_context(), req).await?)
}
async fn http_authentication_summaries(
&self,
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
let results = self.pm().get_http_authentication_summaries(&self.plugin_context()).await?;
Ok(results.into_iter().map(|(_, a)| a).collect())
}
async fn http_authentication_config(
&self,
auth_name: &str,
values: HashMap<String, JsonPrimitive>,
model_id: &str,
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
Ok(self
.pm()
.get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id)
.await?)
}
async fn call_http_authentication_action(
&self,
auth_name: &str,
action_index: i32,
values: HashMap<String, JsonPrimitive>,
model_id: &str,
) -> yaak_commands::Result<()> {
Ok(self
.pm()
.call_http_authentication_action(
&self.plugin_context(),
auth_name,
action_index,
values,
model_id,
)
.await?)
}
async fn import_data(&self, content: &str) -> yaak_commands::Result<ImportResponse> {
Ok(self.pm().import_data(&self.plugin_context(), content).await?)
}
async fn reload_plugins(&self, plugins: Vec<Plugin>) -> Vec<(String, String)> {
self.pm().initialize_all_plugins(plugins, &self.plugin_context()).await
}
async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> {
let plugin_manager = Arc::new((*self.pm()).clone());
let encryption_manager = Arc::new(self.encryption_manager().clone());
Ok(encrypt_secure_template_function(
plugin_manager,
encryption_manager,
&self.plugin_context(),
template,
)?)
}
}
/// The one Tauri command. The payload is the yaak-rpc envelope's payload;
/// a missing payload means an empty one.
#[tauri::command]
@@ -158,11 +382,11 @@ async fn cmd_metadata<R: Runtime>(ctx: ClientCtx<R>, _req: CmdMetadataReq) -> Re
}
async fn cmd_template_tokens_to_string<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateTokensToStringReq) -> Result<String> {
Ok(crate::cmd_template_tokens_to_string(ctx.window.clone(), ctx.window.app_handle().clone(), req.tokens).await?)
Ok(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?)
}
async fn cmd_render_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdRenderTemplateReq) -> Result<String> {
Ok(crate::cmd_render_template(ctx.window.clone(), ctx.window.app_handle().clone(), &req.template, &req.workspace_id, req.environment_id.as_deref(), req.purpose, req.ignore_error).await?)
Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?)
}
async fn cmd_send_feedback<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendFeedbackReq) -> Result<()> {
@@ -189,8 +413,8 @@ async fn cmd_send_ephemeral_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendE
Ok(crate::cmd_send_ephemeral_request(req.request, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.clone(), ctx.window.app_handle().clone()).await?)
}
async fn cmd_format_json<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatJsonReq) -> Result<String> {
Ok(crate::cmd_format_json(&req.text).await?)
async fn cmd_format_json<R: Runtime>(ctx: ClientCtx<R>, req: CmdFormatJsonReq) -> Result<String> {
Ok(yaak_commands::data::cmd_format_json(ctx, req).await?)
}
async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphqlReq) -> Result<String> {
@@ -202,11 +426,11 @@ async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRespo
}
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
Ok(crate::cmd_http_response_body_path(ctx.window.app_handle().clone(), &req.response_id).await?)
Ok(yaak_commands::responses::cmd_http_response_body_path(ctx, req).await?)
}
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?)
Ok(yaak_commands::responses::cmd_http_request_body(ctx, req).await?)
}
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
@@ -214,83 +438,87 @@ async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsR
}
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
Ok(crate::cmd_get_http_response_events(ctx.window.app_handle().clone(), &req.response_id).await?)
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
}
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
}
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?)
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
}
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
Ok(crate::cmd_http_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
}
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
Ok(crate::cmd_websocket_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
Ok(yaak_commands::actions::cmd_http_request_actions(ctx, req).await?)
}
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
Ok(yaak_commands::actions::cmd_websocket_request_actions(ctx, req).await?)
}
async fn cmd_call_websocket_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWebsocketRequestActionReq) -> Result<()> {
Ok(crate::cmd_call_websocket_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::actions::cmd_call_websocket_request_action(ctx, req).await?)
}
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
Ok(crate::cmd_workspace_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
Ok(yaak_commands::actions::cmd_workspace_actions(ctx, req).await?)
}
async fn cmd_call_workspace_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWorkspaceActionReq) -> Result<()> {
Ok(crate::cmd_call_workspace_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::actions::cmd_call_workspace_action(ctx, req).await?)
}
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
Ok(crate::cmd_folder_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
Ok(yaak_commands::actions::cmd_folder_actions(ctx, req).await?)
}
async fn cmd_call_folder_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallFolderActionReq) -> Result<()> {
Ok(crate::cmd_call_folder_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::actions::cmd_call_folder_action(ctx, req).await?)
}
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
Ok(crate::cmd_grpc_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
Ok(yaak_commands::actions::cmd_grpc_request_actions(ctx, req).await?)
}
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, _req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
Ok(crate::cmd_template_function_summaries(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
Ok(yaak_commands::templates::cmd_template_function_summaries(ctx, req).await?)
}
async fn cmd_template_function_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionConfigReq) -> Result<GetTemplateFunctionConfigResponse> {
Ok(crate::cmd_template_function_config(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.function_name, req.values, req.model, req.environment_id.as_deref()).await?)
Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?)
}
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
Ok(crate::cmd_get_http_authentication_summaries(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
Ok(yaak_commands::auth::cmd_get_http_authentication_summaries(ctx, req).await?)
}
async fn cmd_get_http_authentication_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationConfigReq) -> Result<GetHttpAuthenticationConfigResponse> {
Ok(crate::cmd_get_http_authentication_config(ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<EncryptionManager>(), &req.auth_name, req.values, req.model, req.environment_id.as_deref()).await?)
Ok(yaak_commands::auth::cmd_get_http_authentication_config(ctx, req).await?)
}
async fn cmd_call_http_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpRequestActionReq) -> Result<()> {
Ok(crate::cmd_call_http_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::actions::cmd_call_http_request_action(ctx, req).await?)
}
async fn cmd_call_grpc_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallGrpcRequestActionReq) -> Result<()> {
Ok(crate::cmd_call_grpc_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::actions::cmd_call_grpc_request_action(ctx, req).await?)
}
async fn cmd_call_http_authentication_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpAuthenticationActionReq) -> Result<()> {
Ok(crate::cmd_call_http_authentication_action(ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<EncryptionManager>(), &req.auth_name, req.action_index, req.values, req.model, req.environment_id.as_deref()).await?)
Ok(yaak_commands::auth::cmd_call_http_authentication_action(ctx, req).await?)
}
async fn cmd_curl_to_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
Ok(crate::cmd_curl_to_request(ctx.window.clone(), &req.command, ctx.window.app_handle().state::<PluginManager>(), &req.workspace_id).await?)
Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?)
}
async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -> Result<()> {
Ok(crate::cmd_export_data(ctx.window.app_handle().clone(), &req.export_path, req.workspace_ids.iter().map(|s| s.as_str()).collect(), req.include_private_environments).await?)
Ok(yaak_commands::data::cmd_export_data(ctx, req).await?)
}
async fn cmd_save_base64_to_binary<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveBase64ToBinaryReq) -> Result<()> {
@@ -298,35 +526,35 @@ async fn cmd_save_base64_to_binary<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveBa
}
async fn cmd_save_response<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveResponseReq) -> Result<()> {
Ok(crate::cmd_save_response(ctx.window.app_handle().clone(), &req.response_id, &req.filepath).await?)
Ok(yaak_commands::responses::cmd_save_response(ctx, req).await?)
}
async fn cmd_send_http_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendHttpRequestReq) -> Result<HttpResponse> {
Ok(crate::cmd_send_http_request(ctx.window.app_handle().clone(), ctx.window.clone(), req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), req.request_id).await?)
}
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, _req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
Ok(crate::cmd_reload_plugins(ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
Ok(yaak_commands::actions::cmd_reload_plugins(ctx, req).await?)
}
async fn cmd_plugin_info<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
Ok(crate::cmd_plugin_info(&req.id, ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::plugins::cmd_plugin_info(ctx, req).await?)
}
async fn cmd_delete_all_grpc_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllGrpcConnectionsReq) -> Result<()> {
Ok(crate::cmd_delete_all_grpc_connections(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
Ok(yaak_commands::models::cmd_delete_all_grpc_connections(ctx, req).await?)
}
async fn cmd_delete_send_history<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteSendHistoryReq) -> Result<()> {
Ok(crate::cmd_delete_send_history(&req.workspace_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
Ok(yaak_commands::models::cmd_delete_send_history(ctx, req).await?)
}
async fn cmd_delete_all_http_responses<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllHttpResponsesReq) -> Result<()> {
Ok(crate::cmd_delete_all_http_responses(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
Ok(yaak_commands::models::cmd_delete_all_http_responses(ctx, req).await?)
}
async fn cmd_get_workspace_meta<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetWorkspaceMetaReq) -> Result<WorkspaceMeta> {
Ok(crate::cmd_get_workspace_meta(ctx.window.app_handle().clone(), &req.workspace_id).await?)
Ok(yaak_commands::models::cmd_get_workspace_meta(ctx, req).await?)
}
async fn cmd_new_child_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewChildWindowReq) -> Result<()> {
@@ -342,71 +570,110 @@ async fn cmd_check_for_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdCheckForU
}
async fn cmd_decrypt_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdDecryptTemplateReq) -> Result<String> {
Ok(crate::commands::cmd_decrypt_template(ctx.window.clone(), &req.template).await?)
Ok(yaak_commands::encryption::cmd_decrypt_template(ctx, req).await?)
}
async fn cmd_secure_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdSecureTemplateReq) -> Result<String> {
Ok(crate::commands::cmd_secure_template(ctx.window.app_handle().clone(), ctx.window.clone(), &req.template).await?)
Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?)
}
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
Ok(crate::commands::cmd_get_themes(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
Ok(yaak_commands::templates::cmd_get_themes(ctx, req).await?)
}
async fn cmd_enable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdEnableEncryptionReq) -> Result<()> {
Ok(crate::commands::cmd_enable_encryption(ctx.window.clone(), &req.workspace_id).await?)
Ok(yaak_commands::encryption::cmd_enable_encryption(ctx, req).await?)
}
async fn cmd_reveal_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdRevealWorkspaceKeyReq) -> Result<String> {
Ok(crate::commands::cmd_reveal_workspace_key(ctx.window.clone(), &req.workspace_id).await?)
Ok(yaak_commands::encryption::cmd_reveal_workspace_key(ctx, req).await?)
}
async fn cmd_set_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdSetWorkspaceKeyReq) -> Result<()> {
Ok(crate::commands::cmd_set_workspace_key(ctx.window.clone(), &req.workspace_id, &req.key).await?)
Ok(yaak_commands::encryption::cmd_set_workspace_key(ctx, req).await?)
}
async fn cmd_disable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdDisableEncryptionReq) -> Result<()> {
Ok(crate::commands::cmd_disable_encryption(ctx.window.clone(), &req.workspace_id).await?)
Ok(yaak_commands::encryption::cmd_disable_encryption(ctx, req).await?)
}
async fn cmd_default_headers<R: Runtime>(_ctx: ClientCtx<R>, _req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
Ok(crate::commands::cmd_default_headers())
async fn cmd_default_headers<R: Runtime>(ctx: ClientCtx<R>, req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?)
}
async fn models_upsert<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertReq) -> Result<String> {
Ok(crate::models_ext::models_upsert(ctx.window.clone(), req.model)?)
Ok(yaak_commands::models::models_upsert(ctx, req).await?)
}
/// Runs on a blocking thread rather than the async runtime: a cascading delete
/// (a workspace with thousands of requests) holds a transaction for its whole
/// duration, and stalling the runtime stalls every other IPC call behind it.
/// That is this host's concern, so the shared handler stays plain and the
/// relocation happens here.
async fn models_delete<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDeleteReq) -> Result<String> {
Ok(crate::models_ext::models_delete(ctx.window.clone(), req.model).await?)
let deleted = tauri::async_runtime::spawn_blocking(move || {
yaak_commands::models::models_delete_blocking(&ctx, req)
})
.await
.map_err(|e| crate::error::Error::GenericError(format!("Delete task failed: {e}")))?;
Ok(deleted?)
}
async fn models_duplicate<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq) -> Result<String> {
Ok(crate::models_ext::models_duplicate(ctx.window.clone(), req.model_type, req.model_id)?)
Ok(yaak_commands::models::models_duplicate(ctx, req).await?)
}
async fn models_websocket_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWebsocketEventsReq) -> Result<Vec<WebsocketEvent>> {
Ok(crate::models_ext::models_websocket_events(ctx.window.app_handle().clone(), &req.connection_id)?)
Ok(yaak_commands::models::models_websocket_events(ctx, req).await?)
}
async fn models_grpc_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGrpcEventsReq) -> Result<Vec<GrpcEvent>> {
Ok(crate::models_ext::models_grpc_events(ctx.window.app_handle().clone(), &req.connection_id)?)
Ok(yaak_commands::models::models_grpc_events(ctx, req).await?)
}
async fn models_get_settings<R: Runtime>(ctx: ClientCtx<R>, _req: ModelsGetSettingsReq) -> Result<Settings> {
Ok(crate::models_ext::models_get_settings(ctx.window.app_handle().clone())?)
async fn models_get_settings<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetSettingsReq) -> Result<Settings> {
Ok(yaak_commands::models::models_get_settings(ctx, req).await?)
}
async fn models_get_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetGraphqlIntrospectionReq) -> Result<Option<GraphQlIntrospection>> {
Ok(crate::models_ext::models_get_graphql_introspection(ctx.window.app_handle().clone(), &req.request_id)?)
Ok(yaak_commands::models::models_get_graphql_introspection(ctx, req).await?)
}
async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertGraphqlIntrospectionReq) -> Result<GraphQlIntrospection> {
Ok(crate::models_ext::models_upsert_graphql_introspection(ctx.window.app_handle().clone(), &req.request_id, &req.workspace_id, req.content, ctx.window.clone())?)
Ok(yaak_commands::models::models_upsert_graphql_introspection(ctx, req).await?)
}
/// Non-ASCII is escaped to `\uXXXX` before the JSON crosses into the webview:
/// on Linux, sending Cyrillic (and possibly other) characters through this
/// payload leaves every string in the parsed models subtly mis-encoded and
/// CodeMirror unable to place the cursor (feedback: "editing the URL sometimes
/// freezes the app"). Escape sequences sidestep it. This is a quirk of the
/// webview transport, not of the data, so it lives in the adapter rather than
/// the shared handler.
async fn models_workspace_models<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWorkspaceModelsReq) -> Result<String> {
Ok(crate::models_ext::models_workspace_models(ctx.window.clone(), req.workspace_id.as_deref(), ctx.window.app_handle().state::<PluginManager>()).await?)
let json = yaak_commands::models::models_workspace_models(ctx, req).await?;
Ok(escape_str_for_webview(&json))
}
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()
}
async fn cmd_git_checkout<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitCheckoutReq) -> Result<String> {
@@ -538,7 +805,7 @@ async fn cmd_sync_apply<R: Runtime>(ctx: ClientCtx<R>, req: CmdSyncApplyReq) ->
}
async fn cmd_ws_delete_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsDeleteConnectionsReq) -> Result<()> {
Ok(crate::ws_ext::cmd_ws_delete_connections(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
Ok(yaak_commands::models::cmd_ws_delete_connections(ctx, req).await?)
}
async fn cmd_ws_send<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsSendReq) -> Result<WebsocketConnection> {
@@ -569,8 +836,8 @@ async fn cmd_plugins_uninstall<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsUni
Ok(crate::plugins_ext::cmd_plugins_uninstall(&req.plugin_id, ctx.window.clone()).await?)
}
async fn cmd_plugin_init_errors<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
Ok(crate::plugins_ext::cmd_plugin_init_errors(ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_plugin_init_errors<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
Ok(yaak_commands::plugins::cmd_plugin_init_errors(ctx, req).await?)
}
async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdatesReq) -> Result<PluginUpdatesResponse> {
@@ -580,4 +847,3 @@ async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpda
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
}
+4 -31
View File
@@ -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
+1
View File
@@ -7,3 +7,4 @@ publish = false
[dependencies]
tauri = { workspace = true }
regex = "1.11.0"
yaak-core = { workspace = true }
+30 -15
View File
@@ -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())
}
+13 -5
View File
@@ -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,
+1 -1
View File
@@ -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),
+5 -2
View File
@@ -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;
+2 -3
View File
@@ -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
+80
View File
@@ -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::*;
+1 -1
View File
@@ -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, };
File diff suppressed because one or more lines are too long
+10
View File
@@ -2,3 +2,13 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportDestination = { "type": "new_workspace" } | { "type": "current_workspace", workspaceId: string, folderId?: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: ImportPlanResources, warnings: Array<ImportPlanWarning>, };
export type ImportPlanWarning = { title: string, detail: string, };
export type ImportPlanResources = { workspaces: Array<PlannedImportResource<Workspace>>, environments: Array<PlannedImportResource<Environment>>, folders: Array<PlannedImportResource<Folder>>, httpRequests: Array<PlannedImportResource<HttpRequest>>, grpcRequests: Array<PlannedImportResource<GrpcRequest>>, websocketRequests: Array<PlannedImportResource<WebsocketRequest>>, };
export type PlannedImportResource<T> = { sourceKey?: string, resource: T, };
+13 -3
View File
@@ -23,7 +23,7 @@ use yaak_models::models::{
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
@@ -229,6 +229,7 @@ pub struct CmdGetHttpResponseEventsReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportDataReq {
pub file_path: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
@@ -236,6 +237,14 @@ pub struct CmdImportDataReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportUrlReq {
pub url: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdCommitImportReq {
pub plan: ImportPlan,
}
#[derive(Debug, Deserialize, TS)]
@@ -909,8 +918,9 @@ macro_rules! with_commands {
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
+21
View File
@@ -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"] }
+157
View File
@@ -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)
}
+102
View File
@@ -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)?)
}
+23
View File
@@ -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, " "))
}
+41
View File
@@ -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
}
+30
View File
@@ -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>;
+223
View File
@@ -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>>;
}
+28
View File
@@ -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};
+179
View File
@@ -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())
}
+48
View File
@@ -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)
}
+30
View File
@@ -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
}
+56
View File
@@ -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))
}
+86
View File
@@ -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(())
}
+67
View File
@@ -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
}
+501
View File
@@ -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"),
);
}
-19
View File
@@ -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;
}
+1 -1
View File
@@ -6,5 +6,5 @@
mod context;
mod error;
pub use context::{AppContext, WorkspaceContext};
pub use context::WorkspaceContext;
pub use error::{Error, Result};
+7 -5
View File
@@ -11,11 +11,9 @@ hex = { workspace = true }
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 = { version = "1.0", features = ["with-chrono", "attr"] }
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
schemars = { workspace = true }
@@ -23,3 +21,7 @@ sha2 = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
yaak-core = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.32" }
-1
View File
@@ -225,7 +225,6 @@ export type HttpResponse = {
updatedAt: string;
workspaceId: string;
requestId: string;
bodyPath: string | null;
contentLength: number | null;
contentLengthCompressed: number | null;
elapsed: number;
+10
View File
@@ -2,3 +2,13 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportDestination = { "type": "new_workspace" } | { "type": "current_workspace", workspaceId: string, folderId?: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: ImportPlanResources, warnings: Array<ImportPlanWarning>, };
export type ImportPlanWarning = { title: string, detail: string, };
export type ImportPlanResources = { workspaces: Array<PlannedImportResource<Workspace>>, environments: Array<PlannedImportResource<Environment>>, folders: Array<PlannedImportResource<Folder>>, httpRequests: Array<PlannedImportResource<HttpRequest>>, grpcRequests: Array<PlannedImportResource<GrpcRequest>>, websocketRequests: Array<PlannedImportResource<WebsocketRequest>>, };
export type PlannedImportResource<T> = { sourceKey?: string, resource: T, };
+8 -9
View File
@@ -2,9 +2,8 @@ use crate::error::Result;
use crate::util::generate_prefixed_id;
use include_dir::{Dir, include_dir};
use log::{debug, info};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, params};
use yaak_database::{SqliteConn, SqlitePool};
static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations");
@@ -29,11 +28,11 @@ impl BodyChunk {
// whole app whenever the pool is exhausted.
#[derive(Debug, Clone)]
pub struct BlobManager {
pool: Pool<SqliteConnectionManager>,
pool: SqlitePool,
}
impl BlobManager {
pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
@@ -45,7 +44,7 @@ impl BlobManager {
/// Context for blob database operations.
pub struct BlobContext {
conn: r2d2::PooledConnection<SqliteConnectionManager>,
conn: SqliteConn,
}
impl BlobContext {
@@ -131,7 +130,7 @@ impl BlobContext {
}
/// Run migrations for the blob database.
pub fn migrate_blob_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
info!("Running blob database migrations");
// Create migrations tracking table
@@ -198,9 +197,9 @@ pub fn migrate_blob_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
mod tests {
use super::*;
fn create_test_pool() -> Pool<SqliteConnectionManager> {
let manager = SqliteConnectionManager::memory();
let pool = Pool::builder().max_size(1).build(manager).unwrap();
fn create_test_pool() -> SqlitePool {
let manager = r2d2_sqlite::SqliteConnectionManager::memory();
let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap();
migrate_blob_db(&pool).unwrap();
pool
}
+1 -1
View File
@@ -7,7 +7,7 @@ pub enum Error {
SqlError(#[from] rusqlite::Error),
#[error("SQL Pool error: {0}")]
SqlPoolError(#[from] r2d2::Error),
SqlPoolError(#[from] yaak_database::PoolError),
#[error("Database error: {0}")]
Database(String),
+79 -55
View File
@@ -1,15 +1,12 @@
use crate::blob_manager::{BlobManager, migrate_blob_db};
use crate::error::{Error, Result};
use crate::error::Result;
use crate::migrate::migrate_db;
use crate::query_manager::QueryManager;
use crate::util::ModelPayload;
use log::info;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::fs::create_dir_all;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use yaak_database::SqlitePool;
pub mod blob_manager;
pub mod client_db;
@@ -17,22 +14,85 @@ mod connection_or_tx;
pub mod error;
pub mod migrate;
pub mod models;
pub mod models_ops;
pub mod queries;
pub mod query_manager;
pub mod render;
pub mod util;
fn sqlite_file_manager(path: impl Into<PathBuf>) -> SqliteConnectionManager {
SqliteConnectionManager::file(path.into()).with_init(|conn| {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.busy_timeout(Duration::from_millis(5000))
})
/// Per-connection setup, applied by every pool on every connection it opens.
fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
conn.busy_timeout(std::time::Duration::from_millis(5000))
}
fn sqlite_memory_manager() -> SqliteConnectionManager {
SqliteConnectionManager::memory()
.with_init(|conn| conn.busy_timeout(Duration::from_millis(5000)))
#[cfg(not(target_arch = "wasm32"))]
fn init_file_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
init_connection(conn)
}
/// The two ways a pool comes to exist, one per target.
///
/// On the desktop and CLI, an r2d2 pool over a file. In a browser, a single
/// connection over whatever VFS the host registered before calling in — the
/// path is a name inside that VFS, not a place on disk. Everything downstream
/// of `SqlitePool` is target-agnostic; this is the only fork.
#[cfg(not(target_arch = "wasm32"))]
mod open {
use super::*;
use crate::error::Error;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::path::PathBuf;
use std::time::Duration;
pub fn file_pool(path: impl Into<PathBuf>, max_size: u32, min_idle: u32) -> Result<SqlitePool> {
let path: PathBuf = path.into();
// Create parent directories if needed
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let manager = SqliteConnectionManager::file(path).with_init(|c| init_file_connection(c));
Pool::builder()
.max_size(max_size)
.min_idle(Some(min_idle))
.connection_timeout(Duration::from_secs(10))
.build(manager)
.map_err(|e| Error::Database(e.to_string()))
}
pub fn memory_pool() -> Result<SqlitePool> {
let manager = SqliteConnectionManager::memory().with_init(|c| init_connection(c));
// In-memory DB doesn't support multiple connections
Pool::builder().max_size(1).build(manager).map_err(|e| Error::Database(e.to_string()))
}
}
#[cfg(target_arch = "wasm32")]
mod open {
use super::*;
use rusqlite::Connection;
use std::path::PathBuf;
pub fn file_pool(
path: impl Into<PathBuf>,
_max_size: u32,
_min_idle: u32,
) -> Result<SqlitePool> {
// No WAL: the browser VFSs are single-connection and journal their own
// way; the pragma is accepted and ignored on some and rejected on
// others, so it is not applied at all here.
let conn = Connection::open(path.into())?;
init_connection(&conn)?;
Ok(SqlitePool::single(conn))
}
pub fn memory_pool() -> Result<SqlitePool> {
let conn = Connection::open_in_memory()?;
init_connection(&conn)?;
Ok(SqlitePool::single(conn))
}
}
/// Initialize the database managers for standalone (non-Tauri) usage.
@@ -46,40 +106,16 @@ pub fn init_standalone(
let db_path = db_path.as_ref();
let blob_path = blob_path.as_ref();
// Create parent directories if needed
if let Some(parent) = db_path.parent() {
create_dir_all(parent)?;
}
if let Some(parent) = blob_path.parent() {
create_dir_all(parent)?;
}
// Main database pool. Sized for concurrent in-flight queries, not concurrent app
// features — connections are held per-statement, so even heavy fan-out (e.g. many
// gRPC streams) only needs a handful at once. Keep max_size modest: WAL connections
// hold ~3 file descriptors each, and macOS GUI apps get a 256 fd soft limit.
info!("Initializing app database {db_path:?}");
let manager = sqlite_file_manager(db_path);
let pool = Pool::builder()
.max_size(20)
.min_idle(Some(2))
.connection_timeout(Duration::from_secs(10))
.build(manager)
.map_err(|e| Error::Database(e.to_string()))?;
let pool = open::file_pool(db_path, 20, 2)?;
migrate_db(&pool)?;
info!("Initializing blobs database {blob_path:?}");
// Blob database pool
let blob_manager = sqlite_file_manager(blob_path);
let blob_pool = Pool::builder()
.max_size(10)
.min_idle(Some(1))
.connection_timeout(Duration::from_secs(10))
.build(blob_manager)
.map_err(|e| Error::Database(e.to_string()))?;
let blob_pool = open::file_pool(blob_path, 10, 1)?;
migrate_blob_db(&blob_pool)?;
let (tx, rx) = mpsc::channel();
@@ -92,22 +128,10 @@ pub fn init_standalone(
/// Initialize the database managers with in-memory SQLite databases.
/// Useful for testing and CI environments.
pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
// Main database pool
let manager = sqlite_memory_manager();
let pool = Pool::builder()
.max_size(1) // In-memory DB doesn't support multiple connections
.build(manager)
.map_err(|e| Error::Database(e.to_string()))?;
let pool = open::memory_pool()?;
migrate_db(&pool)?;
// Blob database pool
let blob_manager = sqlite_memory_manager();
let blob_pool = Pool::builder()
.max_size(1)
.build(blob_manager)
.map_err(|e| Error::Database(e.to_string()))?;
let blob_pool = open::memory_pool()?;
migrate_blob_db(&blob_pool)?;
let (tx, rx) = mpsc::channel();
+24 -8
View File
@@ -2,14 +2,13 @@ use crate::error::Error::MigrationError;
use crate::error::Result;
use include_dir::{Dir, DirEntry, include_dir};
use log::{debug, info};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, TransactionBehavior, params};
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
use sha2::{Digest, Sha384};
use yaak_database::SqlitePool;
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
pub fn migrate_db(pool: &SqlitePool) -> Result<()> {
info!("Running database migrations");
// Ensure the table exists
@@ -43,8 +42,10 @@ pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
let mut ran_migrations = 0;
for entry in entries {
num_migrations += 1;
let mut conn = pool.get()?;
let mut tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let conn = pool.get()?;
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
// the pool never hands out `&mut`.
let mut tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
match run_migration(entry, &mut tx) {
Ok(ran) => {
if ran {
@@ -74,7 +75,7 @@ pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
}
fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> Result<bool> {
let start = std::time::Instant::now();
let start = elapsed_timer();
let (version, description) = split_migration_filename(migration_path.path().to_str().unwrap())
.expect("Failed to parse migration filename");
@@ -97,7 +98,7 @@ fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> R
// Split on `;`? → optional depending on how your SQL is structured
tx.execute_batch(&sql)?;
let execution_time = start.elapsed().as_nanos() as i64;
let execution_time = start();
let checksum = sha384_hex_prefixed(sql.as_bytes());
// NOTE: The success column is never used. It's just there for sqlx compatibility.
@@ -109,6 +110,21 @@ fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> R
Ok(true)
}
/// Nanoseconds since the timer was started, for the sqlx-compatible
/// `execution_time` column. `Instant` does not exist on `wasm32-unknown-unknown`
/// (there is no monotonic clock to ask), and the column is bookkeeping, so
/// there it reads as zero rather than taking the migrator down with it.
#[cfg(not(target_arch = "wasm32"))]
fn elapsed_timer() -> impl Fn() -> i64 {
let start = std::time::Instant::now();
move || start.elapsed().as_nanos() as i64
}
#[cfg(target_arch = "wasm32")]
fn elapsed_timer() -> impl Fn() -> i64 {
|| 0
}
fn split_migration_filename(filename: &str) -> Option<(String, String)> {
// Remove the .sql extension
let trimmed = filename.strip_suffix(".sql")?;
+7
View File
@@ -1677,6 +1677,13 @@ pub struct HttpResponse {
pub workspace_id: String,
pub request_id: String,
/// Where the engine put the body, when it puts it in a file.
///
/// Not exported to TypeScript: a path is only meaningful to a host that
/// has the filesystem it names, and bodies are moving off it. Read a body
/// by response id instead — the frontend through
/// `cmd_http_response_body_path`, plugins through `ctx.httpResponse.body`.
#[ts(skip)]
pub body_path: Option<String>,
pub content_length: Option<i32>,
pub content_length_compressed: Option<i32>,
@@ -7,12 +7,12 @@
//! `UpdateSource` identifying who is writing; nothing here knows whether the
//! caller is a desktop window or an HTTP request.
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::error::Error::GenericError;
use yaak_models::error::Result;
use yaak_models::models::AnyModel;
use yaak_models::util::UpdateSource;
use crate::blob_manager::BlobManager;
use crate::client_db::ClientDb;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::models::AnyModel;
use crate::util::UpdateSource;
pub fn upsert_model(
db: &ClientDb,
@@ -3,6 +3,7 @@ use crate::error::Result;
use crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden};
use crate::util::UpdateSource;
use chrono::{Duration, Utc};
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
@@ -4,6 +4,7 @@ use crate::models::{GrpcConnection, GrpcConnectionIden, GrpcConnectionState};
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::debug;
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
@@ -5,6 +5,7 @@ use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::{debug, error};
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
use std::fs;
@@ -4,6 +4,7 @@ use crate::models::{KeyValue, KeyValueIden, UpsertModelInfo};
use crate::util::UpdateSource;
use chrono::NaiveDateTime;
use log::error;
use sea_query::ExprTrait;
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
@@ -1,6 +1,7 @@
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{PluginKeyValue, PluginKeyValueIden};
use sea_query::ExprTrait;
use sea_query::Keyword::CurrentTimestamp;
use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
@@ -2,6 +2,7 @@ use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{SyncState, SyncStateIden, UpsertModelInfo};
use crate::util::UpdateSource;
use sea_query::ExprTrait;
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
use std::path::Path;
@@ -4,6 +4,7 @@ use crate::models::{WebsocketConnection, WebsocketConnectionIden, WebsocketConne
use crate::queries::MAX_HISTORY_ITEMS;
use crate::util::UpdateSource;
use log::debug;
use sea_query::ExprTrait;
use sea_query::{Expr, Query, SqliteQueryBuilder};
use sea_query_rusqlite::RusqliteBinder;
+8 -9
View File
@@ -1,23 +1,21 @@
use crate::client_db::ClientDb;
use crate::error::Error::GenericError;
use crate::util::ModelPayload;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::TransactionBehavior;
use rusqlite::{Transaction, TransactionBehavior};
use std::sync::mpsc;
use yaak_database::{ConnectionOrTx, DbContext};
use yaak_database::{ConnectionOrTx, DbContext, SqlitePool};
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
// blocking `get()` serializes every DB access behind the slowest waiter, freezing the
// whole app whenever the pool is exhausted.
#[derive(Debug, Clone)]
pub struct QueryManager {
pool: Pool<SqliteConnectionManager>,
pool: SqlitePool,
events_tx: mpsc::Sender<ModelPayload>,
}
impl QueryManager {
pub fn new(pool: Pool<SqliteConnectionManager>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
pub fn new(pool: SqlitePool, events_tx: mpsc::Sender<ModelPayload>) -> Self {
QueryManager { pool, events_tx }
}
@@ -46,9 +44,10 @@ impl QueryManager {
where
E: From<crate::error::Error>,
{
let mut conn = self.pool.get().expect("Failed to get new DB connection from the pool");
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
// the pool never hands out `&mut`.
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
.expect("Failed to start DB transaction");
let ctx = DbContext::new(ConnectionOrTx::Transaction(&tx));
+80
View File
@@ -85,6 +85,86 @@ pub struct BatchUpsertResult {
pub websocket_requests: Vec<WebsocketRequest>,
}
/// Where a staged import will be committed.
///
/// The current workspace and optional folder IDs are captured in the plan so the preview describes
/// the exact destination that confirmation will use.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportDestination {
NewWorkspace,
CurrentWorkspace {
#[serde(rename = "workspaceId")]
workspace_id: String,
#[serde(rename = "folderId")]
#[ts(optional)]
folder_id: Option<String>,
},
}
/// A model staged for import.
///
/// `source_key` is intentionally part of the plan boundary even though the first import slice does
/// not persist it. Future linked imports can populate it without changing how plans contain models.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct PlannedImportResource<T> {
#[ts(optional)]
pub source_key: Option<String>,
pub resource: T,
}
impl<T> PlannedImportResource<T> {
pub fn new(resource: T) -> Self {
Self { source_key: None, resource }
}
}
#[derive(Default, Debug, Clone, Deserialize, Serialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanResources {
pub workspaces: Vec<PlannedImportResource<Workspace>>,
pub environments: Vec<PlannedImportResource<Environment>>,
pub folders: Vec<PlannedImportResource<Folder>>,
pub http_requests: Vec<PlannedImportResource<HttpRequest>>,
pub grpc_requests: Vec<PlannedImportResource<GrpcRequest>>,
pub websocket_requests: Vec<PlannedImportResource<WebsocketRequest>>,
}
impl ImportPlanResources {
pub fn into_batch(self) -> BatchUpsertResult {
BatchUpsertResult {
workspaces: self.workspaces.into_iter().map(|v| v.resource).collect(),
environments: self.environments.into_iter().map(|v| v.resource).collect(),
folders: self.folders.into_iter().map(|v| v.resource).collect(),
http_requests: self.http_requests.into_iter().map(|v| v.resource).collect(),
grpc_requests: self.grpc_requests.into_iter().map(|v| v.resource).collect(),
websocket_requests: self.websocket_requests.into_iter().map(|v| v.resource).collect(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanWarning {
pub title: String,
pub detail: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlan {
pub importer: String,
pub destination: ImportDestination,
pub resources: ImportPlanResources,
pub warnings: Vec<ImportPlanWarning>,
}
pub fn get_workspace_export_resources(
db: &ClientDb,
yaak_version: &str,
File diff suppressed because one or more lines are too long
-1
View File
@@ -224,7 +224,6 @@ export type HttpResponse = {
updatedAt: string;
workspaceId: string;
requestId: string;
bodyPath: string | null;
contentLength: number | null;
contentLengthCompressed: number | null;
elapsed: number;
+78
View File
@@ -171,6 +171,12 @@ pub enum InternalEventPayload {
FindHttpResponsesRequest(FindHttpResponsesRequest),
FindHttpResponsesResponse(FindHttpResponsesResponse),
GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest),
GetHttpResponseBodyInfoResponse(GetHttpResponseBodyInfoResponse),
ReadHttpResponseBodyChunkRequest(ReadHttpResponseBodyChunkRequest),
ReadHttpResponseBodyChunkResponse(ReadHttpResponseBodyChunkResponse),
ListHttpRequestsRequest(ListHttpRequestsRequest),
ListHttpRequestsResponse(ListHttpRequestsResponse),
ListFoldersRequest(ListFoldersRequest),
@@ -241,6 +247,8 @@ pub struct ImportRequest {
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ImportResponse {
/// Display name of the importer that recognized the input.
pub importer: String,
pub resources: ImportResources,
}
@@ -288,6 +296,15 @@ pub struct SendHttpRequestRequest {
#[ts(export, export_to = "gen_events.ts")]
pub struct SendHttpRequestResponse {
pub http_response: HttpResponse,
/// The body, base64, when the send saved nothing.
///
/// A request with no id behind it produces a response the model store never
/// sees, so it cannot be read back by id later the way a saved one can.
/// This is the only copy of it. `None` means the body was stored and should
/// be read with `read_http_response_body_chunk_request`.
#[ts(optional = nullable)]
pub body: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
@@ -1413,6 +1430,67 @@ pub struct FindHttpResponsesResponse {
pub http_responses: Vec<HttpResponse>,
}
/// Ask what a response's body is, before deciding whether to pull it.
///
/// Bodies are addressed by response id and never by path, so where the host
/// keeps the bytes is its own business.
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct GetHttpResponseBodyInfoRequest {
pub response_id: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct GetHttpResponseBodyInfoResponse {
/// How many bytes are stored right now, which is not necessarily what the
/// `Content-Length` header claimed. Zero when the response has no body.
#[ts(type = "number")]
pub content_length: u64,
/// Whether the response has finished arriving. While it has not, the body
/// keeps growing past `content_length`, and a reader that wants all of it
/// asks again.
pub complete: bool,
/// The response's `Content-Type` header, verbatim, so the reader can pick a
/// charset.
#[ts(optional = nullable)]
pub content_type: Option<String>,
}
/// Pull one window of a response body.
///
/// Reads are idempotent: the bytes live in durable storage, so the same window
/// can be asked for as many times as the plugin likes.
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ReadHttpResponseBodyChunkRequest {
pub response_id: String,
#[ts(type = "number")]
pub offset: u64,
#[ts(type = "number")]
pub length: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ReadHttpResponseBodyChunkResponse {
/// Base64, because the desktop transport is a WebSocket that only sends
/// text frames today. A host that can carry binary sends the bytes as they
/// are and fills this in from them.
pub data: String,
/// Bytes decoded from `data`. Short of the requested length means the body
/// ended here.
#[ts(type = "number")]
pub length: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
+13 -2
View File
@@ -1104,8 +1104,19 @@ impl PluginManager {
.await?;
// TODO: Don't just return the first valid response
let result = reply_events.into_iter().find_map(|e| match e.payload {
InternalEventPayload::ImportResponse(resp) => Some(resp),
let result = reply_events.into_iter().find_map(|e| match e {
InternalEvent {
plugin_name,
payload: InternalEventPayload::ImportResponse(mut resp),
..
} => {
// Older plugin runtimes do not include the importer's display name. The plugin
// package name is still enough to identify the detected format in that case.
if resp.importer.is_empty() {
resp.importer = plugin_name;
}
Some(resp)
}
_ => None,
});
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "yaak-web"
version = "0.1.0"
edition = "2024"
publish = false
# The desktop's model layer, compiled for a browser tab. See src/lib.rs.
#
# Building needs a clang with a WebAssembly backend: sqlite-wasm-rs compiles
# sqlite3.c to wasm at build time, and Apple's clang cannot target it. See
# build-wasm.cjs, which points cc at Homebrew LLVM when it is present.
[package.metadata.wasm-pack.profile.release]
wasm-opt = false # Matches yaak-templates; wasm-opt has caused errors in CI
[lib]
crate-type = ["cdylib", "rlib"]
# The whole crate is `#![cfg(target_arch = "wasm32")]`: on a native target it
# is empty, so a workspace-wide `cargo test` neither builds SQLite's wasm shim
# for the host (which fails) nor links a browser-only runtime. Everything that
# only exists for wasm is a target-scoped dependency for the same reason.
[dependencies]
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
yaak-models = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1"
js-sys = "0.3"
serde-wasm-bindgen = "0.6.5"
sqlite-wasm-rs = "0.5"
sqlite-wasm-vfs = "0.2"
wasm-bindgen = "0.2.100"
wasm-bindgen-futures = "0.4"
+79
View File
@@ -0,0 +1,79 @@
const { execSync, spawnSync } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// Same shape as crates/yaak-templates/build-wasm.cjs, plus one wrinkle: this
// crate links SQLite, and sqlite-wasm-rs compiles sqlite3.c to wasm at build
// time. That needs a C compiler with a WebAssembly backend, which Apple's
// clang is not. So the build looks for one, and when it finds none it keeps
// the committed pkg/ and says so — desktop developers never need this crate
// rebuilt, and failing their `npm run bootstrap` over it would be wrong.
if (process.env.SKIP_WASM_BUILD === "1") {
console.log("Skipping wasm-pack build (SKIP_WASM_BUILD=1)");
return;
}
/** A clang that can emit wasm32, or null. */
function findWasmClang() {
const candidates = [
process.env.CC_wasm32_unknown_unknown,
"/opt/homebrew/opt/llvm/bin/clang", // Homebrew LLVM, Apple Silicon
"/usr/local/opt/llvm/bin/clang", // Homebrew LLVM, Intel
"clang", // Linux distros' clang usually has the backend built in
].filter(Boolean);
for (const clang of candidates) {
const probe = spawnSync(clang, ["--print-targets"], { encoding: "utf8" });
if (probe.status === 0 && /\bwasm32\b/.test(probe.stdout)) return clang;
}
return null;
}
const clang = findWasmClang();
if (clang == null) {
console.log(
[
"yaak-web: no C compiler with a WebAssembly backend found; keeping the committed pkg/.",
" To rebuild: install LLVM (macOS: `brew install llvm`) or point CC_wasm32_unknown_unknown at one.",
].join("\n"),
);
return;
}
// llvm-ar lives next to clang in every LLVM distribution
const ar = path.join(path.dirname(clang), "llvm-ar");
// Remap machine-specific paths that rustc embeds into the binary (panic
// location strings), so builds are reproducible across machines
const sysroot = execSync("rustc --print sysroot").toString().trim();
const cargoHome = process.env.CARGO_HOME ?? path.join(os.homedir(), ".cargo");
execSync("wasm-pack build --target bundler", {
stdio: "inherit",
cwd: __dirname,
env: {
...process.env,
CC_wasm32_unknown_unknown: clang,
AR_wasm32_unknown_unknown: fs.existsSync(ar) ? ar : (process.env.AR_wasm32_unknown_unknown ?? ""),
RUSTFLAGS: `--remap-path-prefix=${cargoHome}=/cargo --remap-path-prefix=${sysroot}=/rustc`,
},
});
// Rewrite the generated entry to use Vite's ?init import style instead of
// the ES Module Integration style that wasm-pack generates, which Vite/rolldown
// does not support in production builds.
const entry = path.join(__dirname, "pkg", "yaak_web.js");
fs.writeFileSync(
entry,
[
'import init from "./yaak_web_bg.wasm?init";',
'export * from "./yaak_web_bg.js";',
'import * as bg from "./yaak_web_bg.js";',
'const instance = await init({ "./yaak_web_bg.js": bg });',
"bg.__wbg_set_wasm(instance.exports);",
"instance.exports.__wbindgen_start();",
"",
].join("\n"),
);
+6
View File
@@ -0,0 +1,6 @@
// The desktop's model layer, compiled to wasm for the browser. See src/lib.rs.
//
// This is loaded by the SharedWorker in packages/platform/src/web/worker.ts and
// nowhere else: it owns a SQLite database, and there must be exactly one of it
// per origin.
export { blob_delete, blob_get, blob_put, boot, rpc } from "./pkg";
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@yaakapp-internal/web",
"version": "1.0.0",
"private": true,
"main": "index.ts",
"scripts": {
"bootstrap": "npm run build",
"build": "run-s build:*",
"build:pack": "node build-wasm.cjs",
"build:clean": "rimraf ./pkg/.gitignore"
},
"devDependencies": {
"rimraf": "^6.1.2"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "yaak-web",
"type": "module",
"version": "0.1.0",
"files": [
"yaak_web_bg.wasm",
"yaak_web.js",
"yaak_web_bg.js",
"yaak_web.d.ts"
],
"main": "yaak_web.js",
"types": "yaak_web.d.ts",
"sideEffects": [
"./yaak_web.js",
"./snippets/*"
]
}
+51
View File
@@ -0,0 +1,51 @@
/* tslint:disable */
/* eslint-disable */
export function blob_delete(id: string): void;
/**
* The bytes stored under an id, or none. Ids are the desktop's: a response's
* own id for its body, `{responseId}.request` for the request that produced
* it. Bytes cross to JS as a `Uint8Array` rather than through JSON.
*/
export function blob_get(id: string): Uint8Array | undefined;
/**
* Store bytes under an id, replacing anything already there. Chunked the way
* the desktop chunks, so a body written here reads back on a desktop that
* imports the database, and vice versa.
*/
export function blob_put(id: string, bytes: Uint8Array): void;
/**
* Register the IndexedDB-backed VFS and open the database.
*
* Migrations run inside `init_standalone`, exactly as they do for the CLI.
* Safe to call more than once; later calls are no-ops.
*/
export function boot(): Promise<void>;
/**
* Run one command as `label` (the calling tab's identity, which stands in for
* the desktop's window label on every write it makes).
*
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are
* declared locally and dispatched by name, which is the one place this host
* does not share the desktop's guarantees: the desktop builds its router from
* the schema, so every command has a handler by construction. Here a renamed
* command would surface as a runtime "not a command this host answers".
*
* The fix is `yaak-commands` (the `Host` trait), not more machinery here
* its `models::*` handlers are already this file, typed. Three things have to
* give before a wasm host can register them:
*
* 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread
* and the connection pool is an `Rc`.
* 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn
* onto here.
* 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP
* stack and the Node sidecar and do not build for wasm32.
*
* None of those are hard; they are just not this PR.
*/
export function rpc(cmd: string, payload: any, label: string): any;
+6
View File
@@ -0,0 +1,6 @@
import init from "./yaak_web_bg.wasm?init";
export * from "./yaak_web_bg.js";
import * as bg from "./yaak_web_bg.js";
const instance = await init({ "./yaak_web_bg.js": bg });
bg.__wbg_set_wasm(instance.exports);
instance.exports.__wbindgen_start();
+990
View File
@@ -0,0 +1,990 @@
/**
* @param {string} id
*/
export function blob_delete(id) {
const ptr0 = passStringToWasm0(id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.blob_delete(ptr0, len0);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
/**
* The bytes stored under an id, or none. Ids are the desktop's: a response's
* own id for its body, `{responseId}.request` for the request that produced
* it. Bytes cross to JS as a `Uint8Array` rather than through JSON.
* @param {string} id
* @returns {Uint8Array | undefined}
*/
export function blob_get(id) {
const ptr0 = passStringToWasm0(id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.blob_get(ptr0, len0);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
let v2;
if (ret[0] !== 0) {
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
}
return v2;
}
/**
* Store bytes under an id, replacing anything already there. Chunked the way
* the desktop chunks, so a body written here reads back on a desktop that
* imports the database, and vice versa.
* @param {string} id
* @param {Uint8Array} bytes
*/
export function blob_put(id, bytes) {
const ptr0 = passStringToWasm0(id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.blob_put(ptr0, len0, ptr1, len1);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
/**
* Register the IndexedDB-backed VFS and open the database.
*
* Migrations run inside `init_standalone`, exactly as they do for the CLI.
* Safe to call more than once; later calls are no-ops.
* @returns {Promise<void>}
*/
export function boot() {
const ret = wasm.boot();
return ret;
}
/**
* Run one command as `label` (the calling tab's identity, which stands in for
* the desktop's window label on every write it makes).
*
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are
* declared locally and dispatched by name, which is the one place this host
* does not share the desktop's guarantees: the desktop builds its router from
* the schema, so every command has a handler by construction. Here a renamed
* command would surface as a runtime "not a command this host answers".
*
* The fix is `yaak-commands` (the `Host` trait), not more machinery here
* its `models::*` handlers are already this file, typed. Three things have to
* give before a wasm host can register them:
*
* 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread
* and the connection pool is an `Rc`.
* 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn
* onto here.
* 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP
* stack and the Node sidecar and do not build for wasm32.
*
* None of those are hard; they are just not this PR.
* @param {string} cmd
* @param {any} payload
* @param {string} label
* @returns {any}
*/
export function rpc(cmd, payload, label) {
const ptr0 = passStringToWasm0(cmd, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(label, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.rpc(ptr0, len0, payload, ptr1, len1);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
export function __wbg_Error_bce6d499ff0a4aff(arg0, arg1) {
const ret = Error(getStringFromWasm0(arg0, arg1));
return ret;
}
export function __wbg_String_8564e559799eccda(arg0, arg1) {
const ret = String(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_Window_70131fc0c91e4b3c(arg0) {
const ret = arg0.Window;
return ret;
}
export function __wbg_WorkerGlobalScope_601c48015b8cc78e(arg0) {
const ret = arg0.WorkerGlobalScope;
return ret;
}
export function __wbg___wbindgen_bigint_get_as_i64_410e28c7b761ad83(arg0, arg1) {
const v = arg1;
const ret = typeof(v) === 'bigint' ? v : undefined;
getDataViewMemory0().setBigInt64(arg0 + 8 * 1, isLikeNone(ret) ? BigInt(0) : ret, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
}
export function __wbg___wbindgen_boolean_get_2304fb8c853028c8(arg0) {
const v = arg0;
const ret = typeof(v) === 'boolean' ? v : undefined;
return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0;
}
export function __wbg___wbindgen_debug_string_edece8177ad01481(arg0, arg1) {
const ret = debugString(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg___wbindgen_in_07056af4f902c445(arg0, arg1) {
const ret = arg0 in arg1;
return ret;
}
export function __wbg___wbindgen_is_bigint_aeae3893f30ed54e(arg0) {
const ret = typeof(arg0) === 'bigint';
return ret;
}
export function __wbg___wbindgen_is_function_5cd60d5cf78b4eef(arg0) {
const ret = typeof(arg0) === 'function';
return ret;
}
export function __wbg___wbindgen_is_null_2042690d351e14f0(arg0) {
const ret = arg0 === null;
return ret;
}
export function __wbg___wbindgen_is_object_b4593df85baada48(arg0) {
const val = arg0;
const ret = typeof(val) === 'object' && val !== null;
return ret;
}
export function __wbg___wbindgen_is_string_dde0fd9020db4434(arg0) {
const ret = typeof(arg0) === 'string';
return ret;
}
export function __wbg___wbindgen_is_undefined_35bb9f4c7fd651d5(arg0) {
const ret = arg0 === undefined;
return ret;
}
export function __wbg___wbindgen_jsval_eq_c0ed08b3e0f393b9(arg0, arg1) {
const ret = arg0 === arg1;
return ret;
}
export function __wbg___wbindgen_jsval_loose_eq_0ad77b7717db155c(arg0, arg1) {
const ret = arg0 == arg1;
return ret;
}
export function __wbg___wbindgen_number_get_f73a1244370fcc2c(arg0, arg1) {
const obj = arg1;
const ret = typeof(obj) === 'number' ? obj : undefined;
getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
}
export function __wbg___wbindgen_string_get_d109740c0d18f4d7(arg0, arg1) {
const obj = arg1;
const ret = typeof(obj) === 'string' ? obj : undefined;
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg___wbindgen_throw_9c31b086c2b26051(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
}
export function __wbg__wbg_cb_unref_3fa391f3fcdb55f8(arg0) {
arg0._wbg_cb_unref();
}
export function __wbg_abort_70a701fced9ad53a() { return handleError(function (arg0) {
arg0.abort();
}, arguments); }
export function __wbg_bound_8d5dfa042d13a74b() { return handleError(function (arg0, arg1, arg2, arg3) {
const ret = IDBKeyRange.bound(arg0, arg1, arg2 !== 0, arg3 !== 0);
return ret;
}, arguments); }
export function __wbg_call_13665d9f14390edc() { return handleError(function (arg0, arg1) {
const ret = arg0.call(arg1);
return ret;
}, arguments); }
export function __wbg_call_dfde26266607c996() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.call(arg1, arg2);
return ret;
}, arguments); }
export function __wbg_clear_bb1b3ff877b62598() { return handleError(function (arg0) {
const ret = arg0.clear();
return ret;
}, arguments); }
export function __wbg_commit_e9c1332714c53826() { return handleError(function (arg0) {
arg0.commit();
}, arguments); }
export function __wbg_createObjectStore_7aa4cf3fcb65c75a() { return handleError(function (arg0, arg1, arg2, arg3) {
const ret = arg0.createObjectStore(getStringFromWasm0(arg1, arg2), arg3);
return ret;
}, arguments); }
export function __wbg_crypto_48300657fced39f9(arg0) {
const ret = arg0.crypto;
return ret;
}
export function __wbg_delete_bc03f88e7f14db56() { return handleError(function (arg0, arg1) {
const ret = arg0.delete(arg1);
return ret;
}, arguments); }
export function __wbg_done_54b8da57023b7ed2(arg0) {
const ret = arg0.done;
return ret;
}
export function __wbg_entries_564a7e8b1e54ede5(arg0) {
const ret = Object.entries(arg0);
return ret;
}
export function __wbg_error_a6fa202b58aa1cd3(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.error(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
}
export function __wbg_error_ef9cbaece146d1d5() { return handleError(function (arg0) {
const ret = arg0.error;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}, arguments); }
export function __wbg_getAll_a0a54eef6ac20915() { return handleError(function (arg0, arg1) {
const ret = arg0.getAll(arg1);
return ret;
}, arguments); }
export function __wbg_getAll_bc4f4ec6a1504163() { return handleError(function (arg0) {
const ret = arg0.getAll();
return ret;
}, arguments); }
export function __wbg_getDate_a52123c8affc9072(arg0) {
const ret = arg0.getDate();
return ret;
}
export function __wbg_getDay_50a9ee1e4d17dc24(arg0) {
const ret = arg0.getDay();
return ret;
}
export function __wbg_getFullYear_d5d1f7de344fdc5b(arg0) {
const ret = arg0.getFullYear();
return ret;
}
export function __wbg_getHours_c974d920209733e8(arg0) {
const ret = arg0.getHours();
return ret;
}
export function __wbg_getMinutes_e2e8ae846b37b328(arg0) {
const ret = arg0.getMinutes();
return ret;
}
export function __wbg_getMonth_de70091920053153(arg0) {
const ret = arg0.getMonth();
return ret;
}
export function __wbg_getRandomValues_15134f5c0ae6b0d0() { return handleError(function (arg0, arg1) {
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
}, arguments); }
export function __wbg_getRandomValues_263d0aa5464054ee() { return handleError(function (arg0, arg1) {
arg0.getRandomValues(arg1);
}, arguments); }
export function __wbg_getSeconds_2782a558f414ec05(arg0) {
const ret = arg0.getSeconds();
return ret;
}
export function __wbg_getTime_09f1dd40a44edb30(arg0) {
const ret = arg0.getTime();
return ret;
}
export function __wbg_getTimezoneOffset_96cfb6ddebc9e5ca(arg0) {
const ret = arg0.getTimezoneOffset();
return ret;
}
export function __wbg_get_3e9a707ab7d352eb() { return handleError(function (arg0, arg1) {
const ret = Reflect.get(arg0, arg1);
return ret;
}, arguments); }
export function __wbg_get_98fdf51d029a75eb(arg0, arg1) {
const ret = arg0[arg1 >>> 0];
return ret;
}
export function __wbg_get_dcf82ab8aad1a593() { return handleError(function (arg0, arg1) {
const ret = Reflect.get(arg0, arg1);
return ret;
}, arguments); }
export function __wbg_get_unchecked_1dfe6d05ad91d9b7(arg0, arg1) {
const ret = arg0[arg1 >>> 0];
return ret;
}
export function __wbg_get_with_ref_key_6412cf3094599694(arg0, arg1) {
const ret = arg0[arg1];
return ret;
}
export function __wbg_global_e30ac0b7684506d0(arg0) {
const ret = arg0.global;
return ret;
}
export function __wbg_indexedDB_2e82cb845ce6b3ad() { return handleError(function (arg0) {
const ret = arg0.indexedDB;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}, arguments); }
export function __wbg_indexedDB_a2139150e2ea2a08() { return handleError(function (arg0) {
const ret = arg0.indexedDB;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}, arguments); }
export function __wbg_indexedDB_cbfeacc981615a77() { return handleError(function (arg0) {
const ret = arg0.indexedDB;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}, arguments); }
export function __wbg_instanceof_ArrayBuffer_53db37b06f6b9afe(arg0) {
let result;
try {
result = arg0 instanceof ArrayBuffer;
} catch (_) {
result = false;
}
const ret = result;
return ret;
}
export function __wbg_instanceof_DomException_bc16ce893e8c7439(arg0) {
let result;
try {
result = arg0 instanceof DOMException;
} catch (_) {
result = false;
}
const ret = result;
return ret;
}
export function __wbg_instanceof_Error_b3f7e146d654031a(arg0) {
let result;
try {
result = arg0 instanceof Error;
} catch (_) {
result = false;
}
const ret = result;
return ret;
}
export function __wbg_instanceof_IdbDatabase_102b0fe5255eee9c(arg0) {
let result;
try {
result = arg0 instanceof IDBDatabase;
} catch (_) {
result = false;
}
const ret = result;
return ret;
}
export function __wbg_instanceof_IdbRequest_eef501cff5d0b7c1(arg0) {
let result;
try {
result = arg0 instanceof IDBRequest;
} catch (_) {
result = false;
}
const ret = result;
return ret;
}
export function __wbg_instanceof_Map_16f217b9a2a08d8c(arg0) {
let result;
try {
result = arg0 instanceof Map;
} catch (_) {
result = false;
}
const ret = result;
return ret;
}
export function __wbg_instanceof_Uint8Array_abd07d4bd221d50b(arg0) {
let result;
try {
result = arg0 instanceof Uint8Array;
} catch (_) {
result = false;
}
const ret = result;
return ret;
}
export function __wbg_isArray_94898ed3aad6947b(arg0) {
const ret = Array.isArray(arg0);
return ret;
}
export function __wbg_isSafeInteger_01e964d144ad3a55(arg0) {
const ret = Number.isSafeInteger(arg0);
return ret;
}
export function __wbg_iterator_1441b47f341dc34f() {
const ret = Symbol.iterator;
return ret;
}
export function __wbg_length_2591a0f4f659a55c(arg0) {
const ret = arg0.length;
return ret;
}
export function __wbg_length_56fcd3e2b7e0299d(arg0) {
const ret = arg0.length;
return ret;
}
export function __wbg_lowerBound_a64226f683db77bb() { return handleError(function (arg0, arg1) {
const ret = IDBKeyRange.lowerBound(arg0, arg1 !== 0);
return ret;
}, arguments); }
export function __wbg_message_324ac511aeaf710e(arg0) {
const ret = arg0.message;
return ret;
}
export function __wbg_message_e88a8d3ba2b91c2a(arg0, arg1) {
const ret = arg1.message;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_msCrypto_8c6d45a75ef1d3da(arg0) {
const ret = arg0.msCrypto;
return ret;
}
export function __wbg_name_fe88cfc178ec40b8(arg0, arg1) {
const ret = arg1.name;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_new_02d162bc6cf02f60() {
const ret = new Object();
return ret;
}
export function __wbg_new_070df68d66325372() {
const ret = new Map();
return ret;
}
export function __wbg_new_0_2722fcdb71a888a6() {
const ret = new Date();
return ret;
}
export function __wbg_new_1f236d63ba0c4784(arg0, arg1) {
const ret = new Error(getStringFromWasm0(arg0, arg1));
return ret;
}
export function __wbg_new_227d7c05414eb861() {
const ret = new Error();
return ret;
}
export function __wbg_new_310879b66b6e95e1() {
const ret = new Array();
return ret;
}
export function __wbg_new_7ddec6de44ff8f5d(arg0) {
const ret = new Uint8Array(arg0);
return ret;
}
export function __wbg_new_859b9002e2668e82(arg0) {
const ret = new Date(arg0);
return ret;
}
export function __wbg_new_from_slice_269e35316ed2d061(arg0, arg1) {
const ret = new Uint8Array(getArrayU8FromWasm0(arg0, arg1));
return ret;
}
export function __wbg_new_typed_c072c4ce9a2a0cdf(arg0, arg1) {
try {
var state0 = {a: arg0, b: arg1};
var cb0 = (arg0, arg1) => {
const a = state0.a;
state0.a = 0;
try {
return wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(a, state0.b, arg0, arg1);
} finally {
state0.a = a;
}
};
const ret = new Promise(cb0);
return ret;
} finally {
state0.a = 0;
}
}
export function __wbg_new_with_length_99887c91eae4abab(arg0) {
const ret = new Uint8Array(arg0 >>> 0);
return ret;
}
export function __wbg_new_with_year_month_day_0ccdc1cc3a42b726(arg0, arg1, arg2) {
const ret = new Date(arg0 >>> 0, arg1, arg2);
return ret;
}
export function __wbg_next_2a4e19f4f5083b0f(arg0) {
const ret = arg0.next;
return ret;
}
export function __wbg_next_6429a146bf756f93() { return handleError(function (arg0) {
const ret = arg0.next();
return ret;
}, arguments); }
export function __wbg_node_95beb7570492fd97(arg0) {
const ret = arg0.node;
return ret;
}
export function __wbg_objectStore_b28adb984a77902e() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.objectStore(getStringFromWasm0(arg1, arg2));
return ret;
}, arguments); }
export function __wbg_open_40ab11cdd8f5ac5a() { return handleError(function (arg0, arg1, arg2, arg3) {
const ret = arg0.open(getStringFromWasm0(arg1, arg2), arg3 >>> 0);
return ret;
}, arguments); }
export function __wbg_process_b2fea42461d03994(arg0) {
const ret = arg0.process;
return ret;
}
export function __wbg_prototypesetcall_5f9bdc8d75e07276(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
}
export function __wbg_push_b77c476b01548d0a(arg0, arg1) {
const ret = arg0.push(arg1);
return ret;
}
export function __wbg_put_848906967513a84d() { return handleError(function (arg0, arg1) {
const ret = arg0.put(arg1);
return ret;
}, arguments); }
export function __wbg_queueMicrotask_78d584b53af520f5(arg0) {
const ret = arg0.queueMicrotask;
return ret;
}
export function __wbg_queueMicrotask_b39ea83c7f01971a(arg0) {
queueMicrotask(arg0);
}
export function __wbg_randomFillSync_ca9f178fb14c88cb() { return handleError(function (arg0, arg1) {
arg0.randomFillSync(arg1);
}, arguments); }
export function __wbg_random_a8dfe52b70cb65a5() {
const ret = Math.random();
return ret;
}
export function __wbg_readyState_b7c530197b76b93b(arg0) {
const ret = arg0.readyState;
return (__wbindgen_enum_IdbRequestReadyState.indexOf(ret) + 1 || 3) - 1;
}
export function __wbg_require_7a9419e39d796c95() { return handleError(function () {
const ret = module.require;
return ret;
}, arguments); }
export function __wbg_resolve_d17db9352f5a220e(arg0) {
const ret = Promise.resolve(arg0);
return ret;
}
export function __wbg_result_c4cb33cd39c97cac() { return handleError(function (arg0) {
const ret = arg0.result;
return ret;
}, arguments); }
export function __wbg_set_24d0fa9e104112f9(arg0, arg1, arg2) {
arg0.set(getArrayU8FromWasm0(arg1, arg2));
}
export function __wbg_set_6be42768c690e380(arg0, arg1, arg2) {
arg0[arg1] = arg2;
}
export function __wbg_set_78ea6a19f4818587(arg0, arg1, arg2) {
arg0[arg1 >>> 0] = arg2;
}
export function __wbg_set_a0e911be3da02782() { return handleError(function (arg0, arg1, arg2) {
const ret = Reflect.set(arg0, arg1, arg2);
return ret;
}, arguments); }
export function __wbg_set_facb7a5914e0fa39(arg0, arg1, arg2) {
const ret = arg0.set(arg1, arg2);
return ret;
}
export function __wbg_set_key_path_8f8e19a098d0851c(arg0, arg1) {
arg0.keyPath = arg1;
}
export function __wbg_set_onabort_ed56d2172d920901(arg0, arg1) {
arg0.onabort = arg1;
}
export function __wbg_set_oncomplete_3f428ec13b20d7cc(arg0, arg1) {
arg0.oncomplete = arg1;
}
export function __wbg_set_onerror_38740b892815eedc(arg0, arg1) {
arg0.onerror = arg1;
}
export function __wbg_set_onerror_457b093a5063c7ec(arg0, arg1) {
arg0.onerror = arg1;
}
export function __wbg_set_onsuccess_b556141053d02ea7(arg0, arg1) {
arg0.onsuccess = arg1;
}
export function __wbg_set_onupgradeneeded_f885fa17614acd2b(arg0, arg1) {
arg0.onupgradeneeded = arg1;
}
export function __wbg_stack_3b0d974bbf31e44f(arg0, arg1) {
const ret = arg1.stack;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg_static_accessor_GLOBAL_THIS_02344c9b09eb08a9() {
const ret = typeof globalThis === 'undefined' ? null : globalThis;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_static_accessor_GLOBAL_ac6d4ac874d5cd54() {
const ret = typeof global === 'undefined' ? null : global;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_static_accessor_SELF_9b2406c23aeb2023() {
const ret = typeof self === 'undefined' ? null : self;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_static_accessor_WINDOW_b34d2126934e16ba() {
const ret = typeof window === 'undefined' ? null : window;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_subarray_7c6a0da8f3b4a1ba(arg0, arg1, arg2) {
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
return ret;
}
export function __wbg_target_84e05e84ffc12989(arg0) {
const ret = arg0.target;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
}
export function __wbg_then_837494e384b37459(arg0, arg1) {
const ret = arg0.then(arg1);
return ret;
}
export function __wbg_toString_1dda136fd8f30a5f(arg0) {
const ret = arg0.toString();
return ret;
}
export function __wbg_transaction_213e4f585d3d1b40(arg0) {
const ret = arg0.transaction;
return ret;
}
export function __wbg_transaction_b7261fed68fa4264() { return handleError(function (arg0, arg1, arg2, arg3) {
const ret = arg0.transaction(getStringFromWasm0(arg1, arg2), __wbindgen_enum_IdbTransactionMode[arg3]);
return ret;
}, arguments); }
export function __wbg_upperBound_f7daa7529e579cfc() { return handleError(function (arg0, arg1) {
const ret = IDBKeyRange.upperBound(arg0, arg1 !== 0);
return ret;
}, arguments); }
export function __wbg_value_9cc0518af87a489c(arg0) {
const ret = arg0.value;
return ret;
}
export function __wbg_versions_215a3ab1c9d5745a(arg0) {
const ret = arg0.versions;
return ret;
}
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1104, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4);
return ret;
}
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb);
return ret;
}
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 180, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9);
return ret;
}
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 200, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc);
return ret;
}
export function __wbindgen_cast_0000000000000005(arg0) {
// Cast intrinsic for `F64 -> Externref`.
const ret = arg0;
return ret;
}
export function __wbindgen_cast_0000000000000006(arg0) {
// Cast intrinsic for `I64 -> Externref`.
const ret = arg0;
return ret;
}
export function __wbindgen_cast_0000000000000007(arg0, arg1) {
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
const ret = getArrayU8FromWasm0(arg0, arg1);
return ret;
}
export function __wbindgen_cast_0000000000000008(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
}
export function __wbindgen_cast_0000000000000009(arg0) {
// Cast intrinsic for `U64 -> Externref`.
const ret = BigInt.asUintN(64, arg0);
return ret;
}
export function __wbindgen_init_externref_table() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
}
function wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3);
}
const __wbindgen_enum_IdbRequestReadyState = ["pending", "done"];
const __wbindgen_enum_IdbTransactionMode = ["readonly", "readwrite", "versionchange", "readwriteflush", "cleanup"];
function addToExternrefTable0(obj) {
const idx = wasm.__externref_table_alloc();
wasm.__wbindgen_externrefs.set(idx, obj);
return idx;
}
const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b));
function debugString(val) {
// primitive types
const type = typeof val;
if (type == 'number' || type == 'boolean' || val == null) {
return `${val}`;
}
if (type == 'string') {
return `"${val}"`;
}
if (type == 'symbol') {
const description = val.description;
if (description == null) {
return 'Symbol';
} else {
return `Symbol(${description})`;
}
}
if (type == 'function') {
const name = val.name;
if (typeof name == 'string' && name.length > 0) {
return `Function(${name})`;
} else {
return 'Function';
}
}
// objects
if (Array.isArray(val)) {
const length = val.length;
let debug = '[';
if (length > 0) {
debug += debugString(val[0]);
}
for(let i = 1; i < length; i++) {
debug += ', ' + debugString(val[i]);
}
debug += ']';
return debug;
}
// Test for built-in
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
let className;
if (builtInMatches && builtInMatches.length > 1) {
className = builtInMatches[1];
} else {
// Failed to match the standard '[object ClassName]'
return toString.call(val);
}
if (className == 'Object') {
// we're a user defined class or Object
// JSON.stringify avoids problems with cycles, and is generally much
// easier than looping through ownProperties of `val`.
try {
return 'Object(' + JSON.stringify(val) + ')';
} catch (_) {
return 'Object';
}
}
// errors
if (val instanceof Error) {
return `${val.name}: ${val.message}\n${val.stack}`;
}
// TODO we could test for more things here, like `Set`s and `Map`s.
return className;
}
function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function handleError(f, args) {
try {
return f.apply(this, args);
} catch (e) {
const idx = addToExternrefTable0(e);
wasm.__wbindgen_exn_store(idx);
}
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function makeMutClosure(arg0, arg1, f) {
const state = { a: arg0, b: arg1, cnt: 1 };
const real = (...args) => {
// First up with a closure we increment the internal reference
// count. This ensures that the Rust closure environment won't
// be deallocated while we're invoking it.
state.cnt++;
const a = state.a;
state.a = 0;
try {
return f(a, state.b, ...args);
} finally {
state.a = a;
real._wbg_cb_unref();
}
};
real._wbg_cb_unref = () => {
if (--state.cnt === 0) {
wasm.__wbindgen_destroy_closure(state.a, state.b);
state.a = 0;
CLOSURE_DTORS.unregister(state);
}
};
CLOSURE_DTORS.register(real, state, state);
return real;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasm;
export function __wbg_set_wasm(val) {
wasm = val;
}
Binary file not shown.
+32
View File
@@ -0,0 +1,32 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const blob_delete: (a: number, b: number) => [number, number];
export const blob_get: (a: number, b: number) => [number, number, number, number];
export const blob_put: (a: number, b: number, c: number, d: number) => [number, number];
export const boot: () => any;
export const rpc: (a: number, b: number, c: any, d: number, e: number) => [number, number, number];
export const rust_sqlite_wasm_abort: () => void;
export const rust_sqlite_wasm_assert_fail: (a: number, b: number, c: number, d: number) => void;
export const rust_sqlite_wasm_calloc: (a: number, b: number) => number;
export const rust_sqlite_wasm_free: (a: number) => void;
export const rust_sqlite_wasm_getentropy: (a: number, b: number) => number;
export const rust_sqlite_wasm_localtime: (a: number) => number;
export const rust_sqlite_wasm_malloc: (a: number) => number;
export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
export const sqlite3_os_end: () => number;
export const sqlite3_os_init: () => number;
export const wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc: (a: number, b: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_exn_store: (a: number) => void;
export const __externref_table_alloc: () => number;
export const __wbindgen_externrefs: WebAssembly.Table;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __wbindgen_destroy_closure: (a: number, b: number) => void;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_start: () => void;
+356
View File
@@ -0,0 +1,356 @@
//! The desktop's model layer, running in a browser.
//!
//! Nothing here is model logic. This crate registers a persistent VFS, opens
//! the database through the same `init_standalone` the CLI uses, and answers
//! the `models_*` commands by calling the same `ClientDb` queries the desktop
//! does. A browser tab therefore stores exactly what a desktop install stores,
//! migrations and all — the only thing that differs is where the SQLite pages
//! live (IndexedDB) and who is calling in (a worker instead of Tauri).
//!
//! It is meant to be loaded once, in one place — a SharedWorker — because two
//! SQLite instances over the same IndexedDB pages would corrupt them. The
//! JavaScript side owns that; this crate assumes it is the only writer.
//!
//! The command surface is deliberately narrow: what the frontend needs to keep
//! its model store coherent, and blob storage. Sending, plugins, git, sync and
//! everything else with a socket or a filesystem behind it lives elsewhere.
// Nothing in here means anything off wasm32, and building it there would drag
// SQLite's wasm C shim into a native compile. So on any other target the crate
// is empty — a workspace-wide `cargo test` passes through it.
#![cfg(target_arch = "wasm32")]
use std::cell::RefCell;
use std::sync::mpsc;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use yaak_models::blob_manager::{BlobManager, BodyChunk};
use yaak_models::models::AnyModel;
use yaak_models::models_ops;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{ModelPayload, UpdateSource};
/// Names inside the VFS, not paths on any disk. Two files because the desktop
/// keeps two: models in one, blobs in the other.
const DB_NAME: &str = "yaak.db";
const BLOB_DB_NAME: &str = "yaak-blobs.db";
const VFS_NAME: &str = "yaak-idb";
struct Host {
queries: QueryManager,
blobs: BlobManager,
events: mpsc::Receiver<ModelPayload>,
}
thread_local! {
static HOST: RefCell<Option<Host>> = const { RefCell::new(None) };
}
/* -------------------------------------------------------------------------- */
/* Errors */
/* -------------------------------------------------------------------------- */
/// What a failed command hands back to JavaScript: a real `Error`, so it
/// throws like one, with `message` set to the model layer's own text.
fn js_error(e: impl std::fmt::Display) -> JsValue {
js_sys::Error::new(&e.to_string()).into()
}
type Result<T> = std::result::Result<T, JsValue>;
/* -------------------------------------------------------------------------- */
/* Boot */
/* -------------------------------------------------------------------------- */
/// Register the IndexedDB-backed VFS and open the database.
///
/// Migrations run inside `init_standalone`, exactly as they do for the CLI.
/// Safe to call more than once; later calls are no-ops.
#[wasm_bindgen]
pub async fn boot() -> Result<()> {
console_error_panic_hook::set_once();
if HOST.with(|h| h.borrow().is_some()) {
return Ok(());
}
// "Relaxed" means writes land in memory first and are flushed to
// IndexedDB shortly after, rather than on every commit. It is the right
// trade for a client app: a tab closing mid-flush loses at most the last
// few writes, and the alternative (OPFS sync access handles) needs a
// dedicated worker per file and is not available everywhere.
let cfg = sqlite_wasm_vfs::relaxed_idb::RelaxedIdbCfgBuilder::new()
.vfs_name(VFS_NAME)
.preload(sqlite_wasm_vfs::relaxed_idb::Preload::All)
.build();
sqlite_wasm_vfs::relaxed_idb::install::<sqlite_wasm_rs::WasmOsCallback>(&cfg, true)
.await
.map_err(js_error)?;
let (queries, blobs, events) =
yaak_models::init_standalone(DB_NAME, BLOB_DB_NAME).map_err(js_error)?;
HOST.with(|h| *h.borrow_mut() = Some(Host { queries, blobs, events }));
Ok(())
}
fn with_host<T>(f: impl FnOnce(&Host) -> Result<T>) -> Result<T> {
HOST.with(|h| {
let h = h.borrow();
let host = h.as_ref().ok_or_else(|| js_error("yaak-web: call boot() before rpc()"))?;
f(host)
})
}
/* -------------------------------------------------------------------------- */
/* Commands */
/* -------------------------------------------------------------------------- */
/// A command's outcome, plus every model write it caused.
///
/// The writes ride along with the result rather than being fetched separately
/// so the caller can announce them atomically with completion — a tab that
/// awaits `models_upsert` must see its echo before or with the response, never
/// after, or the store races the reply.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RpcOutcome {
result: serde_json::Value,
events: Vec<ModelPayload>,
}
/// Run one command as `label` (the calling tab's identity, which stands in for
/// the desktop's window label on every write it makes).
///
/// The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are
/// declared locally and dispatched by name, which is the one place this host
/// does not share the desktop's guarantees: the desktop builds its router from
/// the schema, so every command has a handler by construction. Here a renamed
/// command would surface as a runtime "not a command this host answers".
///
/// The fix is `yaak-commands` (the `Host` trait), not more machinery here —
/// its `models::*` handlers are already this file, typed. Three things have to
/// give before a wasm host can register them:
///
/// 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread
/// and the connection pool is an `Rc`.
/// 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn
/// onto here.
/// 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP
/// stack and the Node sidecar and do not build for wasm32.
///
/// None of those are hard; they are just not this PR.
#[wasm_bindgen]
pub fn rpc(cmd: &str, payload: JsValue, label: &str) -> Result<JsValue> {
let source = UpdateSource::from_window_label(label);
let result = with_host(|host| dispatch(host, cmd, payload, &source))?;
let events = with_host(|host| Ok(host.events.try_iter().collect::<Vec<_>>()))?;
use serde::Serialize as _;
RpcOutcome { result, events }
.serialize(&serde_wasm_bindgen::Serializer::json_compatible())
.map_err(js_error)
}
fn from_js<T: for<'de> Deserialize<'de>>(payload: JsValue) -> Result<T> {
serde_wasm_bindgen::from_value(payload).map_err(js_error)
}
fn to_json<T: Serialize>(value: T) -> Result<serde_json::Value> {
serde_json::to_value(value).map_err(js_error)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorkspaceModelsReq {
workspace_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelReq {
model: AnyModel,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DuplicateReq {
model_type: String,
model_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorkspaceIdReq {
workspace_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RequestIdReq {
request_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpsertIntrospectionReq {
workspace_id: String,
request_id: String,
content: Option<String>,
}
fn dispatch(
host: &Host,
cmd: &str,
payload: JsValue,
source: &UpdateSource,
) -> Result<serde_json::Value> {
match cmd {
// The one big read. Same list, same order, and the same four lazy
// creates (settings, first workspace, cookie jar, base environment) as
// `models_workspace_models` on the desktop — this call is where an
// empty database becomes a usable one. Returned as a JSON *string*
// because that is what the desktop returns and what the store parses.
"models_workspace_models" => {
let req: WorkspaceModelsReq = from_js(payload)?;
let db = host.queries.connect();
let mut list: Vec<AnyModel> = Vec::new();
list.push(db.get_settings().into());
list.extend(db.list_workspaces().map_err(js_error)?.into_iter().map(Into::into));
list.extend(db.list_key_values().map_err(js_error)?.into_iter().map(Into::into));
// No plugin runtime to resolve these against; the rows are still
// the truth about what is installed.
list.extend(db.list_plugins().map_err(js_error)?.into_iter().map(Into::into));
if let Some(wid) = req.workspace_id.as_deref() {
let e = js_error;
list.extend(db.list_cookie_jars(wid).map_err(e)?.into_iter().map(Into::into));
list.extend(
db.list_environments_ensure_base(wid).map_err(e)?.into_iter().map(Into::into),
);
list.extend(db.list_folders(wid).map_err(e)?.into_iter().map(Into::into));
list.extend(db.list_grpc_connections(wid).map_err(e)?.into_iter().map(Into::into));
list.extend(db.list_grpc_requests(wid).map_err(e)?.into_iter().map(Into::into));
list.extend(db.list_http_requests(wid).map_err(e)?.into_iter().map(Into::into));
list.extend(
db.list_http_responses(wid, None).map_err(e)?.into_iter().map(Into::into),
);
list.extend(
db.list_websocket_connections(wid).map_err(e)?.into_iter().map(Into::into),
);
list.extend(
db.list_websocket_requests(wid).map_err(e)?.into_iter().map(Into::into),
);
list.extend(db.list_workspace_metas(wid).map_err(e)?.into_iter().map(Into::into));
}
to_json(serde_json::to_string(&list).map_err(js_error)?)
}
"models_upsert" => {
let req: ModelReq = from_js(payload)?;
let db = host.queries.connect();
let id =
models_ops::upsert_model(&db, &host.blobs, req.model, source).map_err(js_error)?;
to_json(id)
}
// Deletes and duplicates cascade, so they run in a transaction, as on
// the desktop.
"models_delete" => {
let req: ModelReq = from_js(payload)?;
let id = host
.queries
.with_tx(|tx| models_ops::delete_model(tx, &host.blobs, req.model, source))
.map_err(js_error)?;
to_json(id)
}
"models_duplicate" => {
let req: DuplicateReq = from_js(payload)?;
let id = host
.queries
.with_tx(|tx| {
models_ops::duplicate_model(tx, &req.model_type, &req.model_id, source)
})
.map_err(js_error)?;
to_json(id)
}
"models_get_settings" => to_json(host.queries.connect().get_settings()),
"models_get_graphql_introspection" => {
let req: RequestIdReq = from_js(payload)?;
to_json(host.queries.connect().get_graphql_introspection(&req.request_id))
}
"models_upsert_graphql_introspection" => {
let req: UpsertIntrospectionReq = from_js(payload)?;
let saved = host
.queries
.connect()
.upsert_graphql_introspection(
&req.workspace_id,
&req.request_id,
req.content,
source,
)
.map_err(js_error)?;
to_json(saved)
}
// Nothing here can open a socket, so no connection ever produced any.
"models_grpc_events" | "models_websocket_events" => to_json(Vec::<()>::new()),
"cmd_get_workspace_meta" => {
let req: WorkspaceIdReq = from_js(payload)?;
let db = host.queries.connect();
let workspace = db.get_workspace(&req.workspace_id).map_err(js_error)?;
to_json(db.get_or_create_workspace_meta(&workspace.id).map_err(js_error)?)
}
other => Err(js_error(format!("yaak-web: `{other}` is not a command this host answers"))),
}
}
/* -------------------------------------------------------------------------- */
/* Blobs */
/* -------------------------------------------------------------------------- */
/// The bytes stored under an id, or none. Ids are the desktop's: a response's
/// own id for its body, `{responseId}.request` for the request that produced
/// it. Bytes cross to JS as a `Uint8Array` rather than through JSON.
#[wasm_bindgen]
pub fn blob_get(id: &str) -> Result<Option<Vec<u8>>> {
with_host(|host| {
let chunks = host.blobs.connect().get_chunks(id).map_err(js_error)?;
if chunks.is_empty() {
return Ok(None);
}
Ok(Some(chunks.into_iter().flat_map(|c| c.data).collect()))
})
}
/// Store bytes under an id, replacing anything already there. Chunked the way
/// the desktop chunks, so a body written here reads back on a desktop that
/// imports the database, and vice versa.
#[wasm_bindgen]
pub fn blob_put(id: &str, bytes: &[u8]) -> Result<()> {
const CHUNK: usize = 512 * 1024;
with_host(|host| {
let ctx = host.blobs.connect();
ctx.delete_chunks(id).map_err(js_error)?;
for (i, part) in bytes.chunks(CHUNK).enumerate() {
ctx.insert_chunk(&BodyChunk::new(id, i as i32, part.to_vec())).map_err(js_error)?;
}
Ok(())
})
}
#[wasm_bindgen]
pub fn blob_delete(id: &str) -> Result<()> {
with_host(|host| host.blobs.connect().delete_chunks(id).map_err(js_error))
}
+2
View File
@@ -6,6 +6,7 @@ publish = false
[dependencies]
async-trait = "0.1"
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
log = { workspace = true }
md5 = "0.8.0"
serde_json = { workspace = true }
@@ -20,5 +21,6 @@ yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
[dev-dependencies]
rusqlite = { version = "0.38", features = ["bundled"] }
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+770 -73
View File
@@ -1,129 +1,826 @@
use crate::Result;
use log::info;
use std::collections::BTreeMap;
use yaak_core::WorkspaceContext;
use std::collections::{BTreeMap, BTreeSet};
use yaak_models::client_db::ClientDb;
use yaak_models::models::{
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo,
WebsocketRequest, Workspace,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
use yaak_models::util::{
BatchUpsertResult, ImportDestination, ImportPlan, ImportPlanResources, ImportPlanWarning,
PlannedImportResource, UpdateSource,
};
use yaak_plugins::events::{ImportResources, PluginContext};
use yaak_plugins::manager::PluginManager;
pub struct ImportDataParams<'a> {
pub struct PlanImportDataParams<'a> {
pub query_manager: &'a QueryManager,
pub plugin_manager: &'a PluginManager,
pub plugin_context: &'a PluginContext,
pub workspace_context: WorkspaceContext,
pub destination: ImportDestination,
pub contents: &'a str,
}
pub async fn import_data(params: ImportDataParams<'_>) -> Result<BatchUpsertResult> {
/// Parse importer output and turn it into a commit-ready plan without mutating the database.
pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<ImportPlan> {
let import_result =
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
import_resources(params.query_manager, params.workspace_context, import_result.resources)
plan_import_resources(
params.query_manager,
import_result.importer,
params.destination,
import_result.resources,
)
}
pub fn import_resources(
/// Remap parsed importer resources into their selected destination.
///
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
/// existing model and also makes the plan safe to inspect before it is committed.
pub fn plan_import_resources(
query_manager: &QueryManager,
workspace_context: WorkspaceContext,
importer: String,
destination: ImportDestination,
resources: ImportResources,
) -> Result<BatchUpsertResult> {
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
) -> Result<ImportPlan> {
let mut warnings = Vec::new();
validate_destination(query_manager, &destination)?;
let workspaces: Vec<Workspace> = resources
.workspaces
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Workspace>(&workspace_context, v.id.as_str(), &mut id_map);
v
})
.collect();
let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
let mut folder_ids = BTreeMap::new();
for folder in &resources.folders {
folder_ids.insert(folder.id.clone(), Folder::generate_id());
}
let environments: Vec<Environment> = resources
.environments
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Environment>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
("folder", Some(parent_id)) => {
v.parent_id =
Some(maybe_gen_id::<Folder>(&workspace_context, parent_id, &mut id_map));
}
("", _) => {
v.parent_model = "workspace".to_string();
}
_ => {
v.parent_id = None;
}
};
v
})
.collect();
let mut workspace_ids = BTreeMap::new();
let mut workspaces = Vec::new();
let (default_workspace_id, target_folder_id) = match &destination {
ImportDestination::NewWorkspace => {
for source in &resources.workspaces {
let mut workspace = source.clone();
workspace.id = Workspace::generate_id();
workspace_ids.insert(source.id.clone(), workspace.id.clone());
workspaces.push(PlannedImportResource::new(workspace));
}
let folders: Vec<Folder> = resources
if workspaces.is_empty() {
let workspace = Workspace {
id: Workspace::generate_id(),
model: "workspace".to_string(),
name: format!("{} Import", display_importer_name(&importer)),
setting_follow_redirects: true,
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true,
setting_send_cookies: true,
setting_store_cookies: true,
..Default::default()
};
workspaces.push(PlannedImportResource::new(workspace));
}
(workspaces[0].resource.id.clone(), None)
}
ImportDestination::CurrentWorkspace { workspace_id, folder_id } => {
for source in &resources.workspaces {
workspace_ids.insert(source.id.clone(), workspace_id.clone());
}
if !resources.workspaces.is_empty() {
let destination_workspace = query_manager.connect().get_workspace(workspace_id)?;
let skipped_fields = resources
.workspaces
.iter()
.flat_map(|source| {
workspace_fields_not_imported(source, &destination_workspace)
})
.collect::<BTreeSet<_>>();
if !skipped_fields.is_empty() {
let source = if resources.workspaces.len() == 1 {
resources.workspaces[0].name.clone()
} else {
format!("{} imported workspaces", resources.workspaces.len())
};
warnings.push(ImportPlanWarning {
title: "Workspace settings skipped".to_string(),
detail: format!("{source} · {}", display_list(&skipped_fields)),
});
}
}
(workspace_id.clone(), folder_id.clone())
}
};
let resolve_workspace_id = |source_id: &str| {
workspace_ids.get(source_id).cloned().unwrap_or_else(|| default_workspace_id.clone())
};
let resolve_folder_id = |source_id: Option<String>| match source_id {
Some(source_id) if source_folder_ids.contains(&source_id) => {
folder_ids.get(&source_id).cloned()
}
_ => target_folder_id.clone(),
};
let folders = resources
.folders
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Folder>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut folder| {
folder.id = folder_ids.get(&folder.id).cloned().unwrap_or_else(Folder::generate_id);
folder.workspace_id = resolve_workspace_id(&folder.workspace_id);
folder.folder_id = resolve_folder_id(folder.folder_id);
PlannedImportResource::new(folder)
})
.collect();
let http_requests: Vec<HttpRequest> = resources
let http_requests = resources
.http_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<HttpRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut request| {
request.id = HttpRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
PlannedImportResource::new(request)
})
.collect();
let grpc_requests: Vec<GrpcRequest> = resources
let grpc_requests = resources
.grpc_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<GrpcRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut request| {
request.id = GrpcRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
PlannedImportResource::new(request)
})
.collect();
let websocket_requests: Vec<WebsocketRequest> = resources
let websocket_requests = resources
.websocket_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<WebsocketRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut request| {
request.id = WebsocketRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
PlannedImportResource::new(request)
})
.collect();
info!("Importing data");
let importing_into_current = matches!(destination, ImportDestination::CurrentWorkspace { .. });
let mut separated_base_environments = Vec::new();
let mut converted_duplicate_base_environment = false;
let mut converted_duplicate_folder_environment = false;
let mut base_environment_workspaces = BTreeSet::new();
let mut folder_environment_ids = BTreeSet::new();
let environments = resources
.environments
.into_iter()
.map(|mut environment| {
environment.id = Environment::generate_id();
environment.workspace_id = resolve_workspace_id(&environment.workspace_id);
query_manager.with_tx(|tx| {
tx.batch_upsert(
match (environment.parent_model.as_str(), environment.parent_id.clone()) {
("workspace", _) if importing_into_current => {
environment.parent_model = "environment".to_string();
environment.parent_id = None;
let source_name = environment.name.clone();
environment.name = format!("{} (Imported)", environment.name);
separated_base_environments.push((
source_name,
environment.name.clone(),
environment.variables.len(),
));
}
("workspace", _) => {
environment.parent_id = None;
if !base_environment_workspaces.insert(environment.workspace_id.clone()) {
environment.parent_model = "environment".to_string();
environment.name = format!("{} (Imported)", environment.name);
converted_duplicate_base_environment = true;
}
}
("folder", Some(parent_id)) if source_folder_ids.contains(&parent_id) => {
environment.parent_id = folder_ids.get(&parent_id).cloned();
if let Some(parent_id) = &environment.parent_id
&& !folder_environment_ids.insert(parent_id.clone())
{
environment.parent_model = "environment".to_string();
environment.parent_id = None;
converted_duplicate_folder_environment = true;
}
}
("folder", _) => {
// Never attach an imported folder environment to an existing folder: the model
// layer permits only one and would otherwise delete the destination's value.
environment.parent_model = "environment".to_string();
environment.parent_id = None;
}
("environment", _) => {
environment.parent_id = None;
}
_ => {
environment.parent_model = "environment".to_string();
environment.parent_id = None;
}
}
PlannedImportResource::new(environment)
})
.collect();
for (source_name, imported_name, variable_count) in separated_base_environments {
let variables = if variable_count == 1 { "variable" } else { "variables" };
warnings.push(ImportPlanWarning {
title: "Base environment kept separate".to_string(),
detail: format!("{source_name} → {imported_name} · {variable_count} {variables}"),
});
}
if converted_duplicate_base_environment {
warnings.push(ImportPlanWarning {
title: "Base environments separated".to_string(),
detail: "Only the first remains the base environment".to_string(),
});
}
if converted_duplicate_folder_environment {
warnings.push(ImportPlanWarning {
title: "Folder environments separated".to_string(),
detail: "Only the first remains attached to each folder".to_string(),
});
}
Ok(ImportPlan {
importer,
destination,
resources: ImportPlanResources {
workspaces,
environments,
folders,
http_requests,
grpc_requests,
websocket_requests,
},
warnings,
})
}
/// Commit a previously prepared plan in one transaction.
pub fn commit_import_plan(
query_manager: &QueryManager,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
validate_plan(&plan)?;
let resources = plan.resources.into_batch();
info!("Committing staged import from {}", plan.importer);
query_manager.with_tx(|tx| {
validate_destination_db(tx, &plan.destination)?;
tx.batch_upsert(
resources.workspaces,
resources.environments,
resources.folders,
resources.http_requests,
resources.grpc_requests,
resources.websocket_requests,
&UpdateSource::Import,
)
.map_err(crate::Error::from)
})
}
fn validate_destination(
query_manager: &QueryManager,
destination: &ImportDestination,
) -> Result<()> {
let db = query_manager.connect();
validate_destination_db(&db, destination)
}
fn validate_destination_db(db: &ClientDb<'_>, destination: &ImportDestination) -> Result<()> {
let ImportDestination::CurrentWorkspace { workspace_id, folder_id } = destination else {
return Ok(());
};
db.get_workspace(workspace_id)?;
if let Some(folder_id) = folder_id {
let folder = db.get_folder(folder_id)?;
if folder.workspace_id != *workspace_id {
return Err(yaak_models::error::Error::GenericError(format!(
"Folder {folder_id} does not belong to workspace {workspace_id}"
))
.into());
}
}
Ok(())
}
fn validate_plan(plan: &ImportPlan) -> Result<()> {
let invalid = |message: String| -> Result<()> {
Err(yaak_models::error::Error::GenericError(message).into())
};
match &plan.destination {
ImportDestination::CurrentWorkspace { workspace_id, .. } => {
if !plan.resources.workspaces.is_empty() {
return invalid(
"A current-workspace import plan must not contain workspace updates"
.to_string(),
);
}
let all_workspace_ids = plan
.resources
.environments
.iter()
.map(|v| &v.resource.workspace_id)
.chain(plan.resources.folders.iter().map(|v| &v.resource.workspace_id))
.chain(plan.resources.http_requests.iter().map(|v| &v.resource.workspace_id))
.chain(plan.resources.grpc_requests.iter().map(|v| &v.resource.workspace_id))
.chain(plan.resources.websocket_requests.iter().map(|v| &v.resource.workspace_id));
if all_workspace_ids.into_iter().any(|id| id != workspace_id) {
return invalid(
"A current-workspace import plan contains resources for another workspace"
.to_string(),
);
}
if plan.resources.environments.iter().any(|v| v.resource.parent_model == "workspace") {
return invalid(
"A current-workspace import plan must not replace the base environment"
.to_string(),
);
}
}
ImportDestination::NewWorkspace => {
let workspace_ids = plan
.resources
.workspaces
.iter()
.map(|v| v.resource.id.as_str())
.collect::<BTreeSet<_>>();
if workspace_ids.is_empty() {
return invalid("A new-workspace import plan has no workspace".to_string());
}
let all_workspace_ids = plan
.resources
.environments
.iter()
.map(|v| v.resource.workspace_id.as_str())
.chain(plan.resources.folders.iter().map(|v| v.resource.workspace_id.as_str()))
.chain(
plan.resources.http_requests.iter().map(|v| v.resource.workspace_id.as_str()),
)
.chain(
plan.resources.grpc_requests.iter().map(|v| v.resource.workspace_id.as_str()),
)
.chain(
plan.resources
.websocket_requests
.iter()
.map(|v| v.resource.workspace_id.as_str()),
);
if all_workspace_ids.into_iter().any(|id| !workspace_ids.contains(id)) {
return invalid(
"A new-workspace import plan contains resources outside its workspaces"
.to_string(),
);
}
let mut base_environment_workspaces = BTreeSet::new();
if plan.resources.environments.iter().any(|v| {
v.resource.parent_model == "workspace"
&& !base_environment_workspaces.insert(v.resource.workspace_id.as_str())
}) {
return invalid(
"A new-workspace import plan contains multiple base environments for one workspace"
.to_string(),
);
}
}
}
let planned_folder_ids =
plan.resources.folders.iter().map(|v| v.resource.id.as_str()).collect::<BTreeSet<_>>();
if plan.resources.environments.iter().any(|v| {
v.resource.parent_model == "folder"
&& v.resource.parent_id.as_deref().is_none_or(|id| !planned_folder_ids.contains(id))
}) {
return invalid(
"An import plan must not replace an existing folder environment".to_string(),
);
}
Ok(())
}
fn display_importer_name(importer: &str) -> &str {
importer.strip_prefix("@yaak/importer-").unwrap_or(importer)
}
fn workspace_fields_not_imported(source: &Workspace, destination: &Workspace) -> Vec<&'static str> {
let mut fields = Vec::new();
if source.name != destination.name {
fields.push("workspace name");
}
if source.description != destination.description {
fields.push("description");
}
if source.authentication != destination.authentication
|| source.authentication_type != destination.authentication_type
{
fields.push("authentication");
}
if source.headers != destination.headers {
fields.push("default headers");
}
if source.encryption_key_challenge != destination.encryption_key_challenge {
fields.push("encryption configuration");
}
if source.setting_validate_certificates != destination.setting_validate_certificates {
fields.push("certificate validation");
}
if source.setting_follow_redirects != destination.setting_follow_redirects {
fields.push("redirect behavior");
}
if source.setting_request_timeout != destination.setting_request_timeout {
fields.push("request timeout");
}
if source.setting_request_message_size != destination.setting_request_message_size {
fields.push("request message size");
}
if source.setting_dns_overrides != destination.setting_dns_overrides {
fields.push("DNS overrides");
}
if source.setting_send_cookies != destination.setting_send_cookies
|| source.setting_store_cookies != destination.setting_store_cookies
{
fields.push("cookie behavior");
}
fields
}
fn display_list(items: &BTreeSet<&str>) -> String {
let items = items.iter().copied().collect::<Vec<_>>();
match items.as_slice() {
[] => String::new(),
[item] => (*item).to_string(),
[first, second] => format!("{first} and {second}"),
_ => format!("{}, and {}", items[..items.len() - 1].join(", "), items[items.len() - 1]),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use yaak_models::models::{EnvironmentVariable, HttpRequestHeader};
fn destination_workspace() -> Workspace {
Workspace {
id: "wk_destination".to_string(),
model: "workspace".to_string(),
name: "Destination".to_string(),
authentication: BTreeMap::from([("token".to_string(), json!("keep-me"))]),
authentication_type: Some("bearer".to_string()),
headers: vec![HttpRequestHeader {
enabled: true,
name: "X-Destination".to_string(),
value: "preserved".to_string(),
id: None,
}],
setting_validate_certificates: false,
setting_follow_redirects: false,
setting_request_timeout: 1234,
..Default::default()
}
}
fn imported_resources() -> ImportResources {
ImportResources {
workspaces: vec![Workspace {
id: "wk_source".to_string(),
model: "workspace".to_string(),
name: "Imported".to_string(),
authentication_type: Some("basic".to_string()),
setting_validate_certificates: true,
..Default::default()
}],
environments: vec![Environment {
id: "ev_source_base".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Global Variables".to_string(),
parent_model: "workspace".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "imported".to_string(),
value: "yes".to_string(),
id: None,
}],
..Default::default()
}],
folders: vec![Folder {
id: "fl_source".to_string(),
model: "folder".to_string(),
workspace_id: "wk_source".to_string(),
name: "Imported Folder".to_string(),
..Default::default()
}],
http_requests: vec![
HttpRequest {
id: "rq_root".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
name: "Root Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/root".to_string(),
..Default::default()
},
HttpRequest {
id: "rq_nested".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
folder_id: Some("fl_source".to_string()),
name: "Nested Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/nested".to_string(),
..Default::default()
},
],
..Default::default()
}
}
#[test]
fn current_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut destination = destination_workspace();
let selected_folder = Folder {
id: "fl_selected".to_string(),
model: "folder".to_string(),
workspace_id: destination.id.clone(),
name: "Selected Folder".to_string(),
..Default::default()
};
{
let db = query_manager.connect();
destination = db
.upsert_workspace(&destination, &UpdateSource::Import)
.expect("create destination");
db.upsert_folder(&selected_folder, &UpdateSource::Import)
.expect("create selected folder");
db.upsert_environment(
&Environment {
id: "ev_destination_base".to_string(),
model: "environment".to_string(),
workspace_id: destination.id.clone(),
name: "Destination Variables".to_string(),
parent_model: "workspace".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "destination".to_string(),
value: "keep".to_string(),
id: None,
}],
..Default::default()
},
&UpdateSource::Import,
)
.expect("create base environment");
}
let plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::CurrentWorkspace {
workspace_id: destination.id.clone(),
folder_id: Some(selected_folder.id.clone()),
},
imported_resources(),
)
.expect("plan import");
// Planning performed only reads.
{
let db = query_manager.connect();
assert_eq!(db.list_workspaces().expect("list workspaces").len(), 1);
assert_eq!(db.list_folders(&destination.id).expect("list folders").len(), 1);
assert!(db.list_http_requests(&destination.id).expect("list requests").is_empty());
assert_eq!(
db.list_environments_ensure_base(&destination.id).expect("list environments").len(),
1
);
assert_eq!(db.get_workspace(&destination.id).expect("get destination"), destination);
}
assert!(plan.resources.workspaces.is_empty());
assert_eq!(plan.resources.folders[0].resource.workspace_id, destination.id);
assert_eq!(
plan.resources.folders[0].resource.folder_id.as_deref(),
Some(selected_folder.id.as_str())
);
let root_request = plan
.resources
.http_requests
.iter()
.find(|v| v.resource.name == "Root Request")
.expect("root request");
assert_eq!(root_request.resource.folder_id.as_deref(), Some(selected_folder.id.as_str()));
let nested_request = plan
.resources
.http_requests
.iter()
.find(|v| v.resource.name == "Nested Request")
.expect("nested request");
assert_eq!(
nested_request.resource.folder_id,
Some(plan.resources.folders[0].resource.id.clone())
);
assert_eq!(plan.resources.environments[0].resource.parent_model, "environment");
assert!(plan.resources.environments[0].resource.name.ends_with("(Imported)"));
assert_eq!(plan.warnings.len(), 2);
assert!(plan.warnings.iter().any(|warning| {
warning.title == "Workspace settings skipped"
&& warning.detail.starts_with("Imported ·")
&& warning.detail.contains("authentication")
&& warning.detail.contains("default headers")
}));
assert!(plan.warnings.iter().any(|warning| {
warning.title == "Base environment kept separate"
&& warning.detail == "Global Variables → Global Variables (Imported) · 1 variable"
}));
let committed = commit_import_plan(&query_manager, plan).expect("commit import");
assert!(committed.workspaces.is_empty());
assert_eq!(committed.http_requests.len(), 2);
assert_eq!(
query_manager
.connect()
.get_workspace(&destination.id)
.expect("get destination after commit"),
destination
);
}
#[test]
fn environment_collisions_are_explicit_and_do_not_overwrite() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut resources = imported_resources();
resources.environments.extend([
Environment {
id: "ev_second_base".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Second Base".to_string(),
parent_model: "workspace".to_string(),
..Default::default()
},
Environment {
id: "ev_folder_one".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Folder One".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..Default::default()
},
Environment {
id: "ev_folder_two".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Folder Two".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..Default::default()
},
]);
let plan = plan_import_resources(
&query_manager,
"Yaak".to_string(),
ImportDestination::NewWorkspace,
resources,
)
.expect("plan import");
assert_eq!(
plan.resources
.environments
.iter()
.filter(|v| v.resource.parent_model == "workspace")
.count(),
1
);
assert_eq!(
plan.resources
.environments
.iter()
.filter(|v| v.resource.parent_model == "folder")
.count(),
1
);
assert_eq!(plan.warnings.len(), 2);
}
#[test]
fn importer_id_conventions_all_flow_through_the_same_planner() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let destination = destination_workspace();
query_manager
.connect()
.upsert_workspace(&destination, &UpdateSource::Import)
.expect("create destination");
let resources = ImportResources {
workspaces: vec![
Workspace {
id: "GENERATE_ID::WORKSPACE_0".to_string(),
model: "workspace".to_string(),
name: "Generated ID Importer".to_string(),
..Default::default()
},
Workspace {
id: "wk_exported".to_string(),
model: "workspace".to_string(),
name: "Stable ID Importer".to_string(),
..Default::default()
},
],
http_requests: [
"GENERATE_ID::WORKSPACE_0",
"wk_exported",
"CURRENT_WORKSPACE",
]
.into_iter()
.enumerate()
.map(|(index, workspace_id)| HttpRequest {
id: format!("GENERATE_ID::HTTP_REQUEST_{index}"),
model: "http_request".to_string(),
workspace_id: workspace_id.to_string(),
name: format!("Request {index}"),
method: "GET".to_string(),
..Default::default()
})
.collect(),
..Default::default()
};
let plan = plan_import_resources(
&query_manager,
"Compatibility".to_string(),
ImportDestination::CurrentWorkspace {
workspace_id: destination.id.clone(),
folder_id: None,
},
resources,
)
.expect("plan import");
assert!(plan.resources.workspaces.is_empty());
assert!(
plan.resources.http_requests.iter().all(|v| v.resource.workspace_id == destination.id)
);
assert_eq!(
plan.resources
.http_requests
.iter()
.map(|v| v.resource.id.as_str())
.collect::<BTreeSet<_>>()
.len(),
3
);
}
#[test]
fn commit_rolls_back_every_resource_when_a_late_write_fails() {
let dir = tempfile::tempdir().expect("create temp directory");
let db_path = dir.path().join("models.sqlite");
let blob_path = dir.path().join("blobs.sqlite");
let (query_manager, _blob_manager, _rx) =
yaak_models::init_standalone(&db_path, &blob_path).expect("initialize database");
let plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
)
.expect("plan import");
let workspace_id = plan.resources.workspaces[0].resource.id.clone();
let environment_id = plan.resources.environments[0].resource.id.clone();
let connection = rusqlite::Connection::open(&db_path).expect("open test database");
connection
.execute_batch(&format!(
"CREATE TRIGGER fail_import_environment BEFORE INSERT ON environments \
WHEN NEW.id = '{environment_id}' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;"
))
.expect("install failure trigger");
drop(connection);
assert!(commit_import_plan(&query_manager, plan).is_err());
let db = query_manager.connect();
assert!(db.get_workspace(&workspace_id).is_err(), "workspace insert must roll back");
assert!(db.get_environment(&environment_id).is_err(), "environment must not exist");
}
}
+1 -1
View File
@@ -1,9 +1,9 @@
pub mod error;
pub mod export;
pub mod import;
pub mod models_ops;
pub mod plugin_events;
pub mod render;
pub mod response_body;
pub mod send;
pub use error::Error;
+174 -16
View File
@@ -1,3 +1,6 @@
use crate::response_body::ResponseBodyStore;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use yaak_models::models::AnyModel;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
@@ -5,12 +8,14 @@ use yaak_plugins::events::{
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,
ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,
ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,
PromptFormRequest, PromptTextRequest, ReloadResponse, RenderGrpcRequestRequest,
RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,
TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,
GetHttpRequestByIdResponse, GetHttpResponseBodyInfoRequest, GetHttpResponseBodyInfoResponse,
GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload, ListCookieNamesRequest,
ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest, ListHttpRequestsResponse,
ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest,
PromptTextRequest, ReadHttpResponseBodyChunkRequest, ReadHttpResponseBodyChunkResponse,
ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest,
SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest,
UpsertModelResponse, WindowInfoRequest,
};
pub struct SharedPluginEventContext<'a> {
@@ -40,6 +45,8 @@ pub enum SharedRequest<'a> {
ListFolders(&'a ListFoldersRequest),
ListHttpRequests(&'a ListHttpRequestsRequest),
FindHttpResponses(&'a FindHttpResponsesRequest),
GetHttpResponseBodyInfo(&'a GetHttpResponseBodyInfoRequest),
ReadHttpResponseBodyChunk(&'a ReadHttpResponseBodyChunkRequest),
UpsertModel(&'a UpsertModelRequest),
DeleteModel(&'a DeleteModelRequest),
}
@@ -136,6 +143,12 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
InternalEventPayload::FindHttpResponsesRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::FindHttpResponses(req))
}
InternalEventPayload::GetHttpResponseBodyInfoRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::GetHttpResponseBodyInfo(req))
}
InternalEventPayload::ReadHttpResponseBodyChunkRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::ReadHttpResponseBodyChunk(req))
}
InternalEventPayload::UpsertModelRequest(req) => {
GroupedPluginRequest::Shared(SharedRequest::UpsertModel(req))
}
@@ -182,13 +195,17 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
pub fn handle_shared_plugin_event<'a>(
query_manager: &QueryManager,
body_store: &dyn ResponseBodyStore,
payload: &'a InternalEventPayload,
context: SharedPluginEventContext<'_>,
) -> GroupedPluginEvent<'a> {
match GroupedPluginRequest::from(payload) {
GroupedPluginRequest::Shared(req) => {
GroupedPluginEvent::Handled(Some(build_shared_reply(query_manager, req, context)))
}
GroupedPluginRequest::Shared(req) => GroupedPluginEvent::Handled(Some(build_shared_reply(
query_manager,
body_store,
req,
context,
))),
GroupedPluginRequest::Host(req) => GroupedPluginEvent::ToHandle(req),
GroupedPluginRequest::Ignore => GroupedPluginEvent::Handled(None),
}
@@ -196,6 +213,7 @@ pub fn handle_shared_plugin_event<'a>(
fn build_shared_reply(
query_manager: &QueryManager,
body_store: &dyn ResponseBodyStore,
request: SharedRequest<'_>,
context: SharedPluginEventContext<'_>,
) -> InternalEventPayload {
@@ -283,6 +301,31 @@ fn build_shared_reply(
http_responses,
})
}
SharedRequest::GetHttpResponseBodyInfo(req) => match body_store.info(&req.response_id) {
Ok(info) => InternalEventPayload::GetHttpResponseBodyInfoResponse(
GetHttpResponseBodyInfoResponse {
content_length: info.content_length,
content_type: info.content_type,
complete: info.complete,
},
),
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to read body of response {}: {err}", req.response_id),
}),
},
SharedRequest::ReadHttpResponseBodyChunk(req) => {
match body_store.read_chunk(&req.response_id, req.offset, req.length) {
Ok(bytes) => InternalEventPayload::ReadHttpResponseBodyChunkResponse(
ReadHttpResponseBodyChunkResponse {
length: bytes.len() as u64,
data: BASE64_STANDARD.encode(bytes),
},
),
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to read body of response {}: {err}", req.response_id),
}),
}
}
SharedRequest::UpsertModel(req) => {
use AnyModel::*;
@@ -437,10 +480,26 @@ fn build_shared_reply(
#[cfg(test)]
mod tests {
use super::*;
use crate::response_body::{FileResponseBodyStore, ResponseBodyInfo};
use std::cell::RefCell;
use tempfile::TempDir;
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
use yaak_models::util::UpdateSource;
/// The real dispatch, with the store the desktop and CLI hand it.
fn dispatch<'a>(
query_manager: &QueryManager,
payload: &'a InternalEventPayload,
context: SharedPluginEventContext<'_>,
) -> GroupedPluginEvent<'a> {
handle_shared_plugin_event(
query_manager,
&FileResponseBodyStore::new(query_manager),
payload,
context,
)
}
fn seed_query_manager() -> (QueryManager, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let db_path = temp_dir.path().join("db.sqlite");
@@ -498,7 +557,7 @@ mod tests {
let payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
);
let result = handle_shared_plugin_event(
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
@@ -517,7 +576,7 @@ mod tests {
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
);
let by_workspace = handle_shared_plugin_event(
let by_workspace = dispatch(
&query_manager,
&by_workspace_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -536,7 +595,7 @@ mod tests {
folder_id: Some("fl_test".to_string()),
},
);
let by_folder = handle_shared_plugin_event(
let by_folder = dispatch(
&query_manager,
&by_folder_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
@@ -559,7 +618,7 @@ mod tests {
limit: Some(1),
});
let result = handle_shared_plugin_event(
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -575,6 +634,105 @@ mod tests {
}
}
/// A store that answers from memory, standing in for whatever holds the
/// bytes — the point being that the dispatch below never learns which.
struct FakeBodyStore {
body: Vec<u8>,
reads: RefCell<Vec<(u64, u64)>>,
}
impl ResponseBodyStore for FakeBodyStore {
fn info(&self, _response_id: &str) -> crate::error::Result<ResponseBodyInfo> {
Ok(ResponseBodyInfo {
content_length: self.body.len() as u64,
content_type: Some("text/plain; charset=utf-8".to_string()),
complete: true,
})
}
fn read_chunk(
&self,
_response_id: &str,
offset: u64,
length: u64,
) -> crate::error::Result<Vec<u8>> {
self.reads.borrow_mut().push((offset, length));
let start = (offset as usize).min(self.body.len());
let end = (start + length as usize).min(self.body.len());
Ok(self.body[start..end].to_vec())
}
}
#[test]
fn response_body_is_read_by_id_through_the_store() {
let (query_manager, _temp_dir) = seed_query_manager();
let store = FakeBodyStore { body: b"hello".to_vec(), reads: RefCell::new(Vec::new()) };
let info_payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
GetHttpResponseBodyInfoRequest { response_id: "rs_test".to_string() },
);
let info = handle_shared_plugin_event(
&query_manager,
&store,
&info_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match info {
GroupedPluginEvent::Handled(Some(
InternalEventPayload::GetHttpResponseBodyInfoResponse(resp),
)) => {
assert_eq!(resp.content_length, 5);
assert_eq!(resp.content_type.as_deref(), Some("text/plain; charset=utf-8"));
}
other => panic!("unexpected body info result: {other:?}"),
}
let chunk_payload = InternalEventPayload::ReadHttpResponseBodyChunkRequest(
ReadHttpResponseBodyChunkRequest {
response_id: "rs_test".to_string(),
offset: 1,
length: 3,
},
);
let chunk = handle_shared_plugin_event(
&query_manager,
&store,
&chunk_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match chunk {
GroupedPluginEvent::Handled(Some(
InternalEventPayload::ReadHttpResponseBodyChunkResponse(resp),
)) => {
assert_eq!(resp.length, 3);
assert_eq!(BASE64_STANDARD.decode(resp.data).unwrap(), b"ell");
}
other => panic!("unexpected body chunk result: {other:?}"),
}
assert_eq!(*store.reads.borrow(), vec![(1, 3)]);
}
#[test]
fn an_unreadable_response_body_becomes_an_error_reply() {
let (query_manager, _temp_dir) = seed_query_manager();
let payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
GetHttpResponseBodyInfoRequest { response_id: "rs_never_persisted".to_string() },
);
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
);
match result {
GroupedPluginEvent::Handled(Some(InternalEventPayload::ErrorResponse(resp))) => {
assert!(resp.error.contains("rs_never_persisted"), "unhelpful error: {}", resp.error)
}
other => panic!("unexpected missing-response result: {other:?}"),
}
}
#[test]
fn upsert_and_delete_model_are_shared_handled() {
let (query_manager, _temp_dir) = seed_query_manager();
@@ -590,7 +748,7 @@ mod tests {
}),
});
let upsert_result = handle_shared_plugin_event(
let upsert_result = dispatch(
&query_manager,
&upsert_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -609,7 +767,7 @@ mod tests {
model: "http_request".to_string(),
id: "rq_test".to_string(),
});
let delete_result = handle_shared_plugin_event(
let delete_result = dispatch(
&query_manager,
&delete_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -631,7 +789,7 @@ mod tests {
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
label: "main".to_string(),
});
let result = handle_shared_plugin_event(
let result = dispatch(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },

Some files were not shown because too many files have changed in this diff Show More