Compare commits

..
Author SHA1 Message Date
Gregory SchierandClaude Fable 5 c24dcee4bb Add the Yaak Bridge so a browser tab can run the real engine
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:38:46 -07:00
114 changed files with 4410 additions and 5774 deletions
Generated
+157 -336
View File
@@ -2,18 +2,6 @@
# 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"
@@ -52,6 +40,18 @@ 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"
@@ -613,11 +613,15 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"axum-macros",
"base64 0.22.1",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
@@ -626,10 +630,17 @@ dependencies = [
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.24.0",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -650,6 +661,18 @@ dependencies = [
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
@@ -1066,11 +1089,10 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.3"
version = "1.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
@@ -1387,16 +1409,6 @@ 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"
@@ -1881,20 +1893,6 @@ 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"
@@ -2386,18 +2384,6 @@ 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"
@@ -2454,12 +2440,6 @@ 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"
@@ -2892,10 +2872,8 @@ 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]]
@@ -3136,7 +3114,7 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
"ahash 0.7.8",
]
[[package]]
@@ -3144,6 +3122,9 @@ 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"
@@ -3173,11 +3154,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.11.1"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.16.1",
"hashbrown 0.14.5",
]
[[package]]
@@ -3275,6 +3256,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -3539,17 +3526,6 @@ 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"
@@ -3569,40 +3545,6 @@ 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"
@@ -3648,6 +3590,17 @@ 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"
@@ -4083,9 +4036,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.36.0"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
@@ -4185,102 +4138,6 @@ 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"
@@ -6414,9 +6271,9 @@ dependencies = [
[[package]]
name = "r2d2_sqlite"
version = "0.32.0"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6"
checksum = "eb14dba8247a6a15b7fdbc7d389e2e6f03ee9f184f87117706d509c092dfe846"
dependencies = [
"r2d2",
"rusqlite",
@@ -7301,21 +7158,11 @@ dependencies = [
"text-size",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.17",
]
[[package]]
name = "rusqlite"
version = "0.38.0"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.11.0",
"chrono",
@@ -7324,7 +7171,6 @@ dependencies = [
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
@@ -7606,20 +7452,20 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sea-query"
version = "1.0.2"
version = "0.32.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31"
checksum = "64c91783d1514b99754fc6a4079081dcc2c587dadbff65c48c7f62297443536a"
dependencies = [
"chrono",
"itoa",
"inherent",
"sea-query-derive",
]
[[package]]
name = "sea-query-derive"
version = "1.0.0"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537"
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
dependencies = [
"darling 0.20.11",
"heck 0.4.1",
@@ -7631,9 +7477,9 @@ dependencies = [
[[package]]
name = "sea-query-rusqlite"
version = "0.8.0"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ec6038023c8517c623e5bf9606b3c54d40bc8296bb6b2986040428dd84deddd"
checksum = "3743bbdfb24b1a84cc1a6fbf4b1188e6851f6e00ea20944b44c56bf03a585bb4"
dependencies = [
"rusqlite",
"sea-query",
@@ -7645,28 +7491,6 @@ 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"
@@ -8066,9 +7890,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "2.0.1"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
@@ -8236,34 +8060,6 @@ 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"
@@ -8398,17 +8194,6 @@ 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"
@@ -9324,6 +9109,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log 0.4.29",
"tokio",
"tungstenite 0.24.0",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
@@ -9337,7 +9134,7 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"tungstenite 0.26.2",
]
[[package]]
@@ -9531,6 +9328,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -9541,13 +9339,23 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags 2.11.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
"url",
]
@@ -9569,6 +9377,7 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"log 0.4.29",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -9715,6 +9524,24 @@ dependencies = [
"termcolor",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log 0.4.29",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.26.2"
@@ -11196,7 +11023,6 @@ name = "yaak"
version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"log 0.4.29",
"md5 0.8.0",
"serde_json",
@@ -11261,14 +11087,13 @@ dependencies = [
"thiserror 2.0.17",
"tokio",
"tokio-stream",
"tokio-tungstenite",
"tokio-tungstenite 0.26.2",
"tokio-util",
"ts-rs",
"url",
"uuid",
"yaak",
"yaak-api",
"yaak-commands",
"yaak-common",
"yaak-core",
"yaak-crypto",
@@ -11348,24 +11173,6 @@ dependencies = [
"zip",
]
[[package]]
name = "yaak-commands"
version = "0.0.0"
dependencies = [
"log 0.4.29",
"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"
@@ -11400,7 +11207,6 @@ name = "yaak-database"
version = "0.1.0"
dependencies = [
"chrono",
"getrandom 0.2.16",
"include_dir",
"log 0.4.29",
"nanoid",
@@ -11413,7 +11219,6 @@ dependencies = [
"serde_json",
"thiserror 2.0.17",
"ts-rs",
"uuid",
]
[[package]]
@@ -11584,7 +11389,7 @@ dependencies = [
"sha2",
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.26.2",
"ts-rs",
"yaak-common",
"yaak-crypto",
@@ -11655,6 +11460,41 @@ dependencies = [
"yaak-ws",
]
[[package]]
name = "yaak-server"
version = "0.1.0"
dependencies = [
"axum",
"charset",
"chrono",
"clap",
"dirs",
"env_logger",
"eventsource-client",
"futures",
"include_dir",
"log 0.4.29",
"mime_guess",
"pretty_graphql",
"rand 0.8.5",
"serde",
"serde_json",
"serde_urlencoded",
"tokio",
"tower-http",
"yaak",
"yaak-common",
"yaak-core",
"yaak-crypto",
"yaak-http",
"yaak-models",
"yaak-plugins",
"yaak-rpc",
"yaak-rpc-schema",
"yaak-sse",
"yaak-templates",
]
[[package]]
name = "yaak-sse"
version = "0.1.0"
@@ -11697,7 +11537,6 @@ version = "0.1.0"
dependencies = [
"regex 1.11.1",
"tauri",
"yaak-core",
]
[[package]]
@@ -11721,7 +11560,6 @@ version = "0.1.0"
dependencies = [
"log 0.4.29",
"p12",
"pem",
"rustls",
"rustls-pemfile",
"rustls-platform-verifier",
@@ -11732,23 +11570,6 @@ 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"
@@ -11774,7 +11595,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.26.2",
"url",
"yaak-http",
"yaak-models",
+2 -3
View File
@@ -2,7 +2,6 @@
resolver = "2"
members = [
"crates/yaak",
"crates/yaak-commands",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
@@ -21,13 +20,14 @@ members = [
"crates/yaak-templates",
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-web",
"crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# CLI crates
"crates-cli/yaak-cli",
# Headless server crates
"crates-server/yaak-server",
# Tauri-specific crates
"crates-tauri/yaak-app-client",
"crates-tauri/yaak-app-proxy",
@@ -71,7 +71,6 @@ 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,25 +44,6 @@ 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:
+5 -14
View File
@@ -1,4 +1,3 @@
import { platform } from "@yaakapp-internal/platform";
import type { SettingsTab } from "../components/Settings/Settings";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
@@ -15,19 +14,11 @@ export const openSettings = createFastMutation<void, string, SettingsTabWithSubt
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
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 });
const location = router.buildLocation({
to: "/workspaces/$workspaceId/settings",
params: { workspaceId },
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
});
await rpc("cmd_new_child_window", {
url: location.href,
@@ -1,4 +1,3 @@
import { platform } from "@yaakapp-internal/platform";
import { createFastMutation } from "../hooks/useFastMutation";
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
@@ -25,9 +24,7 @@ export const switchWorkspace = createFastMutation<
request_id: requestId,
};
// 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) {
if (inNewWindow) {
const location = router.buildLocation({
to: "/workspaces/$workspaceId",
params: { workspaceId },
-28
View File
@@ -17,37 +17,9 @@ 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 and top-level-await handling as the main one.
worker: {
format: "es" as const,
plugins: () => [wasm(), topLevelAwait()],
},
plugins: [
wasm(),
tanstackRouter({
+1 -13
View File
@@ -1,7 +1,5 @@
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;
@@ -14,7 +12,6 @@ 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;
@@ -134,7 +131,6 @@ 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 },
) {
@@ -227,15 +223,7 @@ async fn build_plugin_reply(
.await
{
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
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)),
},
SendHttpRequestResponse { http_response: result.response },
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to send HTTP request in CLI: {err}"),
+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.32"
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
r2d2_sqlite = "0.25.0"
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl"] }
+48
View File
@@ -0,0 +1,48 @@
[package]
name = "yaak-server"
version = "0.1.0"
edition = "2024"
publish = false
[[bin]]
name = "yaak-bridge"
path = "src/main.rs"
[dependencies]
axum = { version = "0.7", features = ["ws", "macros"] }
charset = "0.1"
chrono = { workspace = true }
clap = { version = "4", features = ["derive", "env"] }
dirs = "6"
env_logger = "0.11"
eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client", version = "0.14.0" }
futures = "0.3"
include_dir = "0.7"
log = { workspace = true }
mime_guess = "2"
pretty_graphql = "0.2"
rand = "0.8"
serde = { workspace = true }
serde_json = { workspace = true }
serde_urlencoded = "0.7"
tokio = { workspace = true, features = [
"rt-multi-thread",
"macros",
"io-util",
"net",
"signal",
"time",
"sync",
] }
tower-http = { version = "0.6", features = ["cors", "fs", "trace"] }
yaak = { workspace = true }
yaak-common = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-rpc = { workspace = true }
yaak-rpc-schema = { workspace = true }
yaak-sse = { workspace = true }
yaak-templates = { workspace = true }
+94
View File
@@ -0,0 +1,94 @@
# Yaak Bridge
A headless binary that runs the real Yaak engine for a browser tab.
The tab is the unmodified Yaak UI. Everything a page cannot do — send an HTTP
request and see every response header, follow redirects, keep a cookie jar, run
the plugin runtime, read a response body off disk — happens in this process,
reached over local HTTP and a WebSocket.
This is the reason a browser Yaak can be credible at all. An in-page `fetch`
sender only ever sees the CORS-safelisted response headers: measured against
httpbin, a server that sent 8 headers yielded 2. Through the bridge the same
request yields all 8, plus the redirect chain, `Set-Cookie`, connection timings
and client certificates.
## Running it
Start the bridge:
```bash
cargo run -p yaak-server -- --port 9444
```
It binds `127.0.0.1` only and prints a bearer token that every route requires.
Then point a frontend at it. In dev, run Vite separately and tell it where the
bridge is:
```bash
YAAK_CLIENT_DEV_PORT=1472 VITE_YAAK_BRIDGE_URL=http://127.0.0.1:9444 npm run dev --workspace apps/yaak-client
```
Open `http://localhost:1472/?bridgeToken=<token>`. The token is consumed from
the query, kept for the session, and stripped from the address bar. Without one
you get a small connect form.
To serve the built frontend from the bridge itself instead, so there is only one
process:
```bash
npm run build --workspace apps/yaak-client
cargo run -p yaak-server -- --web-dir dist/apps/yaak-client
```
## Shape
| Route | What it carries |
| --- | --- |
| `POST /rpc` | The yaak-rpc envelope, the same one Tauri's `invoke` wraps on the desktop |
| `GET /events` | WebSocket. Server to client: `model_writes`, `stream_{id}`, toasts, plugin events. Client to server: the tab's location, and replies to prompts |
| `GET /responses/:id/body` | Response bodies, with Range support. Replaces reading `bodyPath` off disk |
| `GET /bridge/info` | Capabilities and the implemented command list |
Auth is a bearer token in the `Authorization` header, or a `token` query
parameter for the two requests the browser issues itself (the WebSocket, and
`<img src>`-style body loads). It is dev-grade and deliberately minimal: OTP
pairing and request encryption replace it, and `require_token` in `http.rs` is
where they go.
## Relationship to the other hosts
The engine crates under `crates/` are Tauri-free, and `crates-cli/yaak-cli`
already proved they run headless. This crate is structurally the CLI's
`CliContext` with an event hub attached — same `init_standalone` database, same
`PluginManager` over the same Node sidecar.
Two things are ported deliberately rather than invented:
- **Model writes** (`model_writes.rs`) keep the desktop's two paths: an
in-memory channel for writes this process made, and a poll of the
`model_changes` table so external writers — the CLI, the desktop app open on
the same database — show up live in the browser.
- **Plugin host requests** (`plugin_events.rs`) let `yaak::plugin_events`
answer everything that is only a database question, exactly as the CLI and the
desktop do. Only the host-specific arms differ, and where the CLI answers a
prompt from a TTY, the bridge round-trips it to the tab the way the desktop
round-trips it to a window.
## Known gaps
- **Settings is unreachable.** The desktop opens it via `cmd_new_child_window`.
A tab is one window, `multiWindow` is false, and this task did not add in-page
routing for it.
- **One tab at a time.** Model writes broadcast correctly to every connected
tab, so two tabs stay in sync for reads. What breaks is the session: the
tab's reported URL lives in a single slot, so with two tabs in different
workspaces a plugin's template render resolves against whichever attached
last. Prompts also broadcast, so a dialog raised by one tab appears in both.
- **No local files.** There is no file dialog, so request bodies from disk,
export, and save-response are unsupported. `cmd_import_data` is registered and
works, but only for a path typed by hand on the bridge's machine.
- **Command subset.** Roughly 40 of the desktop's 107 commands are implemented.
The rest return a structured "not supported on this host" error naming the
command; `UNSUPPORTED_COMMANDS` in `rpc/mod.rs` lists them.
+117
View File
@@ -0,0 +1,117 @@
//! The events channel: everything the browser tab would have received as a
//! Tauri window event.
//!
//! Two directions ride the same WebSocket. Server to client is a broadcast, so
//! `model_writes`, `stream_{id}` messages, toasts and plugin events all reach
//! the tab through one pipe. Client to server exists because some plugin host
//! requests are questions — a prompt round-trips through the UI and comes back
//! keyed by the originating event's id, exactly as the desktop app's
//! `call_frontend` does with window events.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::{broadcast, mpsc};
/// One frame in either direction: a name and a JSON payload.
///
/// Deliberately the same shape both ways, and the same shape as the desktop's
/// event payloads, so `platform.listen` on the browser side hands the payload
/// to callers unwrapped.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventFrame {
pub event: String,
#[serde(default)]
pub payload: serde_json::Value,
}
#[derive(Clone)]
pub struct EventHub {
outbound: broadcast::Sender<EventFrame>,
/// Listeners waiting on a named event from the client, keyed by event name.
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
}
/// A subscription to one named client-sent event. Deregisters on drop, so a
/// prompt that is never answered doesn't leak a listener for the process's life.
pub struct InboundSubscription {
event: String,
rx: mpsc::UnboundedReceiver<serde_json::Value>,
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
}
impl InboundSubscription {
pub async fn recv(&mut self) -> Option<serde_json::Value> {
self.rx.recv().await
}
}
impl Drop for InboundSubscription {
fn drop(&mut self) {
let mut inbound = match self.inbound.lock() {
Ok(inbound) => inbound,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(senders) = inbound.get_mut(&self.event) {
senders.retain(|tx| !tx.is_closed());
if senders.is_empty() {
inbound.remove(&self.event);
}
}
}
}
impl EventHub {
pub fn new() -> Self {
// Bounded: a tab that stops reading gets dropped frames rather than
// growing the server's memory without limit. Model writes are the
// high-volume case (imports, bulk deletes) and they arrive in batches.
let (outbound, _) = broadcast::channel(1024);
Self { outbound, inbound: Arc::new(Mutex::new(HashMap::new())) }
}
/// Send an event to every connected tab. Fails silently when none is
/// connected, which is the normal state before a browser attaches.
pub fn emit<T: Serialize>(&self, event: impl Into<String>, payload: &T) {
let payload = match serde_json::to_value(payload) {
Ok(payload) => payload,
Err(e) => {
log::warn!("Failed to serialize event payload: {e}");
return;
}
};
let _ = self.outbound.send(EventFrame { event: event.into(), payload });
}
pub fn subscribe(&self) -> broadcast::Receiver<EventFrame> {
self.outbound.subscribe()
}
/// Listen for a named event sent *by* the client.
pub fn subscribe_inbound(&self, event: impl Into<String>) -> InboundSubscription {
let event = event.into();
let (tx, rx) = mpsc::unbounded_channel();
let mut inbound = match self.inbound.lock() {
Ok(inbound) => inbound,
Err(poisoned) => poisoned.into_inner(),
};
inbound.entry(event.clone()).or_default().push(tx);
drop(inbound);
InboundSubscription { event, rx, inbound: Arc::clone(&self.inbound) }
}
/// Route a frame that arrived from a tab to whoever is waiting on it.
pub fn dispatch_inbound(&self, frame: EventFrame) {
let mut inbound = match self.inbound.lock() {
Ok(inbound) => inbound,
Err(poisoned) => poisoned.into_inner(),
};
let Some(senders) = inbound.get_mut(&frame.event) else {
return;
};
senders.retain(|tx| tx.send(frame.payload.clone()).is_ok());
if senders.is_empty() {
inbound.remove(&frame.event);
}
}
}
+354
View File
@@ -0,0 +1,354 @@
//! The front door: one HTTP surface for the browser tab.
//!
//! Three routes carry everything. `POST /rpc` is the yaak-rpc envelope, byte for
//! byte what the desktop puts inside Tauri's `invoke`. `GET /events` is the
//! WebSocket that replaces window events, in both directions. And
//! `GET /responses/:id/body` replaces reading `bodyPath` off disk, which a tab
//! cannot do.
use crate::events::EventFrame;
use crate::rpc::BridgeCtx;
use crate::session::SessionContext;
use crate::state::BridgeState;
use axum::body::Body;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, Request, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tower_http::cors::CorsLayer;
use yaak_rpc::{RpcRequest, RpcResponse, RpcRouter};
#[derive(Clone)]
pub struct AppState {
pub state: Arc<BridgeState>,
pub router: Arc<RpcRouter<BridgeCtx>>,
}
pub fn build_app(state: Arc<BridgeState>, router: Arc<RpcRouter<BridgeCtx>>) -> Router {
let app_state = AppState { state: state.clone(), router };
let api = Router::new()
.route("/bridge/info", get(bridge_info))
.route("/rpc", post(rpc_handler))
.route("/events", get(events_handler))
.route("/responses/:id/body", get(response_body))
.layer(axum::middleware::from_fn_with_state(state.clone(), require_token))
// The dev setup serves the frontend from Vite on another port, so the
// tab's origin is not the bridge's. Credentials never ride on cookies
// here — the token is explicit — so a permissive CORS layer is safe and
// is bounded by the token check that runs before it.
.layer(CorsLayer::permissive())
.with_state(app_state);
match std::env::var("YAAK_BRIDGE_WEB_DIR").ok() {
// Serving the built frontend makes the bridge a single process to run.
// `index.html` is the fallback because the router owns the paths.
Some(dir) => api.fallback_service(
tower_http::services::ServeDir::new(&dir)
.fallback(tower_http::services::ServeFile::new(format!("{dir}/index.html"))),
),
None => api,
}
}
// -- Auth --
#[derive(Debug, Deserialize)]
struct TokenQuery {
token: Option<String>,
}
/// Dev-grade bearer check on every route.
///
/// The header is the normal path. The query parameter exists because two of
/// these are opened by the browser itself — the WebSocket and the `<img src>`
/// pointing at a response body — and neither lets the page set headers.
///
/// This is the seam where OTP pairing and per-session keys go. It is not one
/// today: the token is a process-lifetime shared secret, and anything that can
/// read the tab's URL can read it.
async fn require_token(
State(state): State<Arc<BridgeState>>,
request: Request,
next: Next,
) -> Response {
let from_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|v| v.to_string());
let from_query = request
.uri()
.query()
.and_then(|q| serde_urlencoded::from_str::<TokenQuery>(q).ok())
.and_then(|q| q.token);
let presented = from_header.or(from_query);
match presented {
Some(token) if constant_time_eq(&token, &state.token) => next.run(request).await,
_ => (StatusCode::UNAUTHORIZED, "Invalid or missing bridge token").into_response(),
}
}
/// Compares without returning early on the first differing byte, so a caller
/// can't learn the token one character at a time.
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
// -- Routes --
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BridgeInfo {
name: String,
version: String,
capabilities: crate::state::BridgeCapabilities,
/// Commands this build implements. The browser host uses it to fail fast
/// with a clear message instead of waiting for a round trip.
commands: Vec<String>,
}
async fn bridge_info(State(app): State<AppState>) -> Json<BridgeInfo> {
Json(BridgeInfo {
name: "Yaak Bridge".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
capabilities: app.state.capabilities.clone(),
commands: crate::rpc::implemented_commands(&app.router),
})
}
/// One envelope in, one out. Errors are carried inside the envelope, not as an
/// HTTP status, so the browser host can reject the caller's promise with the
/// backend's own message.
async fn rpc_handler(
State(app): State<AppState>,
Json(req): Json<RpcRequest>,
) -> Json<RpcResponse> {
let ctx = BridgeCtx { state: app.state.clone(), session: app.state.session.get() };
log::debug!("RPC {}", req.cmd);
let response = app.router.handle(req, &ctx).await;
if let RpcResponse::Error { error, .. } = &response {
log::warn!("RPC failed: {error}");
}
Json(response)
}
async fn events_handler(State(app): State<AppState>, ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(move |socket| handle_events_socket(socket, app))
}
/// The tab's first frame reports who and where it is; everything after that is
/// a reply to something the server asked.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AttachPayload {
label: String,
url: String,
}
async fn handle_events_socket(socket: WebSocket, app: AppState) {
use futures::{SinkExt, StreamExt};
let (mut sink, mut stream) = socket.split();
let mut outbound = app.state.events.subscribe();
// Server to client.
let send_task = tokio::spawn(async move {
loop {
match outbound.recv().await {
Ok(frame) => {
let Ok(text) = serde_json::to_string(&frame) else {
continue;
};
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
// A tab that fell behind has missed writes, and the model store
// would be silently stale. Close instead, so a reconnect
// re-reads the workspace from scratch.
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
log::warn!("Events client lagged by {n} frames; closing so it resyncs");
break;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
// Client to server.
let state = app.state.clone();
let recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = stream.next().await {
let Message::Text(text) = message else {
continue;
};
let Ok(frame) = serde_json::from_str::<EventFrame>(&text) else {
log::warn!("Ignoring malformed event frame from browser");
continue;
};
// `bridge_attach` is the browser telling us what the desktop would
// have read off the window: its label and its current URL.
if frame.event == "bridge_attach" {
match serde_json::from_value::<AttachPayload>(frame.payload.clone()) {
Ok(attach) => {
log::info!("Browser attached: {} at {}", attach.label, attach.url);
state.session.set(SessionContext {
label: attach.label,
url: attach.url,
});
}
Err(e) => log::warn!("Bad bridge_attach payload: {e}"),
}
continue;
}
state.events.dispatch_inbound(frame);
}
});
tokio::select! {
_ = send_task => {},
_ = recv_task => {},
}
}
#[derive(Debug, Deserialize)]
struct BodyQuery {
/// Present so the shared token extractor doesn't reject the request; the
/// value itself is checked in the middleware.
#[allow(dead_code)]
token: Option<String>,
}
/// Stream a response body, with Range support.
///
/// Keyed by response id rather than by path: the tab hands back a `bodyPath`
/// the backend gave it, and resolving that through the database means this
/// route can only ever serve a file the engine wrote, not an arbitrary path a
/// page asked for. Range matters because the video and audio viewers seek.
async fn response_body(
State(app): State<AppState>,
Path(id): Path<String>,
Query(_q): Query<BodyQuery>,
headers: HeaderMap,
) -> Response {
let location = match app.state.locate_response_body(&id) {
Ok(location) => location,
Err(_) => return (StatusCode::NOT_FOUND, "No such response").into_response(),
};
let Some(body_path) = location.path else {
return (StatusCode::NOT_FOUND, "Response has no body").into_response();
};
let mut file = match tokio::fs::File::open(&body_path).await {
Ok(file) => file,
Err(e) => return (StatusCode::NOT_FOUND, format!("Body unavailable: {e}")).into_response(),
};
let total = match file.metadata().await {
Ok(meta) => meta.len(),
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body unreadable: {e}"))
.into_response();
}
};
let content_type = if location.content_type.is_empty() {
"application/octet-stream".to_string()
} else {
location.content_type
};
let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok()).and_then(parse_range);
let (start, end, status) = match range {
Some((start, end)) => {
let end = end.unwrap_or(total.saturating_sub(1)).min(total.saturating_sub(1));
if total == 0 || start > end {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
.body(Body::empty())
.unwrap();
}
(start, end, StatusCode::PARTIAL_CONTENT)
}
None => (0, total.saturating_sub(1), StatusCode::OK),
};
let length = if total == 0 { 0 } else { end - start + 1 };
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to seek body").into_response();
}
let mut buf = vec![0u8; length as usize];
if let Err(e) = file.read_exact(&mut buf).await {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read body: {e}"))
.into_response();
}
let mut builder = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, length);
if status == StatusCode::PARTIAL_CONTENT {
builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"));
}
builder.body(Body::from(buf)).unwrap()
}
/// Parses a single `bytes=start-end` range. Multi-range requests are not
/// answered as multipart; the first range is used, which browsers accept.
fn parse_range(value: &str) -> Option<(u64, Option<u64>)> {
let spec = value.strip_prefix("bytes=")?.split(',').next()?.trim();
let (start, end) = spec.split_once('-')?;
if start.is_empty() {
return None;
}
let start: u64 = start.parse().ok()?;
let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
Some((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ranges() {
assert_eq!(parse_range("bytes=0-499"), Some((0, Some(499))));
assert_eq!(parse_range("bytes=500-"), Some((500, None)));
assert_eq!(parse_range("bytes=0-99,200-299"), Some((0, Some(99))));
// Suffix ranges ("last 500 bytes") aren't supported; callers get the
// whole body, which is correct if wasteful.
assert_eq!(parse_range("bytes=-500"), None);
assert_eq!(parse_range("nonsense"), None);
}
#[test]
fn token_comparison_requires_exact_match() {
assert!(constant_time_eq("abc", "abc"));
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", "abcd"));
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Yaak Bridge — the local companion that runs the real Yaak engine for a
//! browser tab.
//!
//! The tab is the Yaak UI, unchanged. Everything it cannot do in a page —
//! sending an HTTP request and seeing every response header, following
//! redirects, keeping a cookie jar, running plugins, reading a response body
//! off disk — happens in this process, over a local HTTP and WebSocket
//! connection.
//!
//! Loopback only, and every route needs the token printed at startup.
mod events;
mod http;
mod model_writes;
mod plugin_events;
mod rpc;
mod session;
mod state;
use clap::Parser;
use rand::Rng;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
const APP_ID: &str = "app.yaak.bridge";
#[derive(Parser, Debug)]
#[command(name = "yaak-bridge", about = "Run the Yaak engine for a browser tab")]
struct Args {
/// Port to listen on. Loopback only, always.
#[arg(long, default_value_t = 9444, env = "YAAK_BRIDGE_PORT")]
port: u16,
/// Where the database, plugins and response bodies live.
#[arg(long, env = "YAAK_BRIDGE_DATA_DIR")]
data_dir: Option<PathBuf>,
/// Use a fixed token instead of generating one. For scripted dev loops.
#[arg(long, env = "YAAK_BRIDGE_TOKEN")]
token: Option<String>,
/// Where the frontend was built to. Serving it makes this the only process
/// to run; without it, point a Vite dev server at this bridge instead.
#[arg(long, env = "YAAK_BRIDGE_WEB_DIR")]
web_dir: Option<PathBuf>,
}
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let args = Args::parse();
let data_dir = args.data_dir.unwrap_or_else(default_data_dir);
if let Err(e) = std::fs::create_dir_all(&data_dir) {
eprintln!("Error: failed to create data dir {}: {e}", data_dir.display());
std::process::exit(1);
}
if let Some(web_dir) = &args.web_dir {
// Read back by the router; keeping it in the environment avoids
// threading an option through every layer for a dev-mode convenience.
unsafe { std::env::set_var("YAAK_BRIDGE_WEB_DIR", web_dir) };
}
let token = args.token.unwrap_or_else(generate_token);
let is_dev = cfg!(debug_assertions);
let mut state = state::BridgeState::new(data_dir.clone(), APP_ID, token.clone(), is_dev);
state.init_plugins().await;
let state = Arc::new(state);
let router = Arc::new(rpc::build_router());
let app = http::build_app(state.clone(), router);
let addr = SocketAddr::from(([127, 0, 0, 1], args.port));
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => listener,
Err(e) => {
eprintln!("Error: failed to bind {addr}: {e}");
std::process::exit(1);
}
};
let base = format!("http://127.0.0.1:{}", args.port);
println!();
println!(" Yaak Bridge listening on {base}");
println!(" Data dir: {}", data_dir.display());
println!(" Plugins: {}", if state.capabilities.plugins { "running" } else { "unavailable" });
println!();
if std::env::var("YAAK_BRIDGE_WEB_DIR").is_ok() {
println!(" Open: {base}/?bridgeToken={token}");
} else {
println!(" Token: {token}");
println!(" Open your dev server with ?bridgeToken={token}");
}
println!();
let shutdown_state = state.clone();
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
let _ = tokio::signal::ctrl_c().await;
log::info!("Shutting down");
shutdown_state.shutdown().await;
});
if let Err(e) = server.await {
eprintln!("Error: server failed: {e}");
std::process::exit(1);
}
}
fn default_data_dir() -> PathBuf {
dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")).join("yaak-bridge")
}
/// A 256-bit random token, hex encoded. Per process, never written to disk.
fn generate_token() -> String {
let bytes: [u8; 32] = rand::thread_rng().r#gen();
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
@@ -0,0 +1,125 @@
//! Pushing model writes to the connected tab.
//!
//! A direct port of the desktop's two paths (see
//! crates-tauri/yaak-app-client/src/models_ext.rs), and for the same reason:
//! the in-memory channel is the fast path for writes this process made on a
//! client's behalf, while polling the `model_changes` table is what makes an
//! external writer — the CLI, a second bridge, the desktop app open on the same
//! database — show up live in the browser. Keeping both means the browser
//! behaves like the desktop rather than like a cache.
use crate::events::EventHub;
use chrono::Utc;
use log::error;
use std::sync::mpsc::Receiver;
use std::time::Duration;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{ModelPayload, UpdateSource};
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
struct ModelChangeCursor {
created_at: String,
id: i64,
}
impl ModelChangeCursor {
fn from_launch_time() -> Self {
Self {
created_at: Utc::now().naive_utc().format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
id: 0,
}
}
}
pub fn start(query_manager: &QueryManager, rx: Receiver<ModelPayload>, events: EventHub) {
if let Err(err) =
query_manager.connect().prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
{
error!("Failed to prune model_changes rows on startup: {err:?}");
}
// Only stream writes that happen after this process started.
let cursor = ModelChangeCursor::from_launch_time();
let poll_query_manager = query_manager.clone();
let poll_events = events.clone();
tokio::spawn(async move {
run_model_change_poller(poll_query_manager, poll_events, cursor).await;
});
// `init_standalone` hands back a std (blocking) receiver, so it gets a
// thread rather than a task.
std::thread::spawn(move || {
while let Ok(payload) = rx.recv() {
let mut batch: Vec<ModelPayload> = Vec::new();
if matches!(payload.update_source, UpdateSource::Window { .. }) {
batch.push(payload);
}
// Coalesce anything already queued into the same frame.
while let Ok(next) = rx.try_recv() {
if matches!(next.update_source, UpdateSource::Window { .. }) {
batch.push(next);
}
}
if batch.is_empty() {
continue;
}
events.emit("model_writes", &batch);
}
});
}
async fn run_model_change_poller(
query_manager: QueryManager,
events: EventHub,
mut cursor: ModelChangeCursor,
) {
loop {
while drain_model_changes_batch(&query_manager, &events, &mut cursor) {}
tokio::time::sleep(Duration::from_millis(MODEL_CHANGES_POLL_INTERVAL_MS)).await;
}
}
fn drain_model_changes_batch(
query_manager: &QueryManager,
events: &EventHub,
cursor: &mut ModelChangeCursor,
) -> bool {
let changes = match query_manager.connect().list_model_changes_since(
&cursor.created_at,
cursor.id,
MODEL_CHANGES_POLL_BATCH_SIZE,
) {
Ok(changes) => changes,
Err(err) => {
error!("Failed to poll model_changes rows: {err:?}");
return false;
}
};
if changes.is_empty() {
return false;
}
let fetched_count = changes.len();
let mut batch: Vec<ModelPayload> = Vec::with_capacity(fetched_count);
for change in changes {
cursor.created_at = change.created_at;
cursor.id = change.id;
// Window-sourced writes already went out on the in-memory fast path.
if matches!(change.payload.update_source, UpdateSource::Window { .. }) {
continue;
}
batch.push(change.payload);
}
// One batch per drain so bulk writes don't flood the tab.
if !batch.is_empty() {
events.emit("model_writes", &batch);
}
fetched_count == MODEL_CHANGES_POLL_BATCH_SIZE
}
@@ -0,0 +1,582 @@
//! The bridge's plugin host.
//!
//! Same shape as the CLI's bridge (crates-cli/yaak-cli/src/plugin_events.rs):
//! subscribe to the plugin manager, let `handle_shared_plugin_event` answer
//! everything that is only a database question, and implement the rest here.
//!
//! Where it differs is that a UI is attached. The CLI answers a prompt from a
//! TTY and refuses when there isn't one; the bridge does what the desktop does
//! instead — pushes the event to the tab and waits for the reply keyed by the
//! event's id. Toasts, clipboard writes and external URLs go the same way,
//! because the browser is the only thing here that can show or do them.
use crate::events::EventHub;
use crate::session::SessionStore;
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::task::JoinHandle;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak::render::{render_grpc_request, render_http_request};
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::get_cookie_value_from_jar;
use yaak_http::manager::HttpConnectionManager;
use yaak_models::blob_manager::BlobManager;
use yaak_models::models::Environment;
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
EmptyPayload, ErrorResponse, GetCookieValueResponse, InternalEvent, InternalEventPayload,
ListCookieNamesResponse, ListOpenWorkspacesResponse, PluginContext, PromptTextResponse,
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
TemplateRenderResponse, WindowInfoResponse, WorkspaceInfo,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::{RenderOptions, TemplateCallback, render_json_value_raw};
pub struct BridgePluginEventBridge {
rx_id: String,
task: JoinHandle<()>,
}
struct BridgeHostContext {
query_manager: QueryManager,
blob_manager: BlobManager,
plugin_manager: Arc<PluginManager>,
encryption_manager: Arc<EncryptionManager>,
connection_manager: Arc<HttpConnectionManager>,
response_dir: PathBuf,
events: EventHub,
session: SessionStore,
}
impl BridgePluginEventBridge {
#[allow(clippy::too_many_arguments)]
pub async fn start(
plugin_manager: Arc<PluginManager>,
query_manager: QueryManager,
blob_manager: BlobManager,
encryption_manager: Arc<EncryptionManager>,
connection_manager: Arc<HttpConnectionManager>,
data_dir: PathBuf,
events: EventHub,
session: SessionStore,
) -> Self {
let (rx_id, mut rx) = plugin_manager.subscribe("bridge").await;
let rx_id_for_task = rx_id.clone();
let pm = plugin_manager.clone();
let host_context = Arc::new(BridgeHostContext {
query_manager,
blob_manager,
plugin_manager,
encryption_manager,
connection_manager,
response_dir: data_dir.join("responses"),
events,
session,
});
let task = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
// Events with reply IDs are replies to app-originated requests.
if event.reply_id.is_some() {
continue;
}
let Some(plugin_handle) = pm.get_plugin_by_ref_id(&event.plugin_ref_id).await
else {
log::warn!(
"Ignoring plugin event with unknown plugin ref '{}'",
event.plugin_ref_id
);
continue;
};
let pm = pm.clone();
let host_context = host_context.clone();
// Avoid deadlocks for nested plugin-host requests (for example, template functions
// that trigger additional host requests during render) by handling each event in
// its own task.
tokio::spawn(async move {
let plugin_name = plugin_handle.info().name;
let Some(reply_payload) = build_plugin_reply(
host_context.as_ref(),
&event,
&plugin_name,
&plugin_handle,
)
.await
else {
return;
};
if let Err(err) = pm.reply(&event, &reply_payload).await {
log::warn!("Failed replying to plugin event: {err}");
}
});
}
pm.unsubscribe(&rx_id_for_task).await;
});
Self { rx_id, task }
}
pub async fn shutdown(self, plugin_manager: &PluginManager) {
plugin_manager.unsubscribe(&self.rx_id).await;
self.task.abort();
let _ = self.task.await;
}
}
async fn build_plugin_reply(
host_context: &BridgeHostContext,
event: &InternalEvent,
plugin_name: &str,
plugin_handle: &PluginHandle,
) -> Option<InternalEventPayload> {
let session = host_context.session.get();
let shared_workspace_id =
event.context.workspace_id.clone().or_else(|| session.workspace_id());
match handle_shared_plugin_event(
&host_context.query_manager,
&event.payload,
SharedPluginEventContext {
plugin_name,
workspace_id: shared_workspace_id.as_deref(),
},
) {
GroupedPluginEvent::Handled(payload) => payload,
GroupedPluginEvent::ToHandle(host_request) => match host_request {
HostRequest::ErrorResponse(resp) => {
log::warn!("[plugin:{plugin_name}] error: {}", resp.error);
None
}
HostRequest::ReloadResponse(_) => None,
// The tab owns everything the user can see or the OS can do. These
// are fire-and-forget: the plugin gets its acknowledgement as soon
// as the frame is queued, matching the desktop, which also does not
// wait for the webview to paint.
HostRequest::ShowToast(req) => {
host_context.events.emit("show_toast", &req);
Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))
}
HostRequest::CopyText(req) => {
host_context.events.emit("bridge_copy_text", &req);
Some(InternalEventPayload::CopyTextResponse(EmptyPayload {}))
}
HostRequest::OpenExternalUrl(req) => {
host_context.events.emit("bridge_open_url", &req);
Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {}))
}
// Prompts are questions, so they round-trip: the tab renders the
// dialog and emits the answer back under the event's own id.
HostRequest::PromptText(_) => {
let reply = call_frontend(host_context, event).await;
Some(reply.unwrap_or(InternalEventPayload::PromptTextResponse(
PromptTextResponse { value: None },
)))
}
// A form streams: the tab sends a response per interaction and the
// plugin re-renders, until one comes back marked done.
HostRequest::PromptForm(_) => {
host_context.events.emit("plugin_event", event);
if event.reply_id.is_none() {
spawn_form_reply_pump(host_context, event, plugin_handle);
}
None
}
HostRequest::ListOpenWorkspaces(_) => {
let workspaces = match host_context.query_manager.connect().list_workspaces() {
Ok(workspaces) => workspaces
.into_iter()
.map(|w| WorkspaceInfo {
id: w.id.clone(),
name: w.name,
label: session.label.clone(),
})
.collect(),
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list workspaces in bridge: {err}"),
}));
}
};
Some(InternalEventPayload::ListOpenWorkspacesResponse(ListOpenWorkspacesResponse {
workspaces,
}))
}
HostRequest::SendHttpRequest(req) => {
let mut http_request = req.http_request.clone();
if http_request.workspace_id.is_empty() {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to send HTTP requests in bridge"
.to_string(),
}));
};
http_request.workspace_id = workspace_id;
}
let cookie_jar_id = match session.cookie_jar_id() {
Some(id) => Some(id),
None => match host_context
.query_manager
.connect()
.list_cookie_jars(http_request.workspace_id.as_str())
{
Ok(jars) => {
jars.into_iter().min_by_key(|jar| jar.created_at).map(|jar| jar.id)
}
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to list cookie jars in bridge: {err}"),
}));
}
},
};
let plugin_context = PluginContext {
workspace_id: Some(http_request.workspace_id.clone()),
..event.context.clone()
};
match send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
query_manager: &host_context.query_manager,
blob_manager: &host_context.blob_manager,
request: http_request,
environment_id: session.environment_id().as_deref(),
update_source: UpdateSource::Plugin,
cookie_jar_id,
response_dir: &host_context.response_dir,
emit_events_to: None,
emit_response_body_chunks_to: None,
existing_response: None,
plugin_manager: host_context.plugin_manager.clone(),
encryption_manager: host_context.encryption_manager.clone(),
plugin_context: &plugin_context,
cancelled_rx: None,
connection_manager: &host_context.connection_manager,
})
.await
{
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
SendHttpRequestResponse { http_response: result.response },
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to send HTTP request in bridge: {err}"),
})),
}
}
HostRequest::RenderHttpRequest(req) => {
let mut http_request = req.http_request.clone();
if http_request.workspace_id.is_empty() {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to render HTTP requests in bridge"
.to_string(),
}));
};
http_request.workspace_id = workspace_id;
}
let plugin_context = PluginContext {
workspace_id: Some(http_request.workspace_id.clone()),
..event.context.clone()
};
let environment_chain = match host_context.query_manager.connect().resolve_environments(
&http_request.workspace_id,
http_request.folder_id.as_deref(),
session.environment_id().as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in bridge: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
host_context.encryption_manager.clone(),
&plugin_context,
req.purpose.clone(),
);
match render_http_request(
&http_request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
{
Ok(http_request) => Some(InternalEventPayload::RenderHttpRequestResponse(
RenderHttpRequestResponse { http_request },
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to render HTTP request in bridge: {err}"),
})),
}
}
HostRequest::RenderGrpcRequest(req) => {
let mut grpc_request = req.grpc_request.clone();
if grpc_request.workspace_id.is_empty() {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to render gRPC requests in bridge"
.to_string(),
}));
};
grpc_request.workspace_id = workspace_id;
}
let plugin_context = PluginContext {
workspace_id: Some(grpc_request.workspace_id.clone()),
..event.context.clone()
};
let environment_chain = match host_context.query_manager.connect().resolve_environments(
&grpc_request.workspace_id,
grpc_request.folder_id.as_deref(),
session.environment_id().as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in bridge: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
host_context.encryption_manager.clone(),
&plugin_context,
req.purpose.clone(),
);
match render_grpc_request(
&grpc_request,
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
{
Ok(grpc_request) => Some(InternalEventPayload::RenderGrpcRequestResponse(
RenderGrpcRequestResponse { grpc_request },
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to render gRPC request in bridge: {err}"),
})),
}
}
HostRequest::TemplateRender(req) => {
let Some(workspace_id) = shared_workspace_id.clone() else {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: "workspace_id is required to render templates in bridge".to_string(),
}));
};
let plugin_context =
PluginContext { workspace_id: Some(workspace_id.clone()), ..event.context.clone() };
let folder_id = session.request_id().and_then(|rid| {
match host_context.query_manager.connect().get_any_request(&rid) {
Ok(AnyRequest::HttpRequest(r)) => r.folder_id,
Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,
Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,
Err(_) => None,
}
});
let environment_chain = match host_context.query_manager.connect().resolve_environments(
&workspace_id,
folder_id.as_deref(),
session.environment_id().as_deref(),
) {
Ok(chain) => chain,
Err(err) => {
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to resolve environments in bridge: {err}"),
}));
}
};
let template_callback = PluginTemplateCallback::new(
host_context.plugin_manager.clone(),
host_context.encryption_manager.clone(),
&plugin_context,
req.purpose.clone(),
);
match render_json_value(
req.data.clone(),
environment_chain,
&template_callback,
&RenderOptions::throw(),
)
.await
{
Ok(data) => {
Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse {
data,
}))
}
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to render template data in bridge: {err}"),
})),
}
}
HostRequest::ListCookieNames(_) => {
let Some(cookie_jar_id) = session.cookie_jar_id() else {
return Some(InternalEventPayload::ListCookieNamesResponse(
ListCookieNamesResponse { names: Vec::new() },
));
};
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
Ok(jar) => Some(InternalEventPayload::ListCookieNamesResponse(
ListCookieNamesResponse {
names: jar.cookies.into_iter().map(|c| c.name).collect(),
},
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to load cookie jar in bridge: {err}"),
})),
}
}
HostRequest::GetCookieValue(req) => {
let Some(cookie_jar_id) = session.cookie_jar_id() else {
return Some(InternalEventPayload::GetCookieValueResponse(
GetCookieValueResponse { value: None },
));
};
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
Ok(jar) => {
let value =
get_cookie_value_from_jar(jar.cookies, &req.name, req.domain.as_deref());
Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse {
value,
}))
}
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to load cookie jar in bridge: {err}"),
})),
}
}
HostRequest::WindowInfo(req) => {
Some(InternalEventPayload::WindowInfoResponse(WindowInfoResponse {
label: req.label.clone(),
request_id: session.request_id(),
workspace_id: shared_workspace_id.clone(),
environment_id: session.environment_id(),
}))
}
// A tab is one window. Opening and closing them needs the
// multiWindow capability the bridge reports false.
HostRequest::OpenWindow(_) => Some(unsupported("open_window_request")),
HostRequest::CloseWindow(_) => Some(unsupported("close_window_request")),
HostRequest::OtherRequest(payload) => Some(unsupported(&payload.type_name())),
},
}
}
fn unsupported(type_name: &str) -> InternalEventPayload {
InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Unsupported plugin request in bridge: {type_name}"),
})
}
/// Ask the tab and wait for its answer, keyed by the event's id — the same
/// contract as the desktop's `call_frontend`.
async fn call_frontend(
host_context: &BridgeHostContext,
event: &InternalEvent,
) -> Option<InternalEventPayload> {
// Subscribe before emitting: the tab can answer faster than this task is
// rescheduled, and a reply that arrives before the listener exists is lost.
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
host_context.events.emit("plugin_event", event);
let value = replies.recv().await?;
match serde_json::from_value::<InternalEvent>(value) {
Ok(reply) => Some(reply.payload),
Err(e) => {
log::warn!("Failed to parse plugin reply from browser: {e}");
None
}
}
}
/// Forward every form response the tab sends back to the plugin, until one is
/// marked done.
fn spawn_form_reply_pump(
host_context: &BridgeHostContext,
event: &InternalEvent,
plugin_handle: &PluginHandle,
) {
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
let plugin_handle = plugin_handle.clone();
let plugin_context = event.context.clone();
tokio::spawn(async move {
while let Some(value) = replies.recv().await {
let Ok(resp) = serde_json::from_value::<InternalEvent>(value) else {
log::warn!("Failed to parse form response from browser");
continue;
};
let is_done = matches!(
&resp.payload,
InternalEventPayload::PromptFormResponse(r) if r.done.unwrap_or(false)
);
let event_to_send = plugin_handle.build_event_to_send(
&plugin_context,
&resp.payload,
Some(resp.reply_id.unwrap_or_default()),
);
if let Err(e) = plugin_handle.send(&event_to_send).await {
log::warn!("Failed to forward form response to plugin: {e:?}");
}
if is_done {
break;
}
}
});
}
async fn render_json_value<T: TemplateCallback>(
value: Value,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<Value> {
let vars = &make_vars_hashmap(environment_chain);
render_json_value_raw(value, vars, cb, opt).await
}
@@ -0,0 +1,985 @@
//! The implemented commands.
//!
//! Request payloads mirror the desktop's structs in
//! crates-tauri/yaak-app-client/src/rpc_ext.rs field for field, because the
//! frontend is unchanged and sends the same JSON. They are redeclared rather
//! than shared: those live in a Tauri crate this one must not depend on, and
//! they are plain data. The command *bodies* are what matter, and they call the
//! same engine functions the desktop calls.
use super::{BridgeCtx, unsupported_command};
use mime_guess::{Mime, mime};
use std::collections::HashMap;
use std::path::Path;
use std::str::FromStr;
use yaak::import::{ImportDataParams, import_data as import_data_shared};
use yaak::models_ops::{delete_model, duplicate_model, upsert_model};
use yaak::send::{ResponseBody, SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_core::WorkspaceContext;
use yaak_models::models::{
AnyModel, Environment, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader,
HttpResponse, HttpResponseEvent, HttpResponseState, Settings, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::render::make_vars_hashmap;
use yaak_models::queries::workspaces::default_headers;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::events::{
CallFolderActionRequest, CallHttpRequestActionRequest, CallWorkspaceActionRequest,
FilterResponse, GetFolderActionsResponse, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose,
};
use yaak_plugins::native_template_functions::{
decrypt_secure_template_function, encrypt_secure_template_function,
};
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_rpc::{RpcError, RpcRouter, rpc_handler_async};
use yaak_rpc_schema::*;
use yaak_sse::sse::ServerSentEvent;
use yaak_templates::format_json::format_json;
use yaak_templates::{
RenderErrorBehavior, RenderOptions, TemplateCallback, parse_and_render,
render_json_value_raw,
};
type Result<T> = std::result::Result<T, RpcError>;
/// Any engine error becomes an RPC error with its message, matching how the
/// desktop's `rpc` command flattens its error enum before it crosses the wire.
fn err(e: impl std::fmt::Display) -> RpcError {
RpcError { message: e.to_string() }
}
/// Run database work that opens a transaction off the async runtime.
///
/// A `rusqlite` transaction borrows a connection that is neither `Send` nor
/// `Sync`, so a future holding one cannot be spawned. Moving it to a blocking
/// thread satisfies that and is the right shape anyway — these are synchronous
/// disk writes that can cascade.
async fn blocking<T, F>(f: F) -> Result<T>
where
F: FnOnce() -> std::result::Result<T, yaak_models::error::Error> + Send + 'static,
T: Send + 'static,
{
match tokio::task::spawn_blocking(f).await {
Ok(result) => result.map_err(err),
Err(e) => Err(RpcError { message: format!("Database task failed: {e}") }),
}
}
// -- App metadata --
async fn cmd_metadata(ctx: BridgeCtx, _req: CmdMetadataReq) -> Result<AppMetaData> {
let data_dir = ctx.state.data_dir().to_string_lossy().to_string();
Ok(AppMetaData {
is_dev: ctx.state.is_dev,
version: env!("CARGO_PKG_VERSION").to_string(),
cli_version: None,
name: "Yaak Bridge".to_string(),
app_data_dir: data_dir.clone(),
app_log_dir: data_dir.clone(),
vendored_plugin_dir: ctx
.state
.data_dir()
.join("vendored-plugins")
.to_string_lossy()
.to_string(),
default_project_dir: dirs::home_dir()
.map(|d| d.join("YaakProjects"))
.unwrap_or_default()
.to_string_lossy()
.to_string(),
feature_updater: false,
feature_license: false,
})
}
// -- Models --
async fn models_upsert(ctx: BridgeCtx, req: ModelsUpsertReq) -> Result<String> {
let db = ctx.state.db();
upsert_model(&db, ctx.state.blob_manager(), req.model, &ctx.update_source()).map_err(err)
}
/// Deletes run on a blocking thread, as they do on the desktop: a transaction
/// holds a raw sqlite connection, which is neither `Send` nor cheap to hold —
/// dropping a workspace with thousands of requests would otherwise stall the
/// runtime and every other request with it.
async fn models_delete(ctx: BridgeCtx, req: ModelsDeleteReq) -> Result<String> {
let source = ctx.update_source();
blocking(move || {
ctx.state
.query_manager()
.with_tx(|tx| delete_model(tx, ctx.state.blob_manager(), req.model, &source))
})
.await
}
async fn models_duplicate(ctx: BridgeCtx, req: ModelsDuplicateReq) -> Result<String> {
let source = ctx.update_source();
blocking(move || {
ctx.state
.query_manager()
.with_tx(|tx| duplicate_model(tx, &req.model_type, &req.model_id, &source))
})
.await
}
async fn models_get_settings(ctx: BridgeCtx, _req: ModelsGetSettingsReq) -> Result<Settings> {
Ok(ctx.state.db().get_settings())
}
/// Everything the frontend's model store needs for a workspace, as one JSON
/// string.
///
/// The desktop escapes non-ASCII into `\uXXXX` before handing this to the
/// webview; that is a workaround for Tauri's IPC and would only corrupt a
/// perfectly good UTF-8 HTTP response body, so the bridge returns the string as
/// serialized. The frontend `JSON.parse`s either form identically.
async fn models_workspace_models(ctx: BridgeCtx, req: ModelsWorkspaceModelsReq) -> Result<String> {
let mut l: Vec<AnyModel> = Vec::new();
{
let db = ctx.state.db();
l.push(db.get_settings().into());
l.append(&mut db.list_workspaces().map_err(err)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_key_values().map_err(err)?.into_iter().map(Into::into).collect());
}
let plugins = ctx.state.db().list_plugins().map_err(err)?;
if let Some(plugin_manager) = ctx.state.plugin_manager() {
let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;
l.append(&mut plugins.into_iter().map(Into::into).collect());
} else {
l.append(&mut plugins.into_iter().map(Into::into).collect());
}
if let Some(wid) = req.workspace_id.as_deref() {
let db = ctx.state.db();
l.append(&mut db.list_cookie_jars(wid).map_err(err)?.into_iter().map(Into::into).collect());
l.append(
&mut db
.list_environments_ensure_base(wid)
.map_err(err)?
.into_iter()
.map(Into::into)
.collect(),
);
l.append(&mut db.list_folders(wid).map_err(err)?.into_iter().map(Into::into).collect());
l.append(
&mut db.list_grpc_connections(wid).map_err(err)?.into_iter().map(Into::into).collect(),
);
l.append(
&mut db.list_grpc_requests(wid).map_err(err)?.into_iter().map(Into::into).collect(),
);
l.append(
&mut db.list_http_requests(wid).map_err(err)?.into_iter().map(Into::into).collect(),
);
l.append(
&mut db
.list_http_responses(wid, None)
.map_err(err)?
.into_iter()
.map(Into::into)
.collect(),
);
l.append(
&mut db
.list_websocket_connections(wid)
.map_err(err)?
.into_iter()
.map(Into::into)
.collect(),
);
l.append(
&mut db.list_websocket_requests(wid).map_err(err)?.into_iter().map(Into::into).collect(),
);
l.append(
&mut db.list_workspace_metas(wid).map_err(err)?.into_iter().map(Into::into).collect(),
);
}
serde_json::to_string(&l).map_err(err)
}
async fn models_websocket_events(
ctx: BridgeCtx,
req: ModelsWebsocketEventsReq,
) -> Result<Vec<WebsocketEvent>> {
ctx.state.db().list_websocket_events(&req.connection_id).map_err(err)
}
async fn models_grpc_events(ctx: BridgeCtx, req: ModelsGrpcEventsReq) -> Result<Vec<GrpcEvent>> {
ctx.state.db().list_grpc_events(&req.connection_id).map_err(err)
}
async fn models_get_graphql_introspection(
ctx: BridgeCtx,
req: ModelsGetGraphqlIntrospectionReq,
) -> Result<Option<GraphQlIntrospection>> {
Ok(ctx.state.db().get_graphql_introspection(&req.request_id))
}
async fn models_upsert_graphql_introspection(
ctx: BridgeCtx,
req: ModelsUpsertGraphqlIntrospectionReq,
) -> Result<GraphQlIntrospection> {
ctx.state
.db()
.upsert_graphql_introspection(
&req.workspace_id,
&req.request_id,
req.content,
&ctx.update_source(),
)
.map_err(err)
}
async fn cmd_get_workspace_meta(
ctx: BridgeCtx,
req: CmdGetWorkspaceMetaReq,
) -> Result<WorkspaceMeta> {
let db = ctx.state.db();
let workspace = db.get_workspace(&req.workspace_id).map_err(err)?;
db.get_or_create_workspace_meta(&workspace.id).map_err(err)
}
// -- Sending --
/// Send a saved request.
///
/// Same sequence as the desktop (crates-tauri/.../lib.rs `cmd_send_http_request`):
/// create the response row first so the UI has something to show, wire up
/// cancellation, then hand off to the engine. Nothing is streamed back to the
/// tab directly — every state change is a database write, and the model-writes
/// push carries it, which is exactly how the desktop does it too.
async fn cmd_send_http_request(ctx: BridgeCtx, req: CmdSendHttpRequestReq) -> Result<HttpResponse> {
let request = ctx.state.db().get_http_request(&req.request_id).map_err(err)?;
let source = ctx.update_source();
let response = ctx
.state
.db()
.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: request.workspace_id.clone(),
..Default::default()
},
&source,
ctx.state.blob_manager(),
)
.map_err(err)?;
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
let mut cancels =
ctx.state.events.subscribe_inbound(format!("cancel_http_response_{}", response.id));
tokio::spawn(async move {
if cancels.recv().await.is_some() {
let _ = cancel_tx.send(true);
}
});
let result = send_persisted(&ctx, request, response.clone(), &req, cancel_rx).await;
match result {
Ok(response) => Ok(response),
Err(e) => {
// Mirror the desktop: a failure is a closed response carrying the
// error, not a rejected command, so the UI shows it in place.
let existing = ctx.state.db().get_http_response(&response.id).map_err(err)?;
ctx.state
.db()
.upsert_http_response(
&HttpResponse {
state: HttpResponseState::Closed,
error: Some(e.message),
..existing
},
&source,
ctx.state.blob_manager(),
)
.map_err(err)
}
}
}
async fn send_persisted(
ctx: &BridgeCtx,
request: HttpRequest,
response: HttpResponse,
req: &CmdSendHttpRequestReq,
cancel_rx: tokio::sync::watch::Receiver<bool>,
) -> Result<HttpResponse> {
let plugin_manager = ctx.plugins()?;
let response_dir = ctx.state.response_dir();
let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
query_manager: ctx.state.query_manager(),
blob_manager: ctx.state.blob_manager(),
request,
environment_id: req.environment_id.as_deref(),
update_source: ctx.update_source(),
cookie_jar_id: req.cookie_jar_id.clone(),
response_dir: &response_dir,
emit_events_to: None,
emit_response_body_chunks_to: None,
existing_response: Some(response),
plugin_manager,
encryption_manager: ctx.state.encryption_manager.clone(),
plugin_context: &ctx.plugin_context(),
cancelled_rx: Some(cancel_rx),
connection_manager: ctx.state.connection_manager(),
})
.await
.map_err(err)?;
Ok(result.response)
}
/// Send without saving. An empty request id keeps the engine from persisting
/// anything, so the body comes back in memory and rides along with the
/// response — there is no row to look up later and no file to serve.
async fn cmd_send_ephemeral_request(
ctx: BridgeCtx,
req: CmdSendEphemeralRequestReq,
) -> Result<EphemeralHttpResponse> {
let mut request = req.request;
request.id = String::new();
let plugin_manager = ctx.plugins()?;
let response_dir = ctx.state.response_dir();
let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
query_manager: ctx.state.query_manager(),
blob_manager: ctx.state.blob_manager(),
request,
environment_id: req.environment_id.as_deref(),
update_source: ctx.update_source(),
cookie_jar_id: req.cookie_jar_id,
response_dir: &response_dir,
emit_events_to: None,
emit_response_body_chunks_to: None,
existing_response: Some(HttpResponse::default()),
plugin_manager,
encryption_manager: ctx.state.encryption_manager.clone(),
plugin_context: &ctx.plugin_context(),
cancelled_rx: None,
connection_manager: ctx.state.connection_manager(),
})
.await
.map_err(err)?;
// Blanking the request id above is what makes this send unsaved, so the
// engine always hands the body back. Failing loudly beats returning an
// empty body that reads as "the server sent nothing".
let ResponseBody::Returned(body) = result.response_body else {
return Err(RpcError { message: "Unsaved response did not return a body".to_string() });
};
Ok(EphemeralHttpResponse { response: result.response, body })
}
// -- Reading responses --
/// 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.
async fn cmd_http_response_body(
ctx: BridgeCtx,
req: CmdHttpResponseBodyReq,
) -> Result<FilterResponse> {
let location = ctx.state.locate_response_body(&req.response_id).map_err(err)?;
let Some(body_path) = location.path else {
return Ok(FilterResponse { content: String::new(), error: None });
};
let content_type = location.content_type.as_str();
let body = read_response_body(&body_path, content_type)
.await
.ok_or_else(|| RpcError { message: "Failed to find response body".to_string() })?;
match req.filter.as_deref() {
Some(filter) if !filter.is_empty() => ctx
.plugins()?
.filter_data(&ctx.plugin_context(), filter, &body, content_type)
.await
.map_err(err),
_ => Ok(FilterResponse { content: body, error: None }),
}
}
/// The desktop host uses this to open the file itself. A tab cannot open a
/// path, so the bridge's browser host never calls it — it fetches
/// `/responses/:id/body` instead — but the command answers honestly for any
/// client that does, with the path on the bridge's machine.
async fn cmd_http_response_body_path(
ctx: BridgeCtx,
req: CmdHttpResponseBodyPathReq,
) -> Result<Option<String>> {
let location = ctx.state.locate_response_body(&req.response_id).map_err(err)?;
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
}
/// Decode a response body from disk using the charset its Content-Type
/// declares. Ported from crates-tauri/yaak-app-client/src/encoding.rs.
async fn read_response_body(body_path: impl AsRef<Path>, content_type: &str) -> Option<String> {
let body = tokio::fs::read(body_path).await.ok()?;
let body_charset = parse_charset(content_type).unwrap_or_else(|| "utf-8".to_string());
if let Some(decoder) = charset::Charset::for_label(body_charset.as_bytes()) {
let (cow, _real_encoding, _exist_replace) = decoder.decode(&body);
return Some(cow.into_owned());
}
Some(String::from_utf8_lossy(&body).to_string())
}
fn parse_charset(content_type: &str) -> Option<String> {
let mime: Mime = Mime::from_str(content_type).ok()?;
mime.get_param(mime::CHARSET).map(|v| v.to_string())
}
async fn cmd_http_request_body(
ctx: BridgeCtx,
req: CmdHttpRequestBodyReq,
) -> Result<Option<Vec<u8>>> {
let body_id = format!("{}.request", req.response_id);
let chunks = ctx.state.blob_manager().connect().get_chunks(&body_id).map_err(err)?;
if chunks.is_empty() {
return Ok(None);
}
Ok(Some(chunks.into_iter().flat_map(|c| c.data).collect()))
}
async fn cmd_get_http_response_events(
ctx: BridgeCtx,
req: CmdGetHttpResponseEventsReq,
) -> Result<Vec<HttpResponseEvent>> {
ctx.state.db().list_http_response_events(&req.response_id).map_err(err)
}
async fn cmd_get_sse_events(
ctx: BridgeCtx,
req: CmdGetSseEventsReq,
) -> Result<Vec<ServerSentEvent>> {
use eventsource_client::{EventParser, SSE};
let Some(body_path) = ctx.state.locate_response_body(&req.response_id).map_err(err)?.path
else {
return Ok(Vec::new());
};
let body = std::fs::read(&body_path).map_err(err)?;
let mut event_parser = EventParser::new();
event_parser.process_bytes(body).map_err(err)?;
let mut events = Vec::new();
while let Some(e) = event_parser.get_event() {
if let SSE::Event(e) = e {
events.push(ServerSentEvent {
event_type: e.event_type,
data: e.data,
id: e.id,
retry: e.retry,
});
}
}
Ok(events)
}
async fn cmd_delete_all_http_responses(
ctx: BridgeCtx,
req: CmdDeleteAllHttpResponsesReq,
) -> Result<()> {
ctx.state
.db()
.delete_all_http_responses_for_request(&req.request_id, &ctx.update_source())
.map_err(err)?;
Ok(())
}
async fn cmd_delete_send_history(ctx: BridgeCtx, req: CmdDeleteSendHistoryReq) -> Result<()> {
let source = ctx.update_source();
blocking(move || {
let blobs = ctx.state.blob_manager();
let db = ctx.state.db();
for r in db.list_http_responses(&req.workspace_id, None)? {
db.delete_http_response(&r, &source, blobs)?;
}
Ok(())
})
.await
}
// -- Formatting and templates --
async fn cmd_format_json(_ctx: BridgeCtx, req: CmdFormatJsonReq) -> Result<String> {
Ok(format_json(&req.text, " "))
}
async fn cmd_format_graphql(_ctx: BridgeCtx, req: CmdFormatGraphqlReq) -> Result<String> {
match pretty_graphql::format_text(&req.text, &Default::default()) {
Ok(formatted) => Ok(formatted),
Err(_) => Ok(req.text),
}
}
async fn cmd_render_template(ctx: BridgeCtx, req: CmdRenderTemplateReq) -> Result<String> {
let environment_chain = ctx
.state
.db()
.resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())
.map_err(err)?;
let callback = yaak_plugins::template_callback::PluginTemplateCallback::new(
ctx.plugins()?,
ctx.state.encryption_manager.clone(),
&ctx.plugin_context(),
req.purpose.unwrap_or(RenderPurpose::Preview),
);
let options = RenderOptions {
error_behavior: match req.ignore_error {
Some(true) => RenderErrorBehavior::ReturnEmpty,
_ => RenderErrorBehavior::Throw,
},
};
let vars = make_vars_hashmap(environment_chain);
parse_and_render(&req.template, &vars, &callback, &options).await.map_err(err)
}
async fn render_json_value<T: TemplateCallback>(
value: serde_json::Value,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<serde_json::Value> {
let vars = &make_vars_hashmap(environment_chain);
render_json_value_raw(value, vars, cb, opt).await
}
async fn cmd_template_tokens_to_string(
_ctx: BridgeCtx,
req: CmdTemplateTokensToStringReq,
) -> Result<String> {
Ok(req.tokens.to_string())
}
async fn cmd_decrypt_template(ctx: BridgeCtx, req: CmdDecryptTemplateReq) -> Result<String> {
decrypt_secure_template_function(
&ctx.state.encryption_manager,
&ctx.plugin_context(),
&req.template,
)
.map_err(err)
}
async fn cmd_secure_template(ctx: BridgeCtx, req: CmdSecureTemplateReq) -> Result<String> {
encrypt_secure_template_function(
ctx.plugins()?,
ctx.state.encryption_manager.clone(),
&ctx.plugin_context(),
&req.template,
)
.map_err(err)
}
async fn cmd_default_headers(_ctx: BridgeCtx, _req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
Ok(default_headers())
}
// -- Plugins --
async fn cmd_get_themes(ctx: BridgeCtx, _req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
// Themes are optional: the TypeScript package ships defaults, and an empty
// list still renders. Don't fail boot when the runtime is down.
let Ok(plugins) = ctx.plugins() else {
return Ok(Vec::new());
};
plugins.get_themes(&ctx.plugin_context()).await.map_err(err)
}
async fn cmd_plugin_init_errors(ctx: BridgeCtx, _req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
let Ok(plugins) = ctx.plugins() else {
return Ok(Vec::new());
};
Ok(plugins.take_init_errors().await)
}
async fn cmd_plugin_info(ctx: BridgeCtx, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
let plugin = ctx.state.db().get_plugin(&req.id).map_err(err)?;
let plugins = ctx.plugins()?;
let handle = plugins
.get_plugin_by_dir(&plugin.directory)
.await
.ok_or_else(|| RpcError { message: format!("Plugin not found: {}", req.id) })?;
Ok(handle.info())
}
async fn cmd_template_function_summaries(
ctx: BridgeCtx,
_req: CmdTemplateFunctionSummariesReq,
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
ctx.plugins()?.get_template_function_summaries(&ctx.plugin_context()).await.map_err(err)
}
async fn cmd_template_function_config(
ctx: BridgeCtx,
req: CmdTemplateFunctionConfigReq,
) -> Result<GetTemplateFunctionConfigResponse> {
ctx.plugins()?
.get_template_function_config(
&ctx.plugin_context(),
&req.function_name,
req.values,
req.model.id(),
)
.await
.map_err(err)
}
async fn cmd_get_http_authentication_summaries(
ctx: BridgeCtx,
_req: CmdGetHttpAuthenticationSummariesReq,
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
let results =
ctx.plugins()?.get_http_authentication_summaries(&ctx.plugin_context()).await.map_err(err)?;
Ok(results.into_iter().map(|(_, a)| a).collect())
}
async fn cmd_get_http_authentication_config(
ctx: BridgeCtx,
req: CmdGetHttpAuthenticationConfigReq,
) -> Result<GetHttpAuthenticationConfigResponse> {
let rendered_values =
render_auth_values(&ctx, &req.model, req.environment_id.as_deref(), &req.values).await?;
ctx.plugins()?
.get_http_authentication_config(
&ctx.plugin_context(),
&req.auth_name,
rendered_values,
req.model.id(),
)
.await
.map_err(err)
}
async fn cmd_call_http_authentication_action(
ctx: BridgeCtx,
req: CmdCallHttpAuthenticationActionReq,
) -> Result<()> {
let rendered_values =
render_auth_values(&ctx, &req.model, req.environment_id.as_deref(), &req.values).await?;
ctx.plugins()?
.call_http_authentication_action(
&ctx.plugin_context(),
&req.auth_name,
req.action_index,
rendered_values,
req.model.id(),
)
.await
.map_err(err)
}
/// Auth config values are templates, so they are rendered against the model's
/// environment chain before the plugin sees them.
async fn render_auth_values(
ctx: &BridgeCtx,
model: &AnyModel,
environment_id: Option<&str>,
values: &HashMap<String, JsonPrimitive>,
) -> 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),
_ => {
return Err(RpcError {
message: "Unsupported model type for authentication config".to_string(),
});
}
};
let environment_chain = ctx
.state
.db()
.resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)
.map_err(err)?;
let callback = yaak_plugins::template_callback::PluginTemplateCallback::new(
ctx.plugins()?,
ctx.state.encryption_manager.clone(),
&ctx.plugin_context(),
RenderPurpose::Preview,
);
let values_json = serde_json::to_value(values).map_err(err)?;
let rendered_json =
render_json_value(values_json, environment_chain, &callback, &RenderOptions::return_empty())
.await
.map_err(err)?;
serde_json::from_value(rendered_json).map_err(err)
}
// -- Plugin actions --
async fn cmd_http_request_actions(
ctx: BridgeCtx,
_req: CmdHttpRequestActionsReq,
) -> Result<Vec<GetHttpRequestActionsResponse>> {
ctx.plugins()?.get_http_request_actions(&ctx.plugin_context()).await.map_err(err)
}
async fn cmd_call_http_request_action(
ctx: BridgeCtx,
req: CmdCallHttpRequestActionReq,
) -> Result<()> {
use yaak_plugins::events::CallHttpRequestActionArgs;
// Resolve inherited auth and headers before handing the request to the
// plugin, so an action sees what a send would see. Scoped so the database
// connection is released before the plugin call awaits.
let http_request = {
let db = ctx.state.db();
let mut http_request = req.req.args.http_request.clone();
let (authentication_type, authentication, _) =
db.resolve_auth_for_http_request(&http_request).map_err(err)?;
http_request.authentication_type = authentication_type;
http_request.authentication = authentication;
http_request.headers = db.resolve_headers_for_http_request(&http_request).map_err(err)?;
http_request
};
ctx.plugins()?
.call_http_request_action(
&ctx.plugin_context(),
CallHttpRequestActionRequest {
args: CallHttpRequestActionArgs { http_request },
..req.req
},
)
.await
.map_err(err)
}
async fn cmd_workspace_actions(
ctx: BridgeCtx,
_req: CmdWorkspaceActionsReq,
) -> Result<Vec<GetWorkspaceActionsResponse>> {
ctx.plugins()?.get_workspace_actions(&ctx.plugin_context()).await.map_err(err)
}
async fn cmd_call_workspace_action(ctx: BridgeCtx, req: CmdCallWorkspaceActionReq) -> Result<()> {
use yaak_plugins::events::CallWorkspaceActionArgs;
let workspace = ctx.state.db().get_workspace(&req.req.args.workspace.id).map_err(err)?;
ctx.plugins()?
.call_workspace_action(
&ctx.plugin_context(),
CallWorkspaceActionRequest { args: CallWorkspaceActionArgs { workspace }, ..req.req },
)
.await
.map_err(err)
}
async fn cmd_folder_actions(ctx: BridgeCtx, _req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
ctx.plugins()?.get_folder_actions(&ctx.plugin_context()).await.map_err(err)
}
async fn cmd_call_folder_action(ctx: BridgeCtx, req: CmdCallFolderActionReq) -> Result<()> {
use yaak_plugins::events::CallFolderActionArgs;
let folder = ctx.state.db().get_folder(&req.req.args.folder.id).map_err(err)?;
ctx.plugins()?
.call_folder_action(
&ctx.plugin_context(),
CallFolderActionRequest { args: CallFolderActionArgs { folder }, ..req.req },
)
.await
.map_err(err)
}
// -- Import --
async fn cmd_curl_to_request(ctx: BridgeCtx, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
let import_result =
ctx.plugins()?.import_data(&ctx.plugin_context(), &req.command).await.map_err(err)?;
let r = import_result
.resources
.http_requests
.first()
.ok_or_else(|| RpcError { message: "No curl command found".to_string() })?;
let mut request = r.clone();
request.workspace_id = req.workspace_id;
request.id = String::new();
Ok(request)
}
/// Import from a path on the *bridge's* machine.
///
/// The desktop gets this path from a native file dialog. A tab has no way to
/// produce one, so in practice this only works for a path typed by hand — which
/// is why `localFiles` is reported false. Kept registered because the command
/// itself works, and a future upload route can reuse it.
async fn cmd_import_data(ctx: BridgeCtx, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
let contents = std::fs::read_to_string(&req.file_path).map_err(|e| RpcError {
message: format!("Unable to read import file {}: {e}", req.file_path),
})?;
let plugins = ctx.plugins()?;
import_data_shared(ImportDataParams {
query_manager: ctx.state.query_manager(),
plugin_manager: &plugins,
plugin_context: &ctx.plugin_context(),
workspace_context: WorkspaceContext {
workspace_id: ctx.session.workspace_id(),
environment_id: ctx.session.environment_id(),
cookie_jar_id: ctx.session.cookie_jar_id(),
request_id: None,
},
contents: &contents,
})
.await
.map_err(err)
}
// -- Not on this host --
/// Commands the bridge does not implement. Each still gets an adapter, so the
/// schema stays fully covered and the frontend receives a structured error
/// naming the command and this host rather than a bare "unknown command".
///
/// One list, two uses: `unsupported_commands!` emits both the adapters and the
/// `UNSUPPORTED_COMMANDS` array `implemented_commands` subtracts.
macro_rules! unsupported_commands {
( $( $name:ident ( $req:ty ) ),* $(,)? ) => {
// The stub never produces a value, so it doesn't need to name the
// response type — which keeps git, gRPC and WebSocket crates out of a
// binary that will never call them. `Never` serializes fine.
$( async fn $name(_ctx: BridgeCtx, _req: $req) -> Result<Never> {
Err(unsupported_command(stringify!($name)))
} )*
pub const UNSUPPORTED_COMMANDS: &[&str] = &[ $( stringify!($name), )* ];
};
}
/// A value that cannot exist. The unsupported adapters return `Result<Never>`
/// and always take the `Err` branch, so `rpc_handler_async!` has something
/// serializable to name without a real response type ever being constructed.
#[derive(serde::Serialize)]
enum Never {}
unsupported_commands! {
// Multi-window. A tab is one window; Settings opens through this on the desktop and is therefore unreachable in the browser today.
cmd_new_child_window(CmdNewChildWindowReq),
cmd_new_main_window(CmdNewMainWindowReq),
// gRPC and WebSocket sending.
cmd_grpc_reflect(CmdGrpcReflectReq),
cmd_grpc_go(CmdGrpcGoReq),
cmd_grpc_request_actions(CmdGrpcRequestActionsReq),
cmd_call_grpc_request_action(CmdCallGrpcRequestActionReq),
cmd_delete_all_grpc_connections(CmdDeleteAllGrpcConnectionsReq),
cmd_ws_connect(CmdWsConnectReq),
cmd_ws_send(CmdWsSendReq),
cmd_ws_close(CmdWsCloseReq),
cmd_ws_delete_connections(CmdWsDeleteConnectionsReq),
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq),
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq),
// Git-backed workspaces.
cmd_git_checkout(CmdGitCheckoutReq),
cmd_git_branch(CmdGitBranchReq),
cmd_git_delete_branch(CmdGitDeleteBranchReq),
cmd_git_delete_remote_branch(CmdGitDeleteRemoteBranchReq),
cmd_git_merge_branch(CmdGitMergeBranchReq),
cmd_git_rename_branch(CmdGitRenameBranchReq),
cmd_git_status(CmdGitStatusReq),
cmd_git_branch_info(CmdGitBranchInfoReq),
cmd_git_worktree_status(CmdGitWorktreeStatusReq),
cmd_git_log(CmdGitLogReq),
cmd_git_log_for_file(CmdGitLogForFileReq),
cmd_git_file_diff_for_commit(CmdGitFileDiffForCommitReq),
cmd_git_initialize(CmdGitInitializeReq),
cmd_git_clone(CmdGitCloneReq),
cmd_git_commit(CmdGitCommitReq),
cmd_git_fetch_all(CmdGitFetchAllReq),
cmd_git_push(CmdGitPushReq),
cmd_git_pull(CmdGitPullReq),
cmd_git_pull_force_reset(CmdGitPullForceResetReq),
cmd_git_pull_merge(CmdGitPullMergeReq),
cmd_git_add(CmdGitAddReq),
cmd_git_unstage(CmdGitUnstageReq),
cmd_git_reset_changes(CmdGitResetChangesReq),
cmd_git_restore_files(CmdGitRestoreFilesReq),
cmd_git_restore_file_from_commit(CmdGitRestoreFileFromCommitReq),
cmd_git_add_credential(CmdGitAddCredentialReq),
cmd_git_remotes(CmdGitRemotesReq),
cmd_git_add_remote(CmdGitAddRemoteReq),
cmd_git_rm_remote(CmdGitRmRemoteReq),
cmd_git_watch_worktree_status(CmdGitWatchWorktreeStatusReq),
// Filesystem sync.
cmd_sync_calculate(CmdSyncCalculateReq),
cmd_sync_calculate_fs(CmdSyncCalculateFsReq),
cmd_sync_apply(CmdSyncApplyReq),
cmd_sync_watch(CmdSyncWatchReq),
// Workspace encryption.
cmd_enable_encryption(CmdEnableEncryptionReq),
cmd_disable_encryption(CmdDisableEncryptionReq),
cmd_reveal_workspace_key(CmdRevealWorkspaceKeyReq),
cmd_set_workspace_key(CmdSetWorkspaceKeyReq),
// Things that need a local filesystem the tab can point at.
cmd_export_data(CmdExportDataReq),
cmd_save_response(CmdSaveResponseReq),
cmd_save_base64_to_binary(CmdSaveBase64ToBinaryReq),
cmd_plugins_install_from_directory(CmdPluginsInstallFromDirectoryReq),
cmd_import_url(CmdImportUrlReq),
// Desktop application management.
cmd_restart(CmdRestartReq),
cmd_check_for_updates(CmdCheckForUpdatesReq),
cmd_dismiss_notification(CmdDismissNotificationReq),
cmd_send_feedback(CmdSendFeedbackReq),
cmd_plugins_search(CmdPluginsSearchReq),
cmd_plugins_install(CmdPluginsInstallReq),
cmd_plugins_uninstall(CmdPluginsUninstallReq),
cmd_plugins_updates(CmdPluginsUpdatesReq),
cmd_plugins_update_all(CmdPluginsUpdateAllReq),
cmd_reload_plugins(CmdReloadPluginsReq),
}
// -- The router --
/// Every command in the schema, wired to an adapter here.
///
/// The list comes from `yaak_rpc_schema`, so this host cannot silently miss a
/// command the frontend knows about: a schema entry with no adapter below is a
/// compile error, not a runtime "unknown command". Commands the bridge does not
/// support still get an adapter — one that says so — which is what lets the
/// frontend tell a host that will never do git from one that is out of date.
macro_rules! register_commands {
( $( $name:ident ( $req:ty ) -> $res:ty ),* $(,)? ) => {
pub fn build_router() -> RpcRouter<BridgeCtx> {
let mut router = RpcRouter::new();
$( router.register(stringify!($name), rpc_handler_async!($name)); )*
router
}
};
}
yaak_rpc_schema::with_commands!(register_commands);
/// The names of the commands this host actually implements — everything in
/// the schema minus the ones whose adapter is `unsupported`. Reported to the
/// browser so it can fail fast with a clear message.
pub fn implemented_commands(router: &RpcRouter<BridgeCtx>) -> Vec<String> {
let unsupported: std::collections::HashSet<&str> =
UNSUPPORTED_COMMANDS.iter().copied().collect();
let mut names: Vec<String> = router
.commands()
.into_iter()
.filter(|c| !unsupported.contains(c))
.map(|c| c.to_string())
.collect();
names.sort();
names
}
+63
View File
@@ -0,0 +1,63 @@
//! The bridge's RPC surface.
//!
//! Same envelope, same command names, same request and response types as the
//! desktop — all of that comes from `yaak_rpc_schema` — dispatched through the
//! same `RpcRouter`. Only the adapters differ: the desktop's take a Tauri
//! window and read the workspace off its URL, while these take a `BridgeCtx`
//! carrying the connected tab's reported URL. The bodies underneath call the
//! same engine functions in `yaak`, `yaak-models` and `yaak-plugins`.
//!
//! The router is built from the schema's full command list, so every command
//! the frontend knows has an adapter here — the ones this host doesn't
//! implement return a structured error naming the command and the host, and
//! the frontend surfaces "not supported by the Yaak Bridge" instead of a bare
//! failure. Enough is implemented to boot, edit, send and inspect.
mod commands;
pub use commands::implemented_commands;
use crate::session::SessionContext;
use crate::state::BridgeState;
use std::sync::Arc;
use yaak_plugins::events::PluginContext;
use yaak_rpc::{RpcError, RpcRouter};
/// Per-call context. The tab's identity and location, plus the engine.
///
/// Mirrors the desktop's `ClientCtx { window }`: the window there answers both
/// "who is calling" and "what are they looking at", and those are exactly the
/// two things a bridge call needs that the payload doesn't carry.
#[derive(Clone)]
pub struct BridgeCtx {
pub state: Arc<BridgeState>,
pub session: SessionContext,
}
impl BridgeCtx {
pub fn plugin_context(&self) -> PluginContext {
PluginContext::new(Some(self.session.label.clone()), self.session.workspace_id())
}
pub fn update_source(&self) -> yaak_models::util::UpdateSource {
yaak_models::util::UpdateSource::from_window_label(&self.session.label)
}
/// The plugin runtime, or an error naming the reason it isn't there.
pub fn plugins(&self) -> Result<Arc<yaak_plugins::manager::PluginManager>, RpcError> {
self.state.plugin_manager().ok_or_else(|| RpcError {
message: "The plugin runtime failed to start, so this command is unavailable"
.to_string(),
})
}
}
pub fn build_router() -> RpcRouter<BridgeCtx> {
commands::build_router()
}
pub fn unsupported_command(cmd: &str) -> RpcError {
RpcError {
message: format!("`{cmd}` is not supported on this host (Yaak Bridge)"),
}
}
+140
View File
@@ -0,0 +1,140 @@
//! What the connected tab is currently looking at.
//!
//! The desktop reads workspace, environment, cookie jar and request straight off
//! the window's URL (crates-tauri/yaak-tauri-utils/src/window.rs). A browser tab
//! runs the same router and so has the same URL, but the server cannot see it —
//! so the tab reports it, on connect and whenever it changes, and the same
//! parsing happens here.
//!
//! One session for the whole process: this slice serves a single tab. A second
//! tab overwrites the first's context rather than getting its own.
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, Default)]
pub struct SessionContext {
/// Identifies the tab, and lands in `UpdateSource::Window { label }` so
/// model-write echo suppression works exactly as it does on the desktop.
pub label: String,
pub url: String,
}
impl SessionContext {
pub fn workspace_id(&self) -> Option<String> {
let rest = self.url.split("/workspaces/").nth(1)?;
let id: String =
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
if id.is_empty() { None } else { Some(id) }
}
pub fn request_id(&self) -> Option<String> {
let rest = self.url.split("/requests/").nth(1)?;
let id: String =
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
if id.is_empty() { None } else { Some(id) }
}
pub fn environment_id(&self) -> Option<String> {
self.query_param("environment_id")
}
pub fn cookie_jar_id(&self) -> Option<String> {
self.query_param("cookie_jar_id")
}
fn query_param(&self, key: &str) -> Option<String> {
let query = self.url.split('?').nth(1)?;
let value = query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
if k != key {
return None;
}
Some(percent_decode(v))
})?;
// The router writes `environment_id=null` when nothing is selected.
// Neither of these is an id, and treating them as one sends a lookup
// for a model that cannot exist.
if value.is_empty() || value == "null" || value == "undefined" {
return None;
}
Some(value)
}
}
fn percent_decode(input: &str) -> String {
let bytes = input.replace('+', " ").into_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).to_string()
}
#[derive(Clone, Default)]
pub struct SessionStore {
inner: Arc<RwLock<SessionContext>>,
}
impl SessionStore {
pub fn get(&self) -> SessionContext {
match self.inner.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
pub fn set(&self, context: SessionContext) {
let mut guard = match self.inner.write() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
*guard = context;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(url: &str) -> SessionContext {
SessionContext { label: "tab".into(), url: url.into() }
}
#[test]
fn parses_ids_from_a_router_url() {
let c = ctx(
"http://localhost:1472/workspaces/wk_abc123/requests/rq_def456?environment_id=ev_1&cookie_jar_id=cj_2",
);
assert_eq!(c.workspace_id().as_deref(), Some("wk_abc123"));
assert_eq!(c.request_id().as_deref(), Some("rq_def456"));
assert_eq!(c.environment_id().as_deref(), Some("ev_1"));
assert_eq!(c.cookie_jar_id().as_deref(), Some("cj_2"));
}
#[test]
fn placeholder_query_values_are_not_ids() {
let c = ctx("http://localhost:1472/workspaces/wk_a?environment_id=null&cookie_jar_id=");
assert_eq!(c.environment_id(), None);
assert_eq!(c.cookie_jar_id(), None);
}
#[test]
fn missing_parts_are_none() {
let c = ctx("http://localhost:1472/");
assert_eq!(c.workspace_id(), None);
assert_eq!(c.request_id(), None);
assert_eq!(c.environment_id(), None);
assert_eq!(c.cookie_jar_id(), None);
}
}
+274
View File
@@ -0,0 +1,274 @@
//! The bridge's engine handles, shared by every route.
//!
//! Structurally this is `CliContext` (crates-cli/yaak-cli/src/context.rs) with
//! an event hub bolted on: the same `init_standalone` database, the same
//! `PluginManager` over the same Node sidecar. What differs is that a browser
//! tab is attached, so writes have to be pushed out as they happen instead of
//! the process exiting when a command finishes.
use crate::events::EventHub;
use crate::plugin_events::BridgePluginEventBridge;
use crate::session::SessionStore;
use include_dir::{Dir, include_dir};
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::manager::HttpConnectionManager;
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::query_manager::QueryManager;
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
));
static EMBEDDED_VENDORED_PLUGINS: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app-client/vendored/plugins");
/// What this host can do, mirroring `PlatformCapabilities` in
/// packages/platform/src/types.ts.
///
/// Reported to the browser rather than hardcoded there, because the honest
/// answer depends on how the bridge was built — these become cargo features as
/// the surface grows, and the tab should not have to guess.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeCapabilities {
pub grpc: bool,
pub websocket: bool,
pub git: bool,
pub sync: bool,
pub tls_options: bool,
pub cookie_jar: bool,
pub local_files: bool,
pub timeline: bool,
pub multi_window: bool,
pub plugins: bool,
pub encryption: bool,
pub updater: bool,
pub clipboard_read: bool,
pub system_fonts: bool,
pub license: bool,
}
impl BridgeCapabilities {
/// The first slice: real HTTP sending with full fidelity, real plugins, a
/// real cookie jar and timeline. Everything the bridge has no route for is
/// reported false so the UI hides it rather than calling and failing.
fn for_this_build(plugins: bool) -> Self {
Self {
grpc: false,
websocket: false,
git: false,
sync: false,
// The engine does the TLS, so client certs and custom CAs are real.
tls_options: true,
cookie_jar: true,
// The bridge has a filesystem but the tab has no way to pick a path
// on it: there is no dialog implementation on this host.
local_files: false,
timeline: true,
multi_window: false,
plugins,
encryption: false,
updater: false,
clipboard_read: false,
system_fonts: false,
license: false,
}
}
}
/// 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,
}
pub struct BridgeState {
data_dir: PathBuf,
query_manager: QueryManager,
blob_manager: BlobManager,
pub encryption_manager: Arc<EncryptionManager>,
connection_manager: Arc<HttpConnectionManager>,
plugin_manager: Option<Arc<PluginManager>>,
plugin_event_bridge: Mutex<Option<BridgePluginEventBridge>>,
pub events: EventHub,
pub session: SessionStore,
pub capabilities: BridgeCapabilities,
/// Dev-grade shared secret, minted per process. The seam where OTP pairing
/// and per-session keys will go; deliberately not persisted.
pub token: String,
pub is_dev: bool,
}
impl BridgeState {
pub fn new(data_dir: PathBuf, app_id: &str, token: String, is_dev: bool) -> Self {
let db_path = data_dir.join("db.sqlite");
let blob_path = data_dir.join("blobs.sqlite");
let (query_manager, blob_manager, rx) =
match yaak_models::init_standalone(&db_path, &blob_path) {
Ok(v) => v,
Err(err) => {
eprintln!("Error: Failed to initialize database: {err}");
std::process::exit(1);
}
};
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
let events = EventHub::new();
// A Settings row has to exist before the frontend's first render — the
// singular model atom throws without one. `get_settings` upserts a
// default when it finds nothing, so touching it here is enough.
let _ = query_manager.connect().get_settings();
crate::model_writes::start(&query_manager, rx, events.clone());
Self {
data_dir,
query_manager,
blob_manager,
encryption_manager,
connection_manager: Arc::new(HttpConnectionManager::new()),
plugin_manager: None,
plugin_event_bridge: Mutex::new(None),
events,
session: SessionStore::default(),
capabilities: BridgeCapabilities::for_this_build(false),
token,
is_dev,
}
}
/// Start the Node plugin runtime and the host-request bridge. Mirrors
/// `CliContext::init_plugins`; a failure here is survivable, but sending
/// loses auth and template functions, so the capability flips off.
pub async fn init_plugins(&mut self) {
let vendored_plugin_dir = self.data_dir.join("vendored-plugins");
let installed_plugin_dir = self.data_dir.join("installed-plugins");
let node_bin_path = PathBuf::from("node");
prepare_embedded_vendored_plugins(&vendored_plugin_dir)
.expect("Failed to prepare bundled plugins");
let plugin_runtime_main =
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
prepare_embedded_plugin_runtime(&self.data_dir)
.expect("Failed to prepare embedded plugin runtime")
});
match PluginManager::new(
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
&self.query_manager,
&PluginContext::new_empty(),
false,
)
.await
{
Ok(plugin_manager) => {
let plugin_manager = Arc::new(plugin_manager);
let plugin_event_bridge = BridgePluginEventBridge::start(
plugin_manager.clone(),
self.query_manager.clone(),
self.blob_manager.clone(),
self.encryption_manager.clone(),
self.connection_manager.clone(),
self.data_dir.clone(),
self.events.clone(),
self.session.clone(),
)
.await;
self.plugin_manager = Some(plugin_manager);
*self.plugin_event_bridge.lock().await = Some(plugin_event_bridge);
self.capabilities.plugins = true;
}
Err(err) => {
log::warn!("Failed to initialize plugins: {err}");
self.capabilities.plugins = false;
}
}
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
pub fn response_dir(&self) -> PathBuf {
self.data_dir.join("responses")
}
/// Find a response's body from its id alone.
///
/// The tab hands back an id and never a path, so the only bodies reachable
/// through the bridge are ones the engine wrote and the database still
/// knows about. Every route and command that reads a body goes through
/// here for that reason.
pub fn locate_response_body(
&self,
response_id: &str,
) -> yaak_models::error::Result<ResponseBodyLocation> {
let response = self.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 fn db(&self) -> ClientDb<'_> {
self.query_manager.connect()
}
pub fn query_manager(&self) -> &QueryManager {
&self.query_manager
}
pub fn blob_manager(&self) -> &BlobManager {
&self.blob_manager
}
pub fn connection_manager(&self) -> &HttpConnectionManager {
&self.connection_manager
}
pub fn plugin_manager(&self) -> Option<Arc<PluginManager>> {
self.plugin_manager.clone()
}
pub async fn shutdown(&self) {
if let Some(plugin_manager) = &self.plugin_manager {
if let Some(plugin_event_bridge) = self.plugin_event_bridge.lock().await.take() {
plugin_event_bridge.shutdown(plugin_manager).await;
}
plugin_manager.terminate().await;
}
}
}
fn prepare_embedded_plugin_runtime(data_dir: &Path) -> std::io::Result<PathBuf> {
let runtime_dir = data_dir.join("vendored").join("plugin-runtime");
fs::create_dir_all(&runtime_dir)?;
let runtime_main = runtime_dir.join("index.cjs");
fs::write(&runtime_main, EMBEDDED_PLUGIN_RUNTIME)?;
Ok(runtime_main)
}
fn prepare_embedded_vendored_plugins(vendored_plugin_dir: &Path) -> std::io::Result<()> {
fs::create_dir_all(vendored_plugin_dir)?;
EMBEDDED_VENDORED_PLUGINS.extract(vendored_plugin_dir)?;
Ok(())
}
+1 -2
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.32"
r2d2_sqlite = "0.25.0"
mime_guess = "2.0.5"
rand = "0.9.0"
reqwest = { workspace = true, features = [
@@ -80,7 +80,6 @@ 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 }
+81 -1
View File
@@ -1,8 +1,51 @@
use crate::PluginContextExt;
use crate::error::Result;
use tauri::{Runtime, State, WebviewWindow};
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>,
@@ -10,3 +53,40 @@ pub(crate) async fn cmd_get_themes<R: Runtime>(
) -> 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,9 +41,6 @@ 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),
+195 -5
View File
@@ -14,7 +14,7 @@ use error::Result as YaakResult;
use eventsource_client::{EventParser, SSE};
use log::{debug, error, info, warn};
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
@@ -29,8 +29,8 @@ 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_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
@@ -38,7 +38,8 @@ use yaak_grpc::{Code, ServiceDefinition};
use yaak_mac_window::AppHandleMacWindowExt;
use yaak_models::models::{
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
WorkspaceMeta,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_plugins::events::{
@@ -53,10 +54,12 @@ use yaak_plugins::events::{
InternalEventPayload, JsonPrimitive, 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_tls::find_client_certificate;
@@ -1008,6 +1011,10 @@ 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),
@@ -1015,13 +1022,44 @@ 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.db(), response_id)?;
let location = locate_response_body(window.app_handle(), response_id)?;
let Some(body_path) = location.path else {
return Ok(FilterResponse { content: String::new(), error: None });
};
@@ -1039,11 +1077,41 @@ 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.db(), response_id)?.path else {
let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else {
return Ok(Vec::new());
};
@@ -1066,6 +1134,14 @@ 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,
@@ -1358,6 +1434,22 @@ async fn cmd_curl_to_request<R: Runtime>(
})?)
}
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.
///
/// The webview can't do this itself: its `fs` permissions are read-only and scoped to the app
@@ -1381,6 +1473,20 @@ 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>,
@@ -1464,6 +1570,90 @@ async fn cmd_reload_plugins<R: Runtime>(
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>,
url: &str,
@@ -12,8 +12,10 @@ 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;
@@ -121,12 +123,163 @@ 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,8 +7,6 @@ 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;
@@ -18,7 +16,6 @@ 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};
@@ -57,7 +54,6 @@ 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,
@@ -317,13 +313,8 @@ 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,6 +194,12 @@ 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> {
+37 -145
View File
@@ -8,13 +8,10 @@
//! is the only way the frontend reaches any of it — one envelope,
//! `{ cmd, payload }`, exactly like the proxy app.
//!
//! 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.
//! 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.
use crate::error::Result;
use crate::notifications::YaakNotifier;
@@ -22,10 +19,7 @@ use crate::updates::YaakUpdater;
use log::warn;
use serde::Serialize;
use tauri::{Manager, Runtime, State, WebviewWindow};
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,
@@ -33,12 +27,10 @@ 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::query_manager::QueryManager;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::events::{
FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
@@ -49,13 +41,11 @@ 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::plugin_meta::PluginMetadata;
use yaak_rpc::RpcRouter;
use yaak_rpc_schema::*;
use yaak_sse::sse::ServerSentEvent;
use yaak_sync::sync::SyncOp;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_ws::WebsocketManager;
/// Per-call context: the window a command was invoked from.
@@ -75,65 +65,6 @@ 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()
}
}
/// 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 manager = self.window.state::<PluginManager>();
let handle = manager.get_plugin_by_dir(directory).await?;
Some(handle.info())
}
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
self.window.state::<PluginManager>().take_init_errors().await
}
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
self.window.state::<PluginManager>().resolve_plugins_for_runtime_from_db(plugins).await
}
async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> {
let plugin_manager = Arc::new((*self.window.state::<PluginManager>()).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]
@@ -258,8 +189,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(yaak_commands::data::cmd_format_json(ctx, req).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_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphqlReq) -> Result<String> {
@@ -271,11 +202,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(yaak_commands::responses::cmd_http_response_body_path(ctx, req).await?)
Ok(crate::cmd_http_response_body_path(ctx.window.app_handle().clone(), &req.response_id).await?)
}
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
Ok(yaak_commands::responses::cmd_http_request_body(ctx, req).await?)
Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?)
}
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
@@ -283,7 +214,7 @@ 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(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
Ok(crate::cmd_get_http_response_events(ctx.window.app_handle().clone(), &req.response_id).await?)
}
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
@@ -359,7 +290,7 @@ async fn cmd_curl_to_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdCurlToReques
}
async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -> Result<()> {
Ok(yaak_commands::data::cmd_export_data(ctx, req).await?)
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?)
}
async fn cmd_save_base64_to_binary<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveBase64ToBinaryReq) -> Result<()> {
@@ -367,7 +298,7 @@ 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(yaak_commands::responses::cmd_save_response(ctx, req).await?)
Ok(crate::cmd_save_response(ctx.window.app_handle().clone(), &req.response_id, &req.filepath).await?)
}
async fn cmd_send_http_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendHttpRequestReq) -> Result<HttpResponse> {
@@ -379,23 +310,23 @@ async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, _req: CmdReloadPlugin
}
async fn cmd_plugin_info<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
Ok(yaak_commands::plugins::cmd_plugin_info(ctx, req).await?)
Ok(crate::cmd_plugin_info(&req.id, ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
}
async fn cmd_delete_all_grpc_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllGrpcConnectionsReq) -> Result<()> {
Ok(yaak_commands::models::cmd_delete_all_grpc_connections(ctx, req).await?)
Ok(crate::cmd_delete_all_grpc_connections(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
}
async fn cmd_delete_send_history<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteSendHistoryReq) -> Result<()> {
Ok(yaak_commands::models::cmd_delete_send_history(ctx, req).await?)
Ok(crate::cmd_delete_send_history(&req.workspace_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
}
async fn cmd_delete_all_http_responses<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllHttpResponsesReq) -> Result<()> {
Ok(yaak_commands::models::cmd_delete_all_http_responses(ctx, req).await?)
Ok(crate::cmd_delete_all_http_responses(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
}
async fn cmd_get_workspace_meta<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetWorkspaceMetaReq) -> Result<WorkspaceMeta> {
Ok(yaak_commands::models::cmd_get_workspace_meta(ctx, req).await?)
Ok(crate::cmd_get_workspace_meta(ctx.window.app_handle().clone(), &req.workspace_id).await?)
}
async fn cmd_new_child_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewChildWindowReq) -> Result<()> {
@@ -411,11 +342,11 @@ 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(yaak_commands::encryption::cmd_decrypt_template(ctx, req).await?)
Ok(crate::commands::cmd_decrypt_template(ctx.window.clone(), &req.template).await?)
}
async fn cmd_secure_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdSecureTemplateReq) -> Result<String> {
Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?)
Ok(crate::commands::cmd_secure_template(ctx.window.app_handle().clone(), ctx.window.clone(), &req.template).await?)
}
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
@@ -423,98 +354,59 @@ async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetThemesReq) ->
}
async fn cmd_enable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdEnableEncryptionReq) -> Result<()> {
Ok(yaak_commands::encryption::cmd_enable_encryption(ctx, req).await?)
Ok(crate::commands::cmd_enable_encryption(ctx.window.clone(), &req.workspace_id).await?)
}
async fn cmd_reveal_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdRevealWorkspaceKeyReq) -> Result<String> {
Ok(yaak_commands::encryption::cmd_reveal_workspace_key(ctx, req).await?)
Ok(crate::commands::cmd_reveal_workspace_key(ctx.window.clone(), &req.workspace_id).await?)
}
async fn cmd_set_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdSetWorkspaceKeyReq) -> Result<()> {
Ok(yaak_commands::encryption::cmd_set_workspace_key(ctx, req).await?)
Ok(crate::commands::cmd_set_workspace_key(ctx.window.clone(), &req.workspace_id, &req.key).await?)
}
async fn cmd_disable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdDisableEncryptionReq) -> Result<()> {
Ok(yaak_commands::encryption::cmd_disable_encryption(ctx, req).await?)
Ok(crate::commands::cmd_disable_encryption(ctx.window.clone(), &req.workspace_id).await?)
}
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 cmd_default_headers<R: Runtime>(_ctx: ClientCtx<R>, _req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
Ok(crate::commands::cmd_default_headers())
}
async fn models_upsert<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertReq) -> Result<String> {
Ok(yaak_commands::models::models_upsert(ctx, req).await?)
Ok(crate::models_ext::models_upsert(ctx.window.clone(), req.model)?)
}
/// 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> {
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?)
Ok(crate::models_ext::models_delete(ctx.window.clone(), req.model).await?)
}
async fn models_duplicate<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq) -> Result<String> {
Ok(yaak_commands::models::models_duplicate(ctx, req).await?)
Ok(crate::models_ext::models_duplicate(ctx.window.clone(), req.model_type, req.model_id)?)
}
async fn models_websocket_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWebsocketEventsReq) -> Result<Vec<WebsocketEvent>> {
Ok(yaak_commands::models::models_websocket_events(ctx, req).await?)
Ok(crate::models_ext::models_websocket_events(ctx.window.app_handle().clone(), &req.connection_id)?)
}
async fn models_grpc_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGrpcEventsReq) -> Result<Vec<GrpcEvent>> {
Ok(yaak_commands::models::models_grpc_events(ctx, req).await?)
Ok(crate::models_ext::models_grpc_events(ctx.window.app_handle().clone(), &req.connection_id)?)
}
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_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_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetGraphqlIntrospectionReq) -> Result<Option<GraphQlIntrospection>> {
Ok(yaak_commands::models::models_get_graphql_introspection(ctx, req).await?)
Ok(crate::models_ext::models_get_graphql_introspection(ctx.window.app_handle().clone(), &req.request_id)?)
}
async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertGraphqlIntrospectionReq) -> Result<GraphQlIntrospection> {
Ok(yaak_commands::models::models_upsert_graphql_introspection(ctx, req).await?)
Ok(crate::models_ext::models_upsert_graphql_introspection(ctx.window.app_handle().clone(), &req.request_id, &req.workspace_id, req.content, ctx.window.clone())?)
}
/// 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> {
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()
Ok(crate::models_ext::models_workspace_models(ctx.window.clone(), req.workspace_id.as_deref(), ctx.window.app_handle().state::<PluginManager>()).await?)
}
async fn cmd_git_checkout<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitCheckoutReq) -> Result<String> {
@@ -646,7 +538,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(yaak_commands::models::cmd_ws_delete_connections(ctx, req).await?)
Ok(crate::ws_ext::cmd_ws_delete_connections(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
}
async fn cmd_ws_send<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsSendReq) -> Result<WebsocketConnection> {
@@ -677,8 +569,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(yaak_commands::plugins::cmd_plugin_init_errors(ctx, req).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_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdatesReq) -> Result<PluginUpdatesResponse> {
@@ -29,6 +29,17 @@ use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
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>,
-1
View File
@@ -7,4 +7,3 @@ publish = false
[dependencies]
tauri = { workspace = true }
regex = "1.11.0"
yaak-core = { workspace = true }
+15 -30
View File
@@ -1,53 +1,38 @@
use regex::Regex;
use tauri::{Runtime, Url, WebviewWindow};
use yaak_core::WorkspaceContext;
use tauri::{Runtime, WebviewWindow};
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> {
workspace_id_from_url(&self.url().unwrap())
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()),
}
}
fn cookie_jar_id(&self) -> Option<String> {
query_param(&self.url().unwrap(), "cookie_jar_id")
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())
}
fn environment_id(&self) -> Option<String> {
query_param(&self.url().unwrap(), "environment_id")
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())
}
fn request_id(&self) -> Option<String> {
query_param(&self.url().unwrap(), "request_id")
}
fn workspace_context(&self) -> WorkspaceContext {
let url = self.url().unwrap();
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"),
}
let mut query_pairs = url.query_pairs();
query_pairs.find(|(k, _v)| k == "request_id").map(|(_k, v)| v.to_string())
}
}
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())
}
+5 -13
View File
@@ -9,20 +9,12 @@ chrono = { version = "0.4.38", features = ["serde"] }
include_dir = "0.7"
log = { workspace = true }
nanoid = "0.4.0"
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"] }
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"] }
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,8 +1,9 @@
use crate::pool::SqliteConn;
use r2d2::PooledConnection;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{Connection, Statement, ToSql, Transaction};
pub enum ConnectionOrTx<'a> {
Connection(SqliteConn),
Connection(PooledConnection<SqliteConnectionManager>),
Transaction(&'a Transaction<'a>),
}
@@ -3,7 +3,6 @@ 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] crate::pool::PoolError),
SqlPoolError(#[from] r2d2::Error),
#[error("Database error: {0}")]
Database(String),
+2 -5
View File
@@ -2,7 +2,6 @@ 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;
@@ -12,15 +11,13 @@ 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 types that consumers will need
#[cfg(not(target_arch = "wasm32"))]
// Re-export pool types that consumers will need
pub use r2d2;
#[cfg(not(target_arch = "wasm32"))]
pub use r2d2_sqlite;
pub use rusqlite;
pub use sea_query;
pub use sea_query_rusqlite;
+3 -2
View File
@@ -1,7 +1,8 @@
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";
@@ -10,7 +11,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: &SqlitePool, dir: &Dir<'_>) -> Result<()> {
pub fn run_migrations(pool: &Pool<SqliteConnectionManager>, dir: &Dir<'_>) -> Result<()> {
info!("Running migrations");
// Create tracking table
-80
View File
@@ -1,80 +0,0 @@
//! 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::*;
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "yaak-commands"
version = "0.0.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
log = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["rt"] }
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"] }
-23
View File
@@ -1,23 +0,0 @@
//! 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
@@ -1,41 +0,0 @@
//! 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
@@ -1,30 +0,0 @@
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>;
-118
View File
@@ -1,118 +0,0 @@
//! 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::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::PluginContext;
use yaak_plugins::plugin_meta::PluginMetadata;
/// 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>>;
/// 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>>;
}
-23
View File
@@ -1,23 +0,0 @@
//! 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 data;
pub mod encryption;
pub mod error;
pub mod host;
pub mod models;
pub mod plugins;
pub mod responses;
pub use error::{Error, Result};
pub use host::{Host, PluginHost};
-179
View File
@@ -1,179 +0,0 @@
//! 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
@@ -1,48 +0,0 @@
//! 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)
}
-86
View File
@@ -1,86 +0,0 @@
//! 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(())
}
-237
View File
@@ -1,237 +0,0 @@
//! 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::rc::Rc;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
use yaak_commands::models::{
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
models_workspace_models,
};
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, Plugin, Workspace};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{ModelPayload, UpdateSource};
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_rpc_schema::{
CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, ModelsDeleteReq, ModelsUpsertReq,
ModelsWorkspaceModelsReq,
};
#[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>,
}
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
}
}
/// 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()))
}
}
#[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")) };
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");
// 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);
}
+19
View File
@@ -1,3 +1,5 @@
use std::path::PathBuf;
/// Context for a workspace operation.
///
/// In Tauri, this is extracted from the WebviewWindow URL.
@@ -35,3 +37,20 @@ 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::WorkspaceContext;
pub use context::{AppContext, WorkspaceContext};
pub use error::{Error, Result};
+3 -12
View File
@@ -4,9 +4,7 @@ use log::{debug, info, warn};
use reqwest::{Client, ClientBuilder, Proxy, redirect};
use std::sync::{Arc, Mutex};
use yaak_models::models::DnsOverride;
use yaak_tls::{
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
};
use yaak_tls::{ClientCertificateConfig, get_tls_config, load_client_identity_pkcs12};
pub const HTTP2_MAX_RESPONSE_HEADER_LIST_SIZE: u32 = 1024 * 1024;
@@ -63,19 +61,12 @@ static IDENTITY_IMPORT: Mutex<()> = Mutex::new(());
fn build_native_tls_identity(
client_cert: Option<ClientCertificateConfig>,
) -> Result<Option<native_tls::Identity>> {
let Some(material) = load_native_client_identity(client_cert)? else {
let Some((pkcs12, password)) = load_client_identity_pkcs12(client_cert)? else {
return Ok(None);
};
let _guard = IDENTITY_IMPORT.lock().unwrap_or_else(|e| e.into_inner());
Ok(Some(match material {
NativeClientIdentity::Pkcs12 { data, password } => {
native_tls::Identity::from_pkcs12(&data, &password)?
}
NativeClientIdentity::Pkcs8 { chain_pem, key_pem } => {
native_tls::Identity::from_pkcs8(&chain_pem, &key_pem)?
}
}))
Ok(Some(native_tls::Identity::from_pkcs12(&pkcs12, &password)?))
}
#[derive(Clone)]
+5 -7
View File
@@ -11,9 +11,11 @@ hex = { workspace = true }
include_dir = "0.7"
log = { workspace = true }
nanoid = "0.4.0"
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"] }
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"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
schemars = { workspace = true }
@@ -21,7 +23,3 @@ 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" }
+9 -8
View File
@@ -2,8 +2,9 @@ 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");
@@ -28,11 +29,11 @@ impl BodyChunk {
// whole app whenever the pool is exhausted.
#[derive(Debug, Clone)]
pub struct BlobManager {
pool: SqlitePool,
pool: Pool<SqliteConnectionManager>,
}
impl BlobManager {
pub fn new(pool: SqlitePool) -> Self {
pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {
Self { pool }
}
@@ -44,7 +45,7 @@ impl BlobManager {
/// Context for blob database operations.
pub struct BlobContext {
conn: SqliteConn,
conn: r2d2::PooledConnection<SqliteConnectionManager>,
}
impl BlobContext {
@@ -130,7 +131,7 @@ impl BlobContext {
}
/// Run migrations for the blob database.
pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
pub fn migrate_blob_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
info!("Running blob database migrations");
// Create migrations tracking table
@@ -197,9 +198,9 @@ pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
mod tests {
use super::*;
fn create_test_pool() -> SqlitePool {
let manager = r2d2_sqlite::SqliteConnectionManager::memory();
let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap();
fn create_test_pool() -> Pool<SqliteConnectionManager> {
let manager = SqliteConnectionManager::memory();
let pool = 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] yaak_database::PoolError),
SqlPoolError(#[from] r2d2::Error),
#[error("Database error: {0}")]
Database(String),
+55 -79
View File
@@ -1,12 +1,15 @@
use crate::blob_manager::{BlobManager, migrate_blob_db};
use crate::error::Result;
use crate::error::{Error, Result};
use crate::migrate::migrate_db;
use crate::query_manager::QueryManager;
use crate::util::ModelPayload;
use log::info;
use std::path::Path;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::fs::create_dir_all;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use yaak_database::SqlitePool;
use std::time::Duration;
pub mod blob_manager;
pub mod client_db;
@@ -14,85 +17,22 @@ 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;
/// 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_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))
})
}
#[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))
}
fn sqlite_memory_manager() -> SqliteConnectionManager {
SqliteConnectionManager::memory()
.with_init(|conn| conn.busy_timeout(Duration::from_millis(5000)))
}
/// Initialize the database managers for standalone (non-Tauri) usage.
@@ -106,16 +46,40 @@ 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 pool = open::file_pool(db_path, 20, 2)?;
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()))?;
migrate_db(&pool)?;
info!("Initializing blobs database {blob_path:?}");
let blob_pool = open::file_pool(blob_path, 10, 1)?;
// 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()))?;
migrate_blob_db(&blob_pool)?;
let (tx, rx) = mpsc::channel();
@@ -128,10 +92,22 @@ 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>)> {
let pool = open::memory_pool()?;
// 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()))?;
migrate_db(&pool)?;
let blob_pool = open::memory_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()))?;
migrate_blob_db(&blob_pool)?;
let (tx, rx) = mpsc::channel();
+8 -24
View File
@@ -2,13 +2,14 @@ use crate::error::Error::MigrationError;
use crate::error::Result;
use include_dir::{Dir, DirEntry, include_dir};
use log::{debug, info};
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, 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: &SqlitePool) -> Result<()> {
pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
info!("Running database migrations");
// Ensure the table exists
@@ -42,10 +43,8 @@ pub fn migrate_db(pool: &SqlitePool) -> Result<()> {
let mut ran_migrations = 0;
for entry in entries {
num_migrations += 1;
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)?;
let mut conn = pool.get()?;
let mut tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
match run_migration(entry, &mut tx) {
Ok(ran) => {
if ran {
@@ -75,7 +74,7 @@ pub fn migrate_db(pool: &SqlitePool) -> Result<()> {
}
fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> Result<bool> {
let start = elapsed_timer();
let start = std::time::Instant::now();
let (version, description) = split_migration_filename(migration_path.path().to_str().unwrap())
.expect("Failed to parse migration filename");
@@ -98,7 +97,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();
let execution_time = start.elapsed().as_nanos() as i64;
let checksum = sha384_hex_prefixed(sql.as_bytes());
// NOTE: The success column is never used. It's just there for sqlx compatibility.
@@ -110,21 +109,6 @@ 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")?;
@@ -3,7 +3,6 @@ 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,7 +4,6 @@ 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,7 +5,6 @@ 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,7 +4,6 @@ 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,7 +1,6 @@
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,7 +2,6 @@ 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,7 +4,6 @@ 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;
+9 -8
View File
@@ -1,21 +1,23 @@
use crate::client_db::ClientDb;
use crate::error::Error::GenericError;
use crate::util::ModelPayload;
use rusqlite::{Transaction, TransactionBehavior};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::TransactionBehavior;
use std::sync::mpsc;
use yaak_database::{ConnectionOrTx, DbContext, SqlitePool};
use yaak_database::{ConnectionOrTx, DbContext};
// 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: SqlitePool,
pool: Pool<SqliteConnectionManager>,
events_tx: mpsc::Sender<ModelPayload>,
}
impl QueryManager {
pub fn new(pool: SqlitePool, events_tx: mpsc::Sender<ModelPayload>) -> Self {
pub fn new(pool: Pool<SqliteConnectionManager>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
QueryManager { pool, events_tx }
}
@@ -44,10 +46,9 @@ impl QueryManager {
where
E: From<crate::error::Error>,
{
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)
let mut conn = self.pool.get().expect("Failed to get new DB connection from the pool");
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.expect("Failed to start DB transaction");
let ctx = DbContext::new(ConnectionOrTx::Transaction(&tx));
File diff suppressed because one or more lines are too long
-71
View File
@@ -171,12 +171,6 @@ pub enum InternalEventPayload {
FindHttpResponsesRequest(FindHttpResponsesRequest),
FindHttpResponsesResponse(FindHttpResponsesResponse),
GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest),
GetHttpResponseBodyInfoResponse(GetHttpResponseBodyInfoResponse),
ReadHttpResponseBodyChunkRequest(ReadHttpResponseBodyChunkRequest),
ReadHttpResponseBodyChunkResponse(ReadHttpResponseBodyChunkResponse),
ListHttpRequestsRequest(ListHttpRequestsRequest),
ListHttpRequestsResponse(ListHttpRequestsResponse),
ListFoldersRequest(ListFoldersRequest),
@@ -294,15 +288,6 @@ 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)]
@@ -1428,62 +1413,6 @@ 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 actually stored, which is not necessarily what the
/// `Content-Length` header claimed. Zero when the response has no body.
#[ts(type = "number")]
pub content_length: u64,
/// 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")]
-1
View File
@@ -7,7 +7,6 @@ publish = false
[dependencies]
log = { workspace = true }
p12 = "0.6.3"
pem = "3"
rustls = { workspace = true, default-features = false, features = ["ring"] }
rustls-pemfile = "2"
rustls-platform-verifier = { workspace = true }
+11 -129
View File
@@ -18,7 +18,7 @@ pub mod error;
const OID_RSA_ENCRYPTION: &[u64] = &[1, 2, 840, 113549, 1, 1, 1];
const OID_EC_PUBLIC_KEY: &[u64] = &[1, 2, 840, 10045, 2, 1];
/// Password for the PKCS#12 blob [`load_native_client_identity`] builds from PEM
/// Password for the PKCS#12 blob [`load_client_identity_pkcs12`] builds from PEM
/// files. The blob never leaves the process, so the value only has to agree with
/// the caller that immediately re-parses it.
const IN_MEMORY_PKCS12_PASSWORD: &str = "yaak";
@@ -107,33 +107,16 @@ fn load_client_cert(
Ok(None)
}
/// A client identity in one of the encodings a native TLS stack accepts.
pub enum NativeClientIdentity {
/// A PKCS#12 archive, with the password needed to open it.
Pkcs12 { data: Vec<u8>, password: String },
/// A PEM certificate chain, leaf first, with a PKCS#8 PEM private key.
Pkcs8 {
chain_pem: Vec<u8>,
key_pem: Vec<u8>,
},
}
/// Whether the platform's native TLS stack should be handed PEM material as
/// PKCS#12 rather than PKCS#8.
/// Load the configured client certificate as PKCS#12 DER, along with the
/// password needed to open it.
///
/// Both encodings lose something. PKCS#8 is rejected for EC keys by Security
/// Framework on macOS and by SChannel on Windows, which imports keys through an
/// RSA-only provider. PKCS#12 as the `p12` crate emits it is encrypted with
/// SHA1/40-bit-RC2 (certificates) and SHA1/3DES (key), and OpenSSL 3 moved RC2
/// into the legacy provider, so on Linux it fails to decrypt what we just
/// wrote. Each platform therefore gets the encoding its own stack can read.
const NATIVE_TLS_WANTS_PKCS12: bool = cfg!(any(target_vendor = "apple", target_os = "windows"));
/// Load the configured client certificate in whichever encoding this platform's
/// native TLS stack accepts.
pub fn load_native_client_identity(
/// Native TLS stacks accept a client identity as either PKCS#12 or a PKCS#8
/// PEM, and the PKCS#8 route rejects EC keys on macOS outright. Going through
/// PKCS#12 keeps the key formats we accept identical to the rustls path, which
/// reads PKCS#1 and SEC1 keys directly.
pub fn load_client_identity_pkcs12(
client_cert: Option<ClientCertificateConfig>,
) -> Result<Option<NativeClientIdentity>> {
) -> Result<Option<(Vec<u8>, String)>> {
let config = match client_cert {
None => return Ok(None),
Some(c) => c,
@@ -144,10 +127,7 @@ pub fn load_native_client_identity(
if let Some(pfx_path) = &config.pfx_file {
if !pfx_path.is_empty() {
let data = fs::read(Path::new(pfx_path))?;
return Ok(Some(NativeClientIdentity::Pkcs12 {
data,
password: config.passphrase.clone().unwrap_or_default(),
}));
return Ok(Some((data, config.passphrase.clone().unwrap_or_default())));
}
}
@@ -156,35 +136,13 @@ pub fn load_native_client_identity(
};
let key_der = to_pkcs8_der(&key)?;
if !NATIVE_TLS_WANTS_PKCS12 {
return Ok(Some(to_pkcs8_identity(&certs, &key_der)));
}
let (leaf, cas) = certs.split_first().ok_or(GenericError("No certificates found".into()))?;
let cas: Vec<&[u8]> = cas.iter().map(|c| c.as_ref()).collect();
let pfx = p12::PFX::new_with_cas(leaf, &key_der, &cas, IN_MEMORY_PKCS12_PASSWORD, "yaak")
.ok_or(GenericError("Failed to build PKCS#12 from client certificate".into()))?;
Ok(Some(NativeClientIdentity::Pkcs12 {
data: pfx.to_der(),
password: IN_MEMORY_PKCS12_PASSWORD.to_string(),
}))
}
/// Re-encode a certificate chain and PKCS#8 key as the PEM pair native-tls
/// expects. It only recognises a key whose first line is the PKCS#8 header, so
/// the key has to arrive already converted by [`to_pkcs8_der`].
fn to_pkcs8_identity(certs: &[CertificateDer<'static>], key_der: &[u8]) -> NativeClientIdentity {
let config = pem::EncodeConfig::new().set_line_ending(pem::LineEnding::LF);
let chain: Vec<pem::Pem> =
certs.iter().map(|c| pem::Pem::new("CERTIFICATE", c.as_ref())).collect();
NativeClientIdentity::Pkcs8 {
chain_pem: pem::encode_many_config(&chain, config).into_bytes(),
key_pem: pem::encode_config(&pem::Pem::new("PRIVATE KEY", key_der), config).into_bytes(),
}
Ok(Some((pfx.to_der(), IN_MEMORY_PKCS12_PASSWORD.to_string())))
}
/// Re-encode a private key as PKCS#8 DER, wrapping PKCS#1 and SEC1 keys.
@@ -421,79 +379,3 @@ pub fn find_client_certificate(
None
}
#[cfg(test)]
mod pkcs8_identity_tests {
use super::*;
const EC_CRT: &str = r#"-----BEGIN CERTIFICATE-----
MIIBhTCCASugAwIBAgIUB8703dqXCUOJQbhbyaMUMbVFOjwwCgYIKoZIzj0EAwIw
FzEVMBMGA1UEAwwMeWFhay10ZXN0LWVjMCAXDTI2MDgxNDIwNDYyNFoYDzIxMjYw
NzIxMjA0NjI0WjAXMRUwEwYDVQQDDAx5YWFrLXRlc3QtZWMwWTATBgcqhkjOPQIB
BggqhkjOPQMBBwNCAATCYYKhzgHEaRaGsYVjJSoXvoroL8qe1yeEA0VtfxFzMBg+
+bkPQ0nCtMyFfvQQtXWYIakxzsWJyhI8wPjUj6QSo1MwUTAdBgNVHQ4EFgQUKq40
Hl+2DziVkBVR/tGsPj9FRo0wHwYDVR0jBBgwFoAUKq40Hl+2DziVkBVR/tGsPj9F
Ro0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAj1dx5XLl9iCZ
rD0CW+a3RTluxQ5icXno9WJ9qaS6L08CIFx2t0y9znQr7n5x+SmfXbfZtkDola8e
8nEZga/HXSeu
-----END CERTIFICATE-----"#;
const EC_SEC1_KEY: &str = r#"-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIoiiZ/hb4h6eHkZUVBTQFz7KLrVKJqQtWee2ygOjijNoAoGCCqGSM49
AwEHoUQDQgAEwmGCoc4BxGkWhrGFYyUqF76K6C/KntcnhANFbX8RczAYPvm5D0NJ
wrTMhX70ELV1mCGpMc7FicoSPMD41I+kEg==
-----END EC PRIVATE KEY-----"#;
const EC_PKCS8_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgiiKJn+FviHp4eRlR
UFNAXPsoutUompC1Z57bKA6OKM2hRANCAATCYYKhzgHEaRaGsYVjJSoXvoroL8qe
1yeEA0VtfxFzMBg++bkPQ0nCtMyFfvQQtXWYIakxzsWJyhI8wPjUj6QS
-----END PRIVATE KEY-----"#;
fn pkcs8_identity(crt: &str, key: &str) -> (Vec<u8>, Vec<u8>) {
let certs: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut BufReader::new(crt.as_bytes()))
.map(|c| c.unwrap())
.collect();
let key_der = to_pkcs8_der(&load_private_key(key.as_bytes()).unwrap()).unwrap();
match to_pkcs8_identity(&certs, &key_der) {
NativeClientIdentity::Pkcs8 { chain_pem, key_pem } => (chain_pem, key_pem),
NativeClientIdentity::Pkcs12 { .. } => unreachable!("asked for PKCS#8"),
}
}
/// native-tls matches the PKCS#8 header as a literal prefix and rejects the
/// key outright when it does not line up, so pin it on every platform even
/// though only the OpenSSL backend is handed this encoding.
#[test]
fn every_key_format_re_encodes_to_a_pkcs8_pem() {
for (name, key) in [("SEC1", EC_SEC1_KEY), ("PKCS#8", EC_PKCS8_KEY)] {
let (chain_pem, key_pem) = pkcs8_identity(EC_CRT, key);
assert!(
key_pem.starts_with(b"-----BEGIN PRIVATE KEY-----\n"),
"{name} key did not re-encode to a PKCS#8 PEM"
);
let round_tripped: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut BufReader::new(chain_pem.as_slice()))
.map(|c| c.unwrap())
.collect();
let original: Vec<CertificateDer<'static>> =
rustls_pemfile::certs(&mut BufReader::new(EC_CRT.as_bytes()))
.map(|c| c.unwrap())
.collect();
assert_eq!(round_tripped, original, "{name} chain did not round-trip");
}
}
/// The two on-disk spellings of one EC key have to converge, because only
/// the PKCS#8 one survives the re-encode.
#[test]
fn sec1_and_pkcs8_spellings_of_one_key_agree() {
let (_, from_sec1) = pkcs8_identity(EC_CRT, EC_SEC1_KEY);
let (_, from_pkcs8) = pkcs8_identity(EC_CRT, EC_PKCS8_KEY);
assert_eq!(from_sec1, from_pkcs8);
}
}
-37
View File
@@ -1,37 +0,0 @@
[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
@@ -1,79 +0,0 @@
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
@@ -1,6 +0,0 @@
// 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
@@ -1,15 +0,0 @@
{
"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
@@ -1,17 +0,0 @@
{
"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/*"
]
}
-36
View File
@@ -1,36 +0,0 @@
/* 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` — that
* crate itself pulls the git, gRPC and plugin crates for their response types
* and cannot come to wasm, so the handful needed here are declared locally.
*/
export function rpc(cmd: string, payload: any, label: string): any;
-6
View File
@@ -1,6 +0,0 @@
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();
-975
View File
@@ -1,975 +0,0 @@
/**
* @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` — that
* crate itself pulls the git, gRPC and plugin crates for their response types
* and cannot come to wasm, so the handful needed here are declared locally.
* @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__ha579407f9663b071);
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__ha579407f9663b071(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__ha579407f9663b071(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
@@ -1,32 +0,0 @@
/* 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__ha579407f9663b071: (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
@@ -1,356 +0,0 @@
//! 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))
}
-1
View File
@@ -6,7 +6,6 @@ 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 }
+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;
@@ -7,12 +7,12 @@
//! `UpdateSource` identifying who is writing; nothing here knows whether the
//! caller is a desktop window or an HTTP request.
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;
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;
pub fn upsert_model(
db: &ClientDb,
+16 -172
View File
@@ -1,6 +1,3 @@
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;
@@ -8,14 +5,12 @@ use yaak_plugins::events::{
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
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,
GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,
ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,
ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,
PromptFormRequest, PromptTextRequest, ReloadResponse, RenderGrpcRequestRequest,
RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,
TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,
};
pub struct SharedPluginEventContext<'a> {
@@ -45,8 +40,6 @@ 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),
}
@@ -143,12 +136,6 @@ 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))
}
@@ -195,17 +182,13 @@ 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,
body_store,
req,
context,
))),
GroupedPluginRequest::Shared(req) => {
GroupedPluginEvent::Handled(Some(build_shared_reply(query_manager, req, context)))
}
GroupedPluginRequest::Host(req) => GroupedPluginEvent::ToHandle(req),
GroupedPluginRequest::Ignore => GroupedPluginEvent::Handled(None),
}
@@ -213,7 +196,6 @@ pub fn handle_shared_plugin_event<'a>(
fn build_shared_reply(
query_manager: &QueryManager,
body_store: &dyn ResponseBodyStore,
request: SharedRequest<'_>,
context: SharedPluginEventContext<'_>,
) -> InternalEventPayload {
@@ -301,30 +283,6 @@ 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,
},
),
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::*;
@@ -479,26 +437,10 @@ 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");
@@ -556,7 +498,7 @@ mod tests {
let payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
);
let result = dispatch(
let result = handle_shared_plugin_event(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
@@ -575,7 +517,7 @@ mod tests {
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
);
let by_workspace = dispatch(
let by_workspace = handle_shared_plugin_event(
&query_manager,
&by_workspace_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -594,7 +536,7 @@ mod tests {
folder_id: Some("fl_test".to_string()),
},
);
let by_folder = dispatch(
let by_folder = handle_shared_plugin_event(
&query_manager,
&by_folder_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
@@ -617,7 +559,7 @@ mod tests {
limit: Some(1),
});
let result = dispatch(
let result = handle_shared_plugin_event(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -633,104 +575,6 @@ 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()),
})
}
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();
@@ -746,7 +590,7 @@ mod tests {
}),
});
let upsert_result = dispatch(
let upsert_result = handle_shared_plugin_event(
&query_manager,
&upsert_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -765,7 +609,7 @@ mod tests {
model: "http_request".to_string(),
id: "rq_test".to_string(),
});
let delete_result = dispatch(
let delete_result = handle_shared_plugin_event(
&query_manager,
&delete_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
@@ -787,7 +631,7 @@ mod tests {
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
label: "main".to_string(),
});
let result = dispatch(
let result = handle_shared_plugin_event(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
-210
View File
@@ -1,210 +0,0 @@
//! Reading response bodies back out, by response id.
//!
//! Plugins only ever name a response. Where its bytes actually live — files the
//! engine wrote under `<data dir>/responses/<id>` today, blob rows later — is
//! behind [`ResponseBodyStore`], so moving the bytes is a change to this file
//! and nothing a plugin can see.
//!
//! Only saved responses are reachable by id. A send that saved nothing hands
//! its body back with the reply instead, which is the only copy of it there is.
use crate::error::Result;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use yaak_models::query_manager::QueryManager;
/// The most bytes one read will hand back, however much was asked for.
///
/// A chunk is buffered whole and, on the desktop transport, base64'd into a
/// single WebSocket frame, so an unbounded request is a way to make the host
/// allocate on a plugin's say-so.
pub const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024;
/// What a stored body is, without reading any of it.
#[derive(Debug, Clone, Default)]
pub struct ResponseBodyInfo {
/// Bytes actually stored, which is not necessarily what `Content-Length`
/// claimed. Zero when the response has no body.
pub content_length: u64,
/// The response's `Content-Type` header, verbatim.
pub content_type: Option<String>,
}
/// Somewhere response bodies can be read from, a window at a time.
///
/// Reads are repeatable — the bytes are durable, so nothing is consumed by
/// looking at it.
pub trait ResponseBodyStore {
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo>;
/// Bytes `[offset, offset + length)`, clamped to what is there. A short
/// read means the body ended.
fn read_chunk(&self, response_id: &str, offset: u64, length: u64) -> Result<Vec<u8>>;
}
/// The desktop and CLI store: the database says where the file is, and the
/// filesystem holds it.
pub struct FileResponseBodyStore<'a> {
query_manager: &'a QueryManager,
}
impl<'a> FileResponseBodyStore<'a> {
pub fn new(query_manager: &'a QueryManager) -> Self {
Self { query_manager }
}
/// The file backing a response, or `None` when it stored no body.
///
/// Only responses the store knows about are reachable here. A send with no
/// request behind it never reaches the store at all, and its bytes come
/// back from the send instead — see `SendHttpRequestResponse::body`.
fn body_path(&self, response_id: &str) -> Result<Option<String>> {
Ok(self.query_manager.connect().get_http_response(response_id)?.body_path)
}
}
impl ResponseBodyStore for FileResponseBodyStore<'_> {
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo> {
let response = self.query_manager.connect().get_http_response(response_id)?;
let content_type = response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone());
let content_length = match response.body_path {
Some(path) => std::fs::metadata(path)?.len(),
None => 0,
};
Ok(ResponseBodyInfo { content_length, content_type })
}
fn read_chunk(&self, response_id: &str, offset: u64, length: u64) -> Result<Vec<u8>> {
let Some(path) = self.body_path(response_id)? else {
return Ok(Vec::new());
};
let length = length.min(MAX_CHUNK_BYTES);
if length == 0 {
return Ok(Vec::new());
}
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(offset))?;
let mut buf = Vec::new();
file.take(length).read_to_end(&mut buf)?;
Ok(buf)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseHeader, Workspace};
use yaak_models::util::UpdateSource;
fn seed(body: Option<&[u8]>) -> (QueryManager, TempDir, String) {
let temp_dir = TempDir::new().unwrap();
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
&temp_dir.path().join("db.sqlite"),
&temp_dir.path().join("blobs.sqlite"),
)
.unwrap();
query_manager
.connect()
.upsert_workspace(
&Workspace { id: "wk_test".to_string(), ..Default::default() },
&UpdateSource::Sync,
)
.unwrap();
query_manager
.connect()
.upsert_http_request(
&HttpRequest {
id: "rq_test".to_string(),
workspace_id: "wk_test".to_string(),
..Default::default()
},
&UpdateSource::Sync,
)
.unwrap();
let body_path = body.map(|bytes| {
let path = temp_dir.path().join("body");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(bytes).unwrap();
path.to_string_lossy().to_string()
});
let response = query_manager
.connect()
.upsert_http_response(
&HttpResponse {
workspace_id: "wk_test".to_string(),
request_id: "rq_test".to_string(),
body_path,
headers: vec![HttpResponseHeader {
name: "Content-Type".to_string(),
value: "application/json; charset=utf-8".to_string(),
}],
..Default::default()
},
&UpdateSource::Sync,
&blob_manager,
)
.unwrap();
let id = response.id.clone();
(query_manager, temp_dir, id)
}
#[test]
fn info_reports_stored_size_and_content_type() {
let (qm, _tmp, id) = seed(Some(b"hello world"));
let info = FileResponseBodyStore::new(&qm).info(&id).unwrap();
assert_eq!(info.content_length, 11);
assert_eq!(info.content_type.as_deref(), Some("application/json; charset=utf-8"));
}
#[test]
fn chunks_cover_the_body_and_stop_short_at_the_end() {
let (qm, _tmp, id) = seed(Some(b"hello world"));
let store = FileResponseBodyStore::new(&qm);
assert_eq!(store.read_chunk(&id, 0, 5).unwrap(), b"hello");
assert_eq!(store.read_chunk(&id, 6, 100).unwrap(), b"world");
assert!(store.read_chunk(&id, 11, 100).unwrap().is_empty());
// Reading the same window twice gives the same bytes; nothing is consumed.
assert_eq!(store.read_chunk(&id, 0, 5).unwrap(), b"hello");
}
#[test]
fn a_response_with_no_body_is_empty_not_an_error() {
let (qm, _tmp, id) = seed(None);
let store = FileResponseBodyStore::new(&qm);
assert_eq!(store.info(&id).unwrap().content_length, 0);
assert!(store.read_chunk(&id, 0, 100).unwrap().is_empty());
}
#[test]
fn an_unknown_response_fails() {
let (qm, _tmp, _id) = seed(Some(b"hi"));
assert!(FileResponseBodyStore::new(&qm).info("rs_nope").is_err());
}
#[test]
fn an_unsaved_response_is_not_reachable_by_id() {
// Its bytes rode back with the send; there is nothing here to find, and
// guessing at a file named for the id is exactly what this must not do.
let (qm, tmp, _id) = seed(Some(b"hi"));
std::fs::write(tmp.path().join("rs_ephemeral1"), b"access_token=abc").unwrap();
assert!(FileResponseBodyStore::new(&qm).info("rs_ephemeral1").is_err());
}
}
-13
View File
@@ -354,19 +354,6 @@ pub enum ResponseBody {
Returned(Vec<u8>),
}
impl ResponseBody {
/// The bytes, when this is the only copy of them.
///
/// Stored and streamed bodies belong to whoever holds them; only `Returned`
/// has to travel back to the caller.
pub fn returned_bytes(&self) -> Option<&[u8]> {
match self {
ResponseBody::Returned(bytes) => Some(bytes),
ResponseBody::Stored | ResponseBody::Streamed => None,
}
}
}
pub struct SendHttpRequestResult {
pub rendered_request: HttpRequest,
pub response: HttpResponse,
-3
View File
@@ -1,3 +0,0 @@
{
"ignorePatterns": "crates/yaak-templates/pkg/**\ncrates/yaak-web/pkg/**\n**/bindings/gen_*.ts\npackage-lock.json\nCargo.lock"
}
+2 -24
View File
@@ -59,7 +59,6 @@
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates/common/yaak-rpc-schema",
"crates/yaak-crypto",
"crates/yaak-git",
"crates/yaak-models",
@@ -67,7 +66,6 @@
"crates/yaak-sse",
"crates/yaak-sync",
"crates/yaak-templates",
"crates/yaak-web",
"crates/yaak-ws",
"crates-proxy/yaak-proxy-lib",
"apps/yaak-client",
@@ -292,10 +290,6 @@
"name": "@yaakapp-internal/mac-window",
"version": "1.0.0"
},
"crates/common/yaak-rpc-schema": {
"name": "@yaakapp-internal/rpc-schema",
"version": "1.0.0"
},
"crates/yaak-crypto": {
"name": "@yaakapp-internal/crypto",
"version": "1.0.0"
@@ -330,13 +324,6 @@
"rimraf": "^6.1.2"
}
},
"crates/yaak-web": {
"name": "@yaakapp-internal/web",
"version": "1.0.0",
"devDependencies": {
"rimraf": "^6.1.2"
}
},
"crates/yaak-ws": {
"name": "@yaakapp-internal/ws",
"version": "1.0.0"
@@ -5766,10 +5753,6 @@
"resolved": "crates-proxy/yaak-proxy-lib",
"link": true
},
"node_modules/@yaakapp-internal/rpc-schema": {
"resolved": "crates/common/yaak-rpc-schema",
"link": true
},
"node_modules/@yaakapp-internal/sse": {
"resolved": "crates/yaak-sse",
"link": true
@@ -5802,10 +5785,6 @@
"resolved": "packages/ui",
"link": true
},
"node_modules/@yaakapp-internal/web": {
"resolved": "crates/yaak-web",
"link": true
},
"node_modules/@yaakapp-internal/ws": {
"resolved": "crates/yaak-ws",
"link": true
@@ -17210,8 +17189,7 @@
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/rpc-schema": "^1.0.0"
"@tauri-apps/plugin-os": "^2.3.2"
}
},
"packages/plugin-runtime": {
@@ -17226,7 +17204,7 @@
},
"packages/plugin-runtime-types": {
"name": "@yaakapp/api",
"version": "0.9.0",
"version": "0.8.0",
"dependencies": {
"@types/node": "^24.0.13"
},
-1
View File
@@ -66,7 +66,6 @@
"crates/yaak-sse",
"crates/yaak-sync",
"crates/yaak-templates",
"crates/yaak-web",
"crates/yaak-ws",
"crates-proxy/yaak-proxy-lib",
"apps/yaak-client",
-2
View File
@@ -9,8 +9,6 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@yaakapp-internal/rpc-schema": "^1.0.0",
"@yaakapp-internal/web": "^1.0.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
+211
View File
@@ -0,0 +1,211 @@
import type { Unsubscribe } from "../types";
/**
* The wire to the Yaak Bridge: one `POST /rpc` per command, one WebSocket for
* events in both directions.
*
* The hard requirement this file exists to satisfy: the connection is opened
* asynchronously, but the host that uses it must be constructible
* *synchronously*. Boot-time modules call commands while the module graph is
* still evaluating (`lib/appInfo.ts` top-level-awaits one), so there is no
* later moment to install a host, and a registry that waited for a socket would
* deadlock. So every call made before the connection opens is queued here and
* flushed when it does. The app's own top-level await then doubles as the
* connection gate: nothing renders until the first command has answered, which
* means it has answered over a live connection.
*/
interface EventFrame {
event: string;
payload: unknown;
}
export interface BridgeInfo {
name: string;
version: string;
capabilities: Record<string, boolean>;
commands: string[];
}
/** How long to wait before retrying a dropped connection, and the ceiling. */
const RECONNECT_BASE_MS = 250;
const RECONNECT_MAX_MS = 5000;
export class BridgeConnection {
readonly baseUrl: string;
readonly label: string;
/**
* Null when the user hasn't supplied one yet. The connection then never
* opens, so every call queues forever which is exactly what the connect
* screen wants, and means "waiting for a token" and "waiting for the socket"
* are the same code path rather than two.
*/
private readonly token: string | null;
private socket: WebSocket | null = null;
private connected = false;
private reconnectDelay = RECONNECT_BASE_MS;
/** Frames the page tried to send before the socket opened. */
private outboundQueue: EventFrame[] = [];
/** Resolvers for anything awaiting the first successful connection. */
private readyWaiters: Array<() => void> = [];
private listeners = new Map<string, Set<(payload: unknown) => void>>();
info: BridgeInfo | null = null;
constructor(baseUrl: string, token: string | null, label: string) {
this.baseUrl = baseUrl.replace(/\/$/, "");
this.token = token;
this.label = label;
if (token != null) this.openSocket();
}
get hasToken(): boolean {
return this.token != null;
}
/** Resolves once the events socket is open. */
ready(): Promise<void> {
if (this.connected) return Promise.resolve();
return new Promise((resolve) => this.readyWaiters.push(resolve));
}
/** A URL on the bridge with the token attached, for the browser to fetch directly. */
url(path: string): string {
const url = new URL(this.baseUrl + path);
url.searchParams.set("token", this.token ?? "");
return url.toString();
}
async fetch(path: string, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${this.token ?? ""}`);
return fetch(this.baseUrl + path, { ...init, headers });
}
async loadInfo(): Promise<BridgeInfo> {
const res = await this.fetch("/bridge/info");
if (!res.ok) {
throw new Error(`Bridge rejected the connection (${res.status}). Is the token correct?`);
}
this.info = (await res.json()) as BridgeInfo;
return this.info;
}
/**
* Send a command and await its result.
*
* Waits for the connection first, so a command issued during module
* evaluation queues instead of failing. Errors are carried inside the
* envelope and rethrown here, so callers see the backend's own message
* matching what Tauri's `invoke` does with a rejected command.
*/
async rpc<T>(cmd: string, payload: Record<string, unknown> = {}): Promise<T> {
await this.ready();
const res = await this.fetch("/rpc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: crypto.randomUUID(), cmd, payload }),
});
if (!res.ok) {
throw new Error(`Bridge request failed (${res.status})`);
}
const body = (await res.json()) as
| { type: "Success"; id: string; payload: T }
| { type: "Error"; id: string; error: string };
if (body.type === "Error") {
throw new Error(body.error);
}
return body.payload;
}
listen(event: string, callback: (payload: unknown) => void): Unsubscribe {
let handlers = this.listeners.get(event);
if (handlers == null) {
handlers = new Set();
this.listeners.set(event, handlers);
}
handlers.add(callback);
// Synchronous, because callers unsubscribe from React cleanups.
return () => {
const current = this.listeners.get(event);
if (current == null) return;
current.delete(callback);
if (current.size === 0) this.listeners.delete(event);
};
}
emit(event: string, payload: unknown): void {
const frame: EventFrame = { event, payload };
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(frame));
} else {
this.outboundQueue.push(frame);
}
}
/** Tell the bridge who and where we are — what a desktop window's URL says. */
attach(): void {
this.emit("bridge_attach", { label: this.label, url: window.location.href });
}
private openSocket(): void {
const wsUrl = new URL(this.baseUrl.replace(/^http/, "ws") + "/events");
wsUrl.searchParams.set("token", this.token ?? "");
const socket = new WebSocket(wsUrl.toString());
this.socket = socket;
socket.onopen = () => {
this.connected = true;
this.reconnectDelay = RECONNECT_BASE_MS;
this.attach();
for (const frame of this.outboundQueue.splice(0)) {
socket.send(JSON.stringify(frame));
}
for (const resolve of this.readyWaiters.splice(0)) {
resolve();
}
};
socket.onmessage = (message) => {
let frame: EventFrame;
try {
frame = JSON.parse(String(message.data)) as EventFrame;
} catch {
console.warn("Bridge sent a malformed event frame");
return;
}
// Deliver the payload directly, not wrapped in Tauri's `{ payload }`.
for (const handler of this.listeners.get(frame.event) ?? []) {
try {
handler(frame.payload);
} catch (err) {
console.error("Bridge event handler threw", frame.event, err);
}
}
};
socket.onclose = () => {
this.connected = false;
this.socket = null;
// The server closes the socket when a tab falls too far behind to be
// consistent, so a reconnect has to re-read the workspace rather than
// resume. `bridge_reconnected` is what tells the app to do that.
window.setTimeout(() => this.openSocket(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
};
socket.onerror = () => {
// `onclose` always follows, and it owns the retry.
socket.close();
};
}
}
+293
View File
@@ -0,0 +1,293 @@
import type {
DragDropEvent,
OsType,
Platform,
PlatformCapabilities,
PlatformWindow,
RpcPayload,
RpcStreamHandle,
Unsubscribe,
} from "../types";
import { BridgeConnection } from "./connection";
/**
* The browser host: the Yaak UI in a tab, with the real engine running in the
* Yaak Bridge next to it.
*
* Everything the desktop gets from Tauri comes over one HTTP connection
* instead. The parts a page genuinely cannot do a native file dialog, a
* second window, reading the clipboard unprompted are not faked. They report
* false through `capabilities` and throw if called anyway, so a missing feature
* surfaces as a disabled control rather than a silent no-op.
*/
/**
* Until the bridge answers, assume nothing works.
*
* These are replaced wholesale by the server's own report as soon as
* `/bridge/info` returns, which happens before the app's first render the
* boot sequence top-level-awaits a command, and that command cannot resolve
* before the connection is up. Starting pessimistic means that if that ordering
* ever changes, the UI hides a feature it should have shown instead of offering
* one that will fail.
*/
const NO_CAPABILITIES: PlatformCapabilities = {
grpc: false,
websocket: false,
git: false,
sync: false,
tlsOptions: false,
cookieJar: false,
localFiles: false,
timeline: false,
multiWindow: false,
plugins: false,
encryption: false,
updater: false,
clipboardRead: false,
systemFonts: false,
license: false,
};
function unsupported(what: string): Error {
return new Error(`${what} is not supported in the browser`);
}
/** Match `@tauri-apps/plugin-os` spellings so layout code needs no new branch. */
function detectOsType(): OsType {
const platform = navigator.userAgent;
if (/Mac|iPhone|iPad|iPod/.test(platform)) return "macos";
if (/Win/.test(platform)) return "windows";
if (/Android/.test(platform)) return "android";
return "linux";
}
/**
* Answer the Tauri host-plugin commands, which ride outside the RPC envelope.
*
* `set_title` has a real browser equivalent. `set_theme` paints the native
* window frame behind the webview, which a tab has no equivalent of and does
* not need. Everything else is a desktop-only feature; rejecting is correct,
* and the callers already gate on the matching capability.
*/
async function handleHostPluginCommand<T>(cmd: string, payload?: RpcPayload): Promise<T> {
switch (cmd) {
case "plugin:yaak-mac-window|set_title": {
const title = payload?.title;
document.title = typeof title === "string" ? title : "Yaak";
return undefined as T;
}
case "plugin:yaak-mac-window|set_theme":
return undefined as T;
default:
throw unsupported(`\`${cmd}\``);
}
}
function createWindow(connection: BridgeConnection): PlatformWindow {
const noop = async () => {};
return {
label: connection.label,
// A tab manages its own frame. These exist because the interface names
// them; the UI only reaches for them behind `multiWindow`.
show: noop,
close: noop,
minimize: noop,
maximize: noop,
unmaximize: noop,
isMaximized: async () => false,
isFullscreen: async () => document.fullscreenElement != null,
setZoom: noop,
// Null means "no opinion, let CSS decide". The desktop returns a real value
// because applying a theme forces the window appearance and poisons the
// media query; nothing does that here, so `prefers-color-scheme` is the
// honest answer and the theme package already falls back to it.
theme: async () => null,
onThemeChanged(callback) {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const listener = () => callback(media.matches ? "dark" : "light");
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
},
onFocusChanged(callback) {
const onFocus = () => callback(true);
const onBlur = () => callback(false);
window.addEventListener("focus", onFocus);
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("focus", onFocus);
window.removeEventListener("blur", onBlur);
};
},
// Native drag-and-drop reports OS paths, which a page never sees. The DOM's
// own drag events are a different thing and the components that need them
// use them directly.
onDragDrop(_callback: (event: DragDropEvent) => void): Unsubscribe {
return () => {};
},
};
}
/**
* Keep the bridge told where the tab is.
*
* The desktop reads the workspace, environment, cookie jar and request straight
* off the window's URL whenever a plugin asks. The bridge can't, so the tab
* pushes it on every navigation. The router uses the History API, which fires
* no event of its own on push, hence the wrapping.
*/
function trackNavigation(connection: BridgeConnection): void {
const report = () => connection.attach();
for (const method of ["pushState", "replaceState"] as const) {
const original = history[method];
history[method] = function (this: History, ...args: Parameters<History["pushState"]>) {
const result = original.apply(this, args);
report();
return result;
};
}
window.addEventListener("popstate", report);
window.addEventListener("hashchange", report);
}
export function createBridgePlatform(baseUrl: string, token: string | null): Platform {
const label = `tab_${crypto.randomUUID().slice(0, 8)}`;
const connection = new BridgeConnection(baseUrl, token, label);
// Mutated in place once the bridge reports, because `platform.capabilities`
// hands out this object and callers hold the reference.
const capabilities: PlatformCapabilities = { ...NO_CAPABILITIES };
if (connection.hasToken) {
void connection
.loadInfo()
.then((info) => Object.assign(capabilities, info.capabilities))
.catch((err) => console.error("Failed to read bridge capabilities", err));
}
trackNavigation(connection);
// Two host requests the plugin runtime makes that only a page can carry out.
connection.listen("bridge_copy_text", (payload) => {
const text = (payload as { text?: string } | null)?.text;
if (typeof text === "string") void navigator.clipboard.writeText(text);
});
connection.listen("bridge_open_url", (payload) => {
const url = (payload as { url?: string } | null)?.url;
if (typeof url === "string") window.open(url, "_blank", "noopener,noreferrer");
});
const platformWindow = createWindow(connection);
return {
capabilities,
window: platformWindow,
clipboard: {
writeText: (text) => navigator.clipboard.writeText(text),
// Reading needs a permission prompt the moment the page paints, which is
// a bad ask for an app people paste bearer tokens into. `clipboardRead`
// is false and the one caller is gated on it.
readText: async () => {
throw unsupported("Reading the clipboard");
},
clear: async () => {
throw unsupported("Clearing the clipboard");
},
},
dialog: {
open: (async () => null) as Platform["dialog"]["open"],
save: async () => null,
},
files: {
readDir: async () => {
throw unsupported("Browsing the filesystem");
},
// Only ever called with a path this host handed out, and this host has
// no dialog or drag-drop to hand one out with.
readText: async () => {
throw unsupported("Reading a local file");
},
// No filesystem here, so a path is just a string this host echoes back.
url: (path) => path,
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
resolveResource: async (path) => path,
},
// Bodies live on the bridge's disk and are addressed by response id. The
// server resolves the id through its database, so a page can only ever
// reach a body the engine actually wrote.
blobs: {
async read(id) {
const res = await connection.fetch(`/responses/${encodeURIComponent(id)}/body`);
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`Failed to read response body (${res.status})`);
}
return new Uint8Array(await res.arrayBuffer());
},
// The `<img src>`/`<video src>` equivalent of Tauri's `convertFileSrc`.
// The token rides in the query because the browser makes these requests
// itself and the page cannot add a header to them.
async url(id) {
return connection.url(`/responses/${encodeURIComponent(id)}/body`);
},
},
rpc: <T,>(cmd: string, payload?: RpcPayload): Promise<T> => {
// `plugin:`-prefixed commands are Tauri host plugins, not engine
// commands, so they never reach the RpcRouter. Two of them are window
// chrome the tab can do itself; the rest belong to features this host
// reports false for, and saying so beats a confusing "unknown command".
if (cmd.startsWith("plugin:")) {
return handleHostPluginCommand<T>(cmd, payload);
}
return connection.rpc<T>(cmd, payload);
},
async rpcStream<T, M>(
cmd: string,
payload: RpcPayload,
onMessage: (message: M) => void,
): Promise<RpcStreamHandle<T>> {
// Caller-minted id, subscribed before dispatch, exactly as on the
// desktop: the command can emit its first message before it returns.
const streamId = crypto.randomUUID();
const unlisten = connection.listen(`stream_${streamId}`, (p) => onMessage(p as M));
try {
const result = await connection.rpc<T>(cmd, { ...payload, streamId });
return { result, unlisten };
} catch (err) {
unlisten();
throw err;
}
},
listen: <T,>(event: string, callback: (payload: T) => void): Unsubscribe =>
connection.listen(event, (payload) => callback(payload as T)),
emit: async (event, payload) => connection.emit(event, payload),
openUrl: async (url) => {
window.open(url, "_blank", "noopener,noreferrer");
},
revealItemInDir: async () => {
throw unsupported("Revealing a file");
},
osType: detectOsType,
appIdentifier: async () => "app.yaak.bridge",
};
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Deciding which host to install, and getting a bridge token when there isn't
* one yet.
*
* Dev-grade on purpose. The token is a shared secret the bridge prints at
* startup, passed in the URL and kept for the session. OTP pairing and request
* encryption replace this whole file; the seam is that nothing outside it knows
* how the token was obtained.
*/
export interface BridgeConfig {
url: string;
token: string;
}
const TOKEN_STORAGE_KEY = "yaak.bridge.token";
const TOKEN_QUERY_PARAM = "bridgeToken";
declare global {
interface Window {
__TAURI_INTERNALS__?: unknown;
}
}
function bridgeUrl(): string {
// Set when the frontend runs on a Vite dev server and the bridge is on its
// own port. When the bridge serves the built app, they share an origin.
// Vite inlines `import.meta.env` at build time. Read it through a cast so
// this package typechecks on its own without depending on Vite's types, and
// still picks up the real declaration when the app compiles it.
const env = (import.meta as { env?: Record<string, string | undefined> }).env;
const configured = env?.VITE_YAAK_BRIDGE_URL;
return (configured ?? window.location.origin).replace(/\/$/, "");
}
/**
* The bridge token, or null if the user hasn't supplied one.
*
* A token in the URL is consumed and stashed: leaving it in the address bar
* means it lands in the router's own history entries and in anything the user
* copies out of the bar.
*/
function readToken(): string | null {
const url = new URL(window.location.href);
const fromQuery = url.searchParams.get(TOKEN_QUERY_PARAM);
if (fromQuery != null && fromQuery !== "") {
sessionStorage.setItem(TOKEN_STORAGE_KEY, fromQuery);
url.searchParams.delete(TOKEN_QUERY_PARAM);
history.replaceState(null, "", url.toString());
return fromQuery;
}
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
}
/** Whether this build should talk to a bridge at all. */
export function shouldUseBridge(): boolean {
if (typeof window === "undefined") return false;
// Running inside the desktop app: Tauri always wins.
if (window.__TAURI_INTERNALS__ != null) return false;
return true;
}
export function bridgeConfig(): BridgeConfig | null {
const token = readToken();
if (token == null) return null;
return { url: bridgeUrl(), token };
}
/**
* Put the connect form on screen.
*
* Synchronous, and it does not stop anything by itself the caller pairs it
* with a host that never connects, so the app's own boot-time await is what
* holds. Submitting reloads with the token in the query, which `readToken`
* then consumes.
*/
export function promptForToken(): void {
document.body.innerHTML = `
<div style="font-family: system-ui, sans-serif; max-width: 26rem; margin: 15vh auto; padding: 0 1.5rem; color: #d5d3e0">
<h1 style="font-size: 1.25rem; margin: 0 0 0.5rem">Connect to the Yaak Bridge</h1>
<p style="margin: 0 0 1.25rem; line-height: 1.5; color: #9a97ad">
Paste the token the bridge printed when it started.
</p>
<form id="yaak-bridge-connect" style="display: flex; gap: 0.5rem">
<input name="token" autofocus autocomplete="off" spellcheck="false" placeholder="Bridge token"
style="flex: 1; padding: 0.5rem 0.65rem; border-radius: 0.375rem; border: 1px solid #3b3950; background: #232135; color: inherit; font-family: ui-monospace, monospace" />
<button type="submit"
style="padding: 0.5rem 1rem; border-radius: 0.375rem; border: 0; background: #6d5ef0; color: white; font-weight: 500; cursor: pointer">
Connect
</button>
</form>
</div>
`;
document.documentElement.style.background = "#1b1a29";
document.getElementById("yaak-bridge-connect")?.addEventListener("submit", (e) => {
e.preventDefault();
const token = new FormData(e.target as HTMLFormElement).get("token");
if (typeof token !== "string" || token === "") return;
const url = new URL(window.location.href);
url.searchParams.set(TOKEN_QUERY_PARAM, token);
window.location.href = url.toString();
});
}
+23 -7
View File
@@ -1,14 +1,30 @@
import { createBridgePlatform } from "./bridge";
import { bridgeConfig, promptForToken, shouldUseBridge } from "./connect";
import { setPlatform } from "./registry";
import { createTauriPlatform } from "./tauri";
// The desktop entry. Installed unconditionally and synchronously several
// modules call commands while the module graph is still evaluating, so there is
// no later moment to do this in.
// This line is the swap point, and it has to run synchronously: several modules
// call commands while the module graph is still evaluating, so there is no
// later moment to install a host in.
//
// This line is the swap point, and a browser build swaps it by resolving the
// package to `index.web.ts` instead of this file. Because nothing else in the
// app imports a host directly, that is the whole change.
setPlatform(createTauriPlatform());
// Both hosts are constructible without waiting for anything. The bridge host
// opens its connection in the background and queues calls made before it lands,
// which is why picking a host here does not mean blocking on one.
if (shouldUseBridge()) {
const config = bridgeConfig();
if (config == null) {
// No token yet: put the connect form on screen and install a host that
// never connects. Boot then stalls at its own top-level await rather than
// failing somewhere that doesn't explain itself — and it stalls in the one
// place already designed to wait, which keeps this file synchronous.
promptForToken();
setPlatform(createBridgePlatform(window.location.origin, null));
} else {
setPlatform(createBridgePlatform(config.url, config.token));
}
} else {
setPlatform(createTauriPlatform());
}
export * from "./capabilities";
export { platform, setPlatform } from "./registry";
-23
View File
@@ -1,23 +0,0 @@
import { setPlatform } from "./registry";
import { createWebPlatform } from "./web";
/**
* The package entry for browser builds, selected by aliasing
* `@yaakapp-internal/platform` to this file (see `YAAK_TARGET=web` in the
* client's vite.config.ts).
*
* A separate entry rather than a branch inside `index.ts`, because a branch
* would still leave `import "@tauri-apps/api"` in the module graph: the folded
* `if` disappears, but the imports it guarded do not, and those modules cannot
* be proven side-effect free. Splitting the entry means a web build never
* mentions Tauri at all and it keeps `index.ts` exactly as the desktop has
* always had it.
*
* Like `index.ts`, this must install the host eagerly and synchronously:
* boot-time modules call commands while the module graph is still evaluating.
*/
setPlatform(createWebPlatform());
export * from "./capabilities";
export { platform, setPlatform } from "./registry";
export * from "./types";
-193
View File
@@ -1,193 +0,0 @@
# The browser host
Yaak running in a plain tab: no install, no local process. The desktop's own
model layer — `yaak-models`, SQLite included — runs compiled to wasm inside a
worker the tab talks to, so a browser stores exactly what a desktop install
stores, migrations and all.
Select it at build time and run the frontend alone:
```shell
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
```
The flag resolves `@yaakapp-internal/platform` to `../index.web.ts`, which
installs this host instead of the Tauri one. It is a separate entry rather than
a branch inside `index.ts` so that a web build never pulls `@tauri-apps/*` into
the module graph at all — a folded branch would drop the code but keep the
imports it guarded.
Desktop builds are untouched: without the flag, `packages/platform/src/index.ts`
installs the Tauri host exactly as before.
## How it fits together
```
tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaakapp-internal/web (wasm)
◀── model_writes ── crates/yaak-web → yaak-models → SQLite
└─ pages in IndexedDB
```
| File | What it is |
| --- | --- |
| `index.ts` | The `Platform` implementation. |
| `commands.ts` | The command table: model commands forward to the worker; the rest is fixed answers and refusals-with-a-reason. |
| `connection.ts` | A tab's end of the wire: request/response over a `MessagePort`, event delivery, and the tab's identity (`label`). |
| `worker.ts` | The process that owns the database. Loads the wasm, opens the DB once, answers each port, fans `model_writes` out to every port. |
| `protocol.ts` | The message shapes both sides import. |
| `errors.ts` | `UnsupportedCommandError`, the structured refusal. |
| `storage.ts` | `navigator.storage.persist()`. |
The Rust side is `crates/yaak-web` (`@yaakapp-internal/web`): `boot()`,
`rpc(cmd, payload, label)` returning `{ result, events }`, and blob get/put.
Its `pkg/` is committed; rebuilding needs a clang with a WebAssembly backend
(`brew install llvm`), and `build-wasm.cjs` skips with a notice when there
isn't one, so a desktop `npm run bootstrap` never depends on it.
Behaviours worth knowing before changing anything:
- **The worker is a `SharedWorker`, and only that.** The browser hands every
tab on the origin the same one, which is what makes "one database owner"
true without anyone coordinating — and makes the browser look like the
desktop: one process holds the data, every window talks to it, it pushes
writes to all of them. It still takes a Web Lock before opening, for the one
overlap the browser doesn't rule out (a reloading tab's dying predecessor).
There is deliberately no fallback to a per-tab worker: two kinds of worker
that can both come up is a race. Every current browser, desktop and mobile,
has `SharedWorker` and Web Locks (Chrome for Android since 148, April 2026);
older ones get a clear "unsupported browser" message rather than a second
SQLite over the same pages.
- **Every write is stamped with the calling tab's `label`** as
`UpdateSource::Window`, exactly like a desktop window label, so the frontend
store's echo handling is unchanged.
- **Cascade rules, duplicate naming, id generation, serde defaults, and the
lazy first-run bootstrap are all the Rust code's.** Nothing about what a
model *is* is decided in TypeScript.
- **Persistence is `relaxed-idb`**: SQLite pages live in IndexedDB, writes land
in memory and flush shortly after. A tab closing mid-flush loses at most the
last few writes.
## Commands
109 commands are declared in `@yaakapp-internal/rpc-schema`. This host answers
31, declines 44 by name with a reason, and refuses the remaining 34 generically.
### Implemented (31)
| Group | Commands |
| --- | --- |
| Models | `models_workspace_models`, `models_upsert`, `models_delete`, `models_duplicate`, `models_get_settings`, `models_get_graphql_introspection`, `models_upsert_graphql_introspection`, `models_grpc_events`, `models_websocket_events` |
| App | `cmd_metadata`, `cmd_get_workspace_meta`, `cmd_default_headers`, `cmd_get_themes`, `cmd_check_for_updates`, `cmd_dismiss_notification`, `cmd_plugin_init_errors` |
| Bodies | `cmd_http_response_body`, `cmd_http_response_body_path`, `cmd_http_request_body`, `cmd_get_http_response_events`, `cmd_get_sse_events` |
| Plugin surfaces (empty results) | `cmd_http_request_actions`, `cmd_websocket_request_actions`, `cmd_grpc_request_actions`, `cmd_workspace_actions`, `cmd_folder_actions`, `cmd_template_function_summaries`, `cmd_get_http_authentication_summaries`, `cmd_get_http_authentication_config` |
| Text | `cmd_format_json`, `cmd_render_template` |
Some of these answer honestly rather than fully, and the difference matters:
- `cmd_render_template` returns the template **unrendered**. Resolving variables
and calling template functions is plugin work. The preview shows the raw
`${[…]}` rather than a wrong value.
- `cmd_get_http_authentication_summaries` returns the auth methods Yaak ships as
plugins, so the picker is truthful about the product — but
`cmd_get_http_authentication_config` returns an empty form, because the plugin
that defines the form isn't running.
- `cmd_template_function_summaries` returns one provider contributing no
functions. Both summary commands are polled every second until they return
something, so an empty list is a poll that never stops rather than a quiet no.
- `cmd_metadata` reports empty strings for the data, log, plugin and project
directories. There is no filesystem behind this host.
### Declined by name (44)
Each returns an `UnsupportedCommandError` carrying `cmd`, a user-facing
`message`, and the `capability` a caller should have checked. The UI turns it
into a toast.
| Reason | Commands |
| --- | --- |
| Sending isn't available yet (slice 2) | `cmd_send_http_request`, `cmd_send_ephemeral_request`, `cmd_delete_send_history`, `cmd_delete_all_http_responses`, `cmd_import_url` |
| No plugin runtime | `cmd_reload_plugins`, `cmd_plugin_info`, `cmd_plugins_search`, `cmd_plugins_install`, `cmd_plugins_install_from_directory`, `cmd_plugins_uninstall`, `cmd_plugins_updates`, `cmd_plugins_update_all`, `cmd_template_function_config`, `cmd_template_tokens_to_string`, `cmd_call_http_request_action`, `cmd_call_websocket_request_action`, `cmd_call_grpc_request_action`, `cmd_call_workspace_action`, `cmd_call_folder_action`, `cmd_call_http_authentication_action`, `cmd_curl_to_request`, `cmd_format_graphql` |
| No filesystem | `cmd_import_data`, `cmd_export_data`, `cmd_save_response`, `cmd_save_base64_to_binary` |
| Needs a real socket | `cmd_grpc_reflect`, `cmd_grpc_go`, `cmd_delete_all_grpc_connections`, `cmd_ws_connect`, `cmd_ws_send`, `cmd_ws_close`, `cmd_ws_delete_connections` |
| Workspace encryption | `cmd_enable_encryption`, `cmd_disable_encryption`, `cmd_reveal_workspace_key`, `cmd_set_workspace_key`, `cmd_secure_template`, `cmd_decrypt_template` |
| One tab, no windows | `cmd_new_child_window`, `cmd_new_main_window`, `cmd_restart` |
| Other | `cmd_send_feedback` |
### Refused generically (34)
The 30 `cmd_git_*` commands and `cmd_sync_calculate`, `cmd_sync_calculate_fs`,
`cmd_sync_apply`, `cmd_sync_watch`. Nothing in the app reaches them unless a
workspace has a sync directory, which a browser tab cannot set.
Anything added to the schema later also lands here, and the error names the
command — an unlisted command is a gap in `commands.ts`, and whoever hits it
should be able to see which.
## Capabilities
Reported honestly, so callers gate on the question rather than on the host:
| True | False |
| --- | --- |
| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
`multiWindow: false` means the host cannot open a *second window* on demand —
what `cmd_new_child_window` does for Settings and workspace switching. It is not
a claim that nothing else is looking: other tabs may well be open on the same
worker, and it pushes every write to all of them regardless.
## Multiple tabs
Each tab mints a label at load (`tab_xxxxxxxx`) and sends it with every command;
the worker stamps writes with it as `UpdateSource::Window { label }`, standing in
for the desktop's window label. The worker fans each write out to every
connected tab, and the receiving tab's store applies or ignores it exactly as a
desktop window would.
The label is deliberately *not* kept in `sessionStorage`: duplicating a tab
copies session storage, and two tabs sharing one identity would each mistake the
other's writes for an echo of their own and drop them.
## Known gaps
- **Storage persistence is requested, not guaranteed.** `navigator.storage.persist()`
runs at boot; browsers grant it on their own heuristics and often decline on
`localhost`.
- **`pkg/yaak_web_bg.wasm` is 3.8 MB and committed** (no `wasm-opt`, matching
`yaak-templates`). It will churn on every model-layer change; a CI-built
artifact is the real answer.
- **Settings opens in the same tab** and is left with the browser's Back button.
- **Settings shows Data Directory / Logs Directory rows** with empty values; the
Create Workspace dialog offers directory sync and encryption. Should be gated
on `localFiles` / `sync` / `encryption`.
- **`cmd_render_template` returns the template unrendered.** Resolving variables
and calling template functions is plugin work.
- **A declined command logs an unhandled rejection** next to its toast — the
app's own `createFastMutation.mutate`, same on desktop.
- **`yaak-rpc-schema` does not come to wasm** (it pulls the git/gRPC/plugin
crates for their types), so the crate declares the handful of request shapes
it needs locally, and `commands.ts` stays typed against `RpcSchema`.
## What slice 2 (the send proxy) will need from this layer
Sending becomes a stateless hosted service; this layer stays the only place data
lives. Concretely:
1. **A rendered request to send.** The client assembles `HttpSendInputs` and
posts it. Nothing about the workspace is uploaded except what this request
needs.
2. **Cookies out, cookies in.** The active `cookie_jar` model's `cookies` array
goes up with the request; the proxy returns the jar as the exchange left it,
and the client upserts it back through `models_upsert` like any other write.
The proxy keeps nothing.
3. **A response body sink.** `blob_put(responseId, bytes)` in the worker
writes through the desktop's `blob_manager`, chunked the way it chunks.
Streaming will want an append path rather than one whole-body write.
4. **A request body sink** under `${responseId}.request`, which
`cmd_http_request_body` already reads.
5. **Response and timeline models.** `cmd_send_http_request` currently declines;
it will instead upsert an `http_response` as the exchange progresses, plus
`http_response_event` rows once `timeline` becomes true. Both flow through
the same `write()` helper, so other tabs see a send land live.
6. **Blob cleanup is the desktop's.** `delete_http_response` and
`delete_workspace` in `yaak-models` already remove blob chunks.
-341
View File
@@ -1,341 +0,0 @@
/**
* The command table: what this host answers, and what it declines and why.
*
* The model commands are forwarded to the worker, where the desktop's own
* model layer answers them same queries, same migrations, same cascade
* rules so nothing about *what a model is* is decided in this file. What is
* decided here is the rest of the desktop's command surface: a handful of
* fixed answers that are true of a browser tab, and the refusals. The refusals
* are the important half: a command that silently returns nothing leaves the
* UI showing something that isn't true, whereas a refusal with a reason becomes
* a toast the user can act on. So each unsupported command is listed by name
* with the reason, and anything not listed at all is refused generically
* rather than guessed at.
*
* The command names are `keyof RpcSchema`, the same generated wire schema the
* desktop's router is built from, so a command renamed or added in Rust shows
* up here as a type error rather than as a runtime surprise.
*/
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
import type { CapabilityName, RpcPayload } from "../types";
import type { WorkerConnection } from "./connection";
import { unsupported } from "./errors";
export type AppCmd = keyof RpcSchema;
type Handler = (payload: RpcPayload, db: WorkerConnection) => Promise<unknown>;
/** Placeholder shown wherever the desktop would show a real filesystem path. */
const NO_PATH = "";
function str(payload: RpcPayload, key: string): string | null {
const value = payload[key];
return typeof value === "string" && value !== "" ? value : null;
}
/** Like `str`, but for fields where an empty string is a legitimate value. */
function text(payload: RpcPayload, key: string): string {
const value = payload[key];
return typeof value === "string" ? value : "";
}
/**
* Commands this host answers itself.
*
* Anything here either reads and writes the browser's own database, or is a
* fixed answer that is true of this host not a stub standing in for something
* that should work.
*/
const HANDLERS: Partial<Record<AppCmd, Handler>> = {
/* ------------------------------- models -------------------------------- */
// Answered by the model layer itself, in the worker. The payload goes over
// untouched and the answer comes back untouched: this file has no opinion
// about models, and it would be wrong for it to grow one.
models_workspace_models: (payload, db) => db.rpc("models_workspace_models", payload),
models_upsert: (payload, db) => db.rpc("models_upsert", payload),
models_delete: (payload, db) => db.rpc("models_delete", payload),
models_duplicate: (payload, db) => db.rpc("models_duplicate", payload),
models_get_settings: (payload, db) => db.rpc("models_get_settings", payload),
models_get_graphql_introspection: (payload, db) =>
db.rpc("models_get_graphql_introspection", payload),
models_upsert_graphql_introspection: (payload, db) =>
db.rpc("models_upsert_graphql_introspection", payload),
models_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload),
models_websocket_events: (payload, db) => db.rpc("models_websocket_events", payload),
cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload),
/* -------------------------------- app ---------------------------------- */
async cmd_metadata() {
return {
isDev: true,
version: "0.0.0-web",
cliVersion: null,
name: "Yaak",
// The desktop hands out real directories here and the UI offers to open
// them. There is no filesystem behind this host, and the capability flags
// are what the UI should be gating those affordances on.
appDataDir: NO_PATH,
appLogDir: NO_PATH,
vendoredPluginDir: NO_PATH,
defaultProjectDir: NO_PATH,
featureUpdater: false,
featureLicense: false,
};
},
// The theme package ships its own defaults, so an empty list is a complete
// answer rather than a degraded one — themes beyond those come from plugins.
async cmd_get_themes() {
return [];
},
async cmd_default_headers() {
// Mirrors `default_headers()` in crates/yaak-models/src/queries/workspaces.rs
return [
{ enabled: true, name: "User-Agent", value: "yaak", id: null },
{ enabled: true, name: "Accept", value: "*/*", id: null },
];
},
async cmd_plugin_init_errors() {
return [];
},
async cmd_check_for_updates() {
return false;
},
async cmd_dismiss_notification() {
return null;
},
// Plugin-contributed menus. Empty is honest: no plugin runtime, no actions.
async cmd_http_request_actions() {
return [];
},
async cmd_websocket_request_actions() {
return [];
},
async cmd_grpc_request_actions() {
return [];
},
async cmd_workspace_actions() {
return [];
},
async cmd_folder_actions() {
return [];
},
/**
* Both of these are polled once a second until they answer with something, so
* an empty list is not a quiet no it is a poll that never stops.
*
* The auth list names what Yaak actually offers, so the picker tells the
* truth about the product even though the form behind each entry stays empty
* until plugins run here. Template functions get the opposite treatment: one
* provider contributing no functions. That settles the poll while putting
* nothing in the autocomplete, which is the honest answer a function the
* user could insert but nothing could evaluate would be worse than none.
*/
async cmd_get_http_authentication_summaries() {
return HTTP_AUTHENTICATION_SUMMARIES;
},
async cmd_template_function_summaries() {
return [{ pluginRefId: "web", functions: [] }];
},
async cmd_get_http_authentication_config() {
return { args: [], pluginRefId: "web" };
},
async cmd_format_json(payload) {
const source = text(payload, "text");
try {
return JSON.stringify(JSON.parse(source), null, 2);
} catch {
// Formatting invalid JSON is a no-op, not an error: the editor calls this
// while the user is still typing.
return source;
}
},
/**
* Rendering resolves variables and calls template functions, and the
* functions live in plugins. Handing the template back unrendered is what the
* preview then shows the raw `${[...]}`, which is at least the thing the
* user typed rather than a wrong value.
*/
async cmd_render_template(payload) {
return text(payload, "template");
},
/* ------------------------------- bodies -------------------------------- */
async cmd_http_response_body(payload, db) {
const responseId = str(payload, "responseId");
if (responseId == null) return { content: "" };
if (str(payload, "filter") != null) {
return {
content: "",
error: "Response filters come from a plugin, which this host doesn't run yet",
};
}
const bytes = await db.blobGet(responseId);
return { content: bytes == null ? "" : new TextDecoder().decode(bytes) };
},
// Bodies live in this database, not on a disk, so there is no path to give.
async cmd_http_response_body_path() {
return null;
},
async cmd_http_request_body(payload, db) {
const responseId = str(payload, "responseId");
if (responseId == null) return null;
// Keyed the way the desktop keys it: the request bytes belong to the
// response that recorded them.
const bytes = await db.blobGet(`${responseId}.request`);
return bytes == null ? null : Array.from(bytes);
},
async cmd_get_http_response_events() {
return [];
},
async cmd_get_sse_events() {
return [];
},
};
/**
* The auth methods Yaak ships as plugins today (plugins/auth-*). Listed so the
* picker is truthful about the product; choosing one currently yields an empty
* config form, because the plugin that defines the form isn't running.
*/
const HTTP_AUTHENTICATION_SUMMARIES = [
{ name: "apikey", label: "API Key", shortLabel: "API Key" },
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
{ name: "oauth2", label: "OAuth 2.0", shortLabel: "OAuth 2" },
];
/**
* Commands this host declines, each with the reason a user would need.
*
* Naming them individually rather than letting them fall through to a generic
* refusal is deliberate: "sending is not available yet" and "Yaak in a browser
* has no filesystem" are different situations, and the second is permanent
* while the first is a slice away.
*/
const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityName | null]>> = {
// Sending — the next slice. Everything else about a request works today;
// only the part that puts bytes on the network is missing.
cmd_send_http_request: [
"Sending isn't available in the browser yet — everything else about this request is saved",
null,
],
cmd_send_ephemeral_request: [
"Sending isn't available in the browser yet — everything else about this request is saved",
null,
],
cmd_curl_to_request: ["Importing from cURL needs a plugin, which this host doesn't run", null],
// Protocols that need a real socket.
cmd_grpc_reflect: ["gRPC isn't available in the browser", "grpc"],
cmd_grpc_go: ["gRPC isn't available in the browser", "grpc"],
cmd_delete_all_grpc_connections: ["gRPC isn't available in the browser", "grpc"],
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_delete_connections: ["WebSocket requests aren't available in the browser yet", "websocket"],
// Anything that needs files the page can't reach.
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
cmd_import_url: ["Importing from a URL needs the send proxy, which isn't available yet", null],
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
cmd_format_graphql: ["Formatting GraphQL needs a plugin, which this host doesn't run", null],
// Windows. A tab is the window, and there is only ever one of it.
cmd_new_child_window: ["Yaak in a browser uses one tab", "multiWindow"],
cmd_new_main_window: ["Yaak in a browser uses one tab", "multiWindow"],
cmd_restart: ["Reload the page to restart Yaak", null],
// Workspace encryption is backed by a key the host keeps for you; a page has
// nowhere to keep one that a page couldn't also read.
cmd_enable_encryption: ["Workspace encryption isn't available in the browser", "encryption"],
cmd_disable_encryption: ["Workspace encryption isn't available in the browser", "encryption"],
cmd_reveal_workspace_key: ["Workspace encryption isn't available in the browser", "encryption"],
cmd_set_workspace_key: ["Workspace encryption isn't available in the browser", "encryption"],
cmd_secure_template: ["Workspace encryption isn't available in the browser", "encryption"],
cmd_decrypt_template: ["Workspace encryption isn't available in the browser", "encryption"],
// The plugin runtime is a Node process. Nothing here runs one.
cmd_reload_plugins: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugin_info: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_search: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_install: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_install_from_directory: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_workspace_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_folder_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_http_authentication_action: ["Plugins aren't available in the browser yet", "plugins"],
// Sending history and its bookkeeping belong to the send slice.
cmd_delete_send_history: ["Sending isn't available in the browser yet", null],
cmd_delete_all_http_responses: ["Sending isn't available in the browser yet", null],
cmd_send_feedback: ["Feedback goes through the desktop app for now", null],
};
/**
* The support table, for documentation and for the console.
*
* Derived from the two maps above rather than written alongside them, so it
* cannot drift from what the host actually does.
*/
export function commandSupport(): {
implemented: string[];
declined: { cmd: string; reason: string; capability: CapabilityName | null }[];
} {
return {
implemented: Object.keys(HANDLERS).sort(),
declined: Object.entries(DECLINED)
.map(([cmd, [reason, capability]]) => ({ cmd, reason, capability }))
.sort((a, b) => a.cmd.localeCompare(b.cmd)),
};
}
export async function runCommand(
cmd: string,
payload: RpcPayload,
db: WorkerConnection,
): Promise<unknown> {
const handler = HANDLERS[cmd as AppCmd];
if (handler != null) return handler(payload, db);
const declined = DECLINED[cmd as AppCmd];
if (declined != null) throw unsupported(cmd, declined[0], declined[1]);
// Git and sync land here, along with anything added to the schema since. The
// message names the command because an unlisted one is a gap in this file,
// and whoever hits it should be able to see which.
throw unsupported(cmd, `\`${cmd}\` isn't available when Yaak runs in a browser`);
}
-230
View File
@@ -1,230 +0,0 @@
/**
* A tab's end of the wire to the worker that owns the database.
*
* Constructible synchronously and usable immediately, which is the hard
* requirement: boot-time modules call commands while the module graph is still
* evaluating, so there is no later moment to connect in. Messages posted before
* the worker has opened the database sit in the port until it has, and the
* app's own top-level await then doubles as the boot gate nothing renders
* until the first command has answered, and it can only answer once the
* database is open.
*/
import type { Unsubscribe } from "../types";
import { type FromWorker, type ToWorker, WORKER_NAME } from "./protocol";
/**
* How long a freshly connected worker gets to say hello.
*
* A live worker answers in the same turn it is connected the worker script
* is tiny and imports the model layer lazily, so this measures liveness, not
* load time. It only has to be longer than a cold fetch of that small script;
* a worker silent past this is not coming, and a message beats a blank page.
*/
const HELLO_TIMEOUT_MS = 3000;
const WORKER_FAILED = "Yaak's database worker could not be started. Reload the page to try again";
const UNSUPPORTED =
"This browser can't run Yaak: it needs shared workers and Web Locks to keep your data safe across tabs. Every current browser has both.";
type Pending = { resolve: (value: unknown) => void; reject: (reason: Error) => void };
export class WorkerConnection {
private port: MessagePort | null;
private readonly pending = new Map<number, Pending>();
private readonly listeners = new Map<string, Set<(payload: unknown) => void>>();
private nextId = 1;
private bootError: string | null = null;
/**
* This tab's identity, standing in for the desktop's window label. Stamped
* on every write this tab makes, so the store can tell an echo of its own
* write from another tab's. Minted per page load, not kept in
* `sessionStorage`, on purpose: duplicating a tab copies session storage,
* and two tabs claiming one identity would each drop the other's writes as
* echoes.
*/
readonly label = `tab_${crypto.randomUUID().slice(0, 8)}`;
/** True once the worker has said anything at all. */
private heard = false;
constructor() {
// Both are required and neither is faked. Without a shared worker every
// tab would need its own SQLite over the same pages; without Web Locks
// nothing can promise there is only one even so. Every current browser,
// desktop and mobile, has both (Chrome for Android since 148); the ones
// that don't get told, not corrupted.
if (typeof SharedWorker === "undefined" || typeof navigator.locks === "undefined") {
this.port = null;
this.bootError = UNSUPPORTED;
showBootError(UNSUPPORTED);
return;
}
this.port = this.connect();
// Let the worker forget this port. Not load-bearing — a port that never
// says goodbye is a leaked entry in a Set — but tidy.
window.addEventListener("pagehide", () => this.post({ type: "goodbye" }));
}
/**
* Connect to the origin's one database worker.
*
* The browser hands every tab the same SharedWorker for this name and URL,
* which is what makes "one database owner" true without anyone coordinating.
*
* `new URL("./worker.ts", import.meta.url)` is written out inline on purpose:
* that exact syntax is what the bundler pattern-matches to know it must
* bundle a worker entry. Hoisted into a variable it becomes an asset URL and
* ships as raw TypeScript.
*/
private connect(): MessagePort {
const worker = new SharedWorker(new URL("./worker.ts", import.meta.url), {
type: "module",
name: WORKER_NAME,
});
// A worker whose script fails to load fires `error` on the SharedWorker
// object and nothing else — the port just goes quiet.
worker.onerror = () => {
if (!this.heard) this.failEverything(`${WORKER_FAILED} (its script failed to load).`);
};
worker.port.onmessage = (e: MessageEvent<FromWorker>) => this.receive(e.data);
worker.port.start();
// The worker says hello synchronously on connect. Silence past the timeout
// means this port is attached to nothing that will ever answer, and the
// user should see that rather than a blank page. It is not retried: the
// one way this used to happen (a module worker missing connects during a
// top-level-await import) is fixed at the source by importing the wasm
// lazily, and a reload is the right remedy for anything else.
setTimeout(() => {
if (!this.heard) this.failEverything(`${WORKER_FAILED} (it never answered).`);
}, HELLO_TIMEOUT_MS);
return worker.port;
}
private failEverything(message: string): void {
this.bootError = message;
showBootError(message);
for (const [id, p] of this.pending) {
this.pending.delete(id);
p.reject(new Error(message));
}
}
private post(message: ToWorker, transfer: Transferable[] = []): void {
this.port?.postMessage(message, transfer);
}
private receive(message: FromWorker): void {
this.heard = true;
switch (message.type) {
case "hello":
case "ready":
return;
case "boot_error":
// Nothing will ever answer, and the app cannot render without an
// answer, so say what happened where the user can see it. This is the
// page's whole content at this point.
this.failEverything(message.message);
return;
case "result": {
const p = this.pending.get(message.id);
this.pending.delete(message.id);
p?.resolve(message.result);
return;
}
case "error": {
const p = this.pending.get(message.id);
this.pending.delete(message.id);
p?.reject(new Error(message.message));
return;
}
case "event":
this.deliver(message.event, message.payload);
return;
}
}
private request<T>(build: (id: number) => ToWorker, transfer: Transferable[] = []): Promise<T> {
if (this.bootError != null) return Promise.reject(new Error(this.bootError));
const id = this.nextId++;
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
this.post(build(id), transfer);
});
}
rpc<T>(cmd: string, payload: unknown): Promise<T> {
return this.request<T>((id) => ({ type: "rpc", id, cmd, payload, label: this.label }));
}
async blobGet(blobId: string): Promise<Uint8Array<ArrayBuffer> | null> {
const buf = await this.request<ArrayBuffer | null>((id) => ({ type: "blob_get", id, blobId }));
return buf == null ? null : new Uint8Array(buf);
}
blobPut(blobId: string, bytes: Uint8Array): Promise<void> {
// Copied so the caller's buffer isn't detached out from under it, then
// transferred so the copy isn't copied again crossing to the worker.
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return this.request<void>(
(id) => ({ type: "blob_put", id, blobId, bytes: copy.buffer }),
[copy.buffer],
);
}
blobDelete(blobId: string): Promise<void> {
return this.request<void>((id) => ({ type: "blob_delete", id, blobId }));
}
/* ------------------------------- events -------------------------------- */
listen(event: string, callback: (payload: unknown) => void): Unsubscribe {
let set = this.listeners.get(event);
if (set == null) {
set = new Set();
this.listeners.set(event, set);
}
set.add(callback);
return () => {
set.delete(callback);
if (set.size === 0) this.listeners.delete(event);
};
}
/**
* Deliver an event to this tab's listeners.
*
* Used for what the worker pushes, and for the app's own local emits (a
* plugin round trip, a stream teardown). Local emits stay local: every
* emitter in the app is replying to something *this* tab is doing.
*/
deliver(event: string, payload: unknown): void {
const set = this.listeners.get(event);
if (set == null) return;
// Copied because a listener may unsubscribe itself while being called
for (const callback of Array.from(set)) {
try {
callback(payload);
} catch (err) {
console.error(`Listener for \`${event}\` threw`, err);
}
}
}
}
function showBootError(message: string): void {
const root = document.getElementById("root");
if (root == null || root.childElementCount > 0) return;
const el = document.createElement("div");
el.style.cssText =
"font: 15px/1.5 system-ui, sans-serif; max-width: 32rem; margin: 20vh auto; padding: 0 1rem; color: inherit";
el.textContent = message;
root.appendChild(el);
}
-32
View File
@@ -1,32 +0,0 @@
import type { CapabilityName } from "../types";
/**
* What this host says when it is asked for something it doesn't have.
*
* A rejected command reaches the user as a toast built from `message`, so the
* message is the user-facing text and has to read like one. The structured
* fields alongside it are for code: `capability` names the switch a caller
* should have checked first, and `cmd` identifies the command without anyone
* having to parse prose back out of the message.
*/
export class UnsupportedCommandError extends Error {
readonly name = "UnsupportedCommandError";
/** Stable discriminator, so a caller can branch without matching on text. */
readonly code = "unsupported_command";
readonly cmd: string;
readonly capability: CapabilityName | null;
constructor(cmd: string, message: string, capability: CapabilityName | null = null) {
super(message);
this.cmd = cmd;
this.capability = capability;
}
}
export function unsupported(
cmd: string,
reason: string,
capability: CapabilityName | null = null,
): UnsupportedCommandError {
return new UnsupportedCommandError(cmd, reason, capability);
}
-264
View File
@@ -1,264 +0,0 @@
/**
* The browser host: Yaak in a tab, with no install and nothing running locally.
*
* The desktop host forwards to a Rust process. This one forwards to a worker
* running the same model layer compiled to wasm, over a `MessagePort` instead
* of Tauri's IPC. The worker owns the database and is shared by every tab on
* the origin, so two tabs stay coherent for the same reason two desktop windows
* do: one process holds the data and pushes every write to all of them.
*
* What a page genuinely cannot do is not faked. There is no file dialog, no
* second window, no clipboard read without a prompt, and in this slice no
* sending. Those report false through `capabilities` and refuse with a reason
* if called anyway, so a missing feature shows up as a disabled control or a
* toast that explains itself, never as a silent no-op.
*/
import type {
DragDropEvent,
OsType,
Platform,
PlatformCapabilities,
PlatformWindow,
RpcPayload,
RpcStreamHandle,
Unsubscribe,
} from "../types";
import { commandSupport, runCommand } from "./commands";
import { WorkerConnection } from "./connection";
import { unsupported } from "./errors";
import { requestPersistence } from "./storage";
/** What this host can do, reported honestly. */
function capabilitiesFor(): PlatformCapabilities {
return {
grpc: false,
websocket: false,
git: false,
sync: false,
// Certificates and proxies are decided by whoever puts the bytes on the
// wire. Nothing in the browser does yet.
tlsOptions: false,
// The jar can be edited and stored here; only filling it needs the sender.
cookieJar: true,
localFiles: false,
timeline: false,
// Whether the host can put a *second window* on this data on demand — what
// `cmd_new_child_window` does for Settings and workspace switching. A tab
// can't, so those open in place instead. This is not a claim that nothing
// else is looking: other tabs may well be open on the same worker, and it
// pushes every write to all of them regardless of this flag.
multiWindow: false,
plugins: false,
encryption: false,
updater: false,
// Reading needs a permission prompt at first paint, which is a bad ask for
// an app people paste bearer tokens into. Pasting still works everywhere.
clipboardRead: false,
systemFonts: false,
license: false,
};
}
/** Match `@tauri-apps/plugin-os` spellings so layout code needs no new branch. */
function detectOsType(): OsType {
const ua = navigator.userAgent;
if (/Mac|iPhone|iPad|iPod/.test(ua)) return "macos";
if (/Win/.test(ua)) return "windows";
if (/Android/.test(ua)) return "android";
return "linux";
}
/**
* The Tauri host-plugin commands, which ride outside the RPC envelope.
*
* `set_title` has a real browser equivalent. `set_theme` paints the native
* window frame behind the webview, which a tab has neither of. The license and
* font plugins answer with "nothing", which is true and keeps the settings
* screens rendering instead of erroring.
*/
async function hostPluginCommand<T>(cmd: string, payload?: RpcPayload): Promise<T> {
switch (cmd) {
case "plugin:yaak-mac-window|set_title": {
const title = payload?.title;
document.title = typeof title === "string" ? title : "Yaak";
return undefined as T;
}
case "plugin:yaak-mac-window|set_theme":
return undefined as T;
case "plugin:yaak-fonts|list":
// Enumerating installed fonts is a fingerprinting surface browsers don't
// offer. The pickers fall back to their bundled families.
return { editorFonts: [], uiFonts: [] } as T;
default:
throw unsupported(cmd, `\`${cmd}\` isn't available when Yaak runs in a browser`);
}
}
function createWindow(db: WorkerConnection): PlatformWindow {
const noop = async () => {};
return {
// Stands in for the desktop's window label: the identity model writes carry
// so a tab can tell its own echo from another tab's write.
label: db.label,
// A tab manages its own frame. These exist because the interface names
// them; the UI only reaches for them behind `multiWindow`.
show: noop,
close: noop,
minimize: noop,
maximize: noop,
unmaximize: noop,
isMaximized: async () => false,
isFullscreen: async () => document.fullscreenElement != null,
setZoom: noop,
// Null means "no opinion, let CSS decide". The desktop returns a real value
// because applying a theme forces the window appearance and poisons the
// media query; nothing does that here, so `prefers-color-scheme` is the
// honest answer and the theme package already falls back to it.
theme: async () => null,
onThemeChanged(callback) {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const listener = () => callback(media.matches ? "dark" : "light");
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
},
onFocusChanged(callback) {
const onFocus = () => callback(true);
const onBlur = () => callback(false);
window.addEventListener("focus", onFocus);
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("focus", onFocus);
window.removeEventListener("blur", onBlur);
};
},
// Native drag-and-drop reports OS paths, which a page never sees. The DOM's
// own drag events are a different thing, and the components that need those
// use them directly.
onDragDrop(_callback: (event: DragDropEvent) => void): Unsubscribe {
return () => {};
},
};
}
export function createWebPlatform(): Platform {
const db = new WorkerConnection();
const capabilities = capabilitiesFor();
// Without this, IndexedDB is best-effort storage and a browser reclaiming
// space may drop someone's workspaces. Asking is all we can do, and there is
// nothing useful to do about a refusal.
void requestPersistence();
// Enough to answer "what does this host actually do?" from the console
// without reading the source.
(window as unknown as Record<string, unknown>).__YAAK_WEB__ = {
label: db.label,
capabilities,
commands: commandSupport,
};
return {
capabilities,
window: createWindow(db),
clipboard: {
writeText: (text) => navigator.clipboard.writeText(text),
readText: async () => {
throw unsupported("clipboard.readText", "Paste instead — Yaak in a browser can't read the clipboard on its own", "clipboardRead");
},
clear: async () => {
throw unsupported("clipboard.clear", "Yaak in a browser can't modify the clipboard", "clipboardRead");
},
},
// Returning null rather than throwing: null is what a cancelled dialog
// returns, which every caller already handles.
dialog: {
open: (async () => null) as Platform["dialog"]["open"],
save: async () => null,
},
files: {
readDir: async () => {
throw unsupported("files.readDir", "A browser tab can't browse your filesystem", "localFiles");
},
readText: async () => {
throw unsupported("files.readText", "A browser tab can't read local files", "localFiles");
},
// No filesystem here, so a path is just a string this host echoes back.
url: (path) => path,
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
resolveResource: async (path) => path,
},
/**
* Bodies live in the worker's blob database, addressed by the id they were
* stored under. A page hands over an id and never a location, which is what
* keeps it from naming bytes the app never wrote.
*/
blobs: {
read: (id) => db.blobGet(id),
async url(id) {
const bytes = await db.blobGet(id);
// An object URL, the tab's equivalent of Tauri's `convertFileSrc`. The
// caller keys a query on it and drops it on the next response, so it is
// left to be reclaimed when the document goes rather than revoked here
// while an <img> may still be loading it.
return bytes == null ? null : URL.createObjectURL(new Blob([bytes]));
},
},
rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
// `plugin:` commands are Tauri host plugins, not engine commands, and
// never reached the router even on the desktop.
if (cmd.startsWith("plugin:")) return hostPluginCommand<T>(cmd, payload);
return runCommand(cmd, payload ?? {}, db) as Promise<T>;
},
async rpcStream<T, M>(
cmd: string,
payload: RpcPayload,
onMessage: (message: M) => void,
): Promise<RpcStreamHandle<T>> {
// Same shape as the desktop — subscribe first, then dispatch — so that a
// command which grows the ability to stream here needs no caller changes.
const streamId = crypto.randomUUID();
const unlisten = db.listen(`stream_${streamId}`, (p) => onMessage(p as M));
try {
const result = (await runCommand(cmd, { ...payload, streamId }, db)) as T;
return { result, unlisten };
} catch (err) {
unlisten();
throw err;
}
},
listen<T>(event: string, callback: (payload: T) => void): Unsubscribe {
return db.listen(event, (payload) => callback(payload as T));
},
// Local only. Every emitter in the app is replying to something this tab is
// doing — a plugin round trip, a stream teardown — and telling other tabs
// about it would answer a question they never asked.
emit: async (event, payload) => db.deliver(event, payload),
openUrl: async (url) => {
window.open(url, "_blank", "noopener,noreferrer");
},
revealItemInDir: async () => {
throw unsupported("revealItemInDir", "A browser tab can't open your file manager", "localFiles");
},
osType: detectOsType,
appIdentifier: async () => "app.yaak.web",
};
}
-47
View File
@@ -1,47 +0,0 @@
/**
* The messages that cross between a tab and the worker that owns the database.
*
* Declared once and imported from both sides, so a change to the shape is a
* type error in whichever side forgot. Kept deliberately small: commands in,
* results or errors out, and events pushed the other way the same envelope
* the desktop's IPC uses, because that is what the frontend is written against.
*/
/** Tab → worker */
export type ToWorker =
| { type: "rpc"; id: number; cmd: string; payload: unknown; label: string }
| { type: "blob_get"; id: number; blobId: string }
| { type: "blob_put"; id: number; blobId: string; bytes: ArrayBuffer }
| { type: "blob_delete"; id: number; blobId: string }
/** The tab is going away; the worker can forget its port. */
| { type: "goodbye" };
/** Worker → tab */
export type FromWorker =
/**
* Sent synchronously the moment a port connects, before anything else. Its
* only job is to prove the worker is alive: a tab that connects during a
* shared worker's teardown gets a port that is accepted and then never
* serviced, and this is how it tells that apart from a slow boot.
*/
| { type: "hello" }
/** The database is open. Sent to each port once boot has finished. */
| { type: "ready" }
/** The database could not be opened; every command will fail with this. */
| { type: "boot_error"; message: string }
| { type: "result"; id: number; result: unknown }
| { type: "error"; id: number; message: string }
/** A backend event for the app — today only `model_writes`. Sent to every port. */
| { type: "event"; event: string; payload: unknown };
/** What the worker registers itself under. Tabs on one origin share it. */
export const WORKER_NAME = "yaak-db";
/**
* The Web Lock the worker takes before opening the database.
*
* The browser already guarantees one SharedWorker per origin for this name.
* The lock covers the one overlap it doesn't rule out: a tab reloading itself,
* whose old worker may still be letting go while the new one comes up.
*/
export const DB_LOCK_NAME = "yaak-db";
-18
View File
@@ -1,18 +0,0 @@
/**
* Ask the browser not to evict this origin's data under storage pressure.
*
* Without it IndexedDB where the worker's SQLite pages live is "best
* effort", and a browser clearing space can drop a user's workspaces. Granting
* is the browser's call; it typically says yes once a site looks installed or
* engaged, and often says no on localhost. This is a request, not a guarantee,
* and there is nothing useful to do when it declines.
*/
export async function requestPersistence(): Promise<boolean> {
try {
if (navigator.storage?.persist == null) return false;
if (await navigator.storage.persisted()) return true;
return await navigator.storage.persist();
} catch {
return false;
}
}

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