mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-16 16:42:01 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f91f76064 | ||
|
|
32e92d484b | ||
|
|
cdbbef34f8 | ||
|
|
4838353585 | ||
|
|
93001e3da7 |
Generated
+329
-48
@@ -2,6 +2,18 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "accessory"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc86a613208ca7d144ca24f6ec49c5cdfa5c05a46f2f7ab5880ff99371f165de"
|
||||
dependencies = [
|
||||
"macroific 3.0.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.0"
|
||||
@@ -40,18 +52,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "0.6.10"
|
||||
@@ -1066,10 +1066,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.26"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac"
|
||||
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
@@ -1386,6 +1387,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
@@ -1870,6 +1881,20 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
|
||||
|
||||
[[package]]
|
||||
name = "delegate-display"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54e3a499943fd5180aeffcb708164ffb22125ceeccb05491b5297981d37d6249"
|
||||
dependencies = [
|
||||
"impartial-ord",
|
||||
"itoa",
|
||||
"macroific 3.0.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
@@ -2361,6 +2386,18 @@ dependencies = [
|
||||
"regex-syntax 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy_constructor"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a8211ab12c36b63c269e17873331dc1da196a3edb7a5c79015677ef972da34a"
|
||||
dependencies = [
|
||||
"macroific 3.0.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fast-glob"
|
||||
version = "1.0.0"
|
||||
@@ -2417,6 +2454,12 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.4.2"
|
||||
@@ -2849,8 +2892,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3091,7 +3136,7 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
dependencies = [
|
||||
"ahash 0.7.8",
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3099,9 +3144,6 @@ name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -3131,11 +3173,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3497,6 +3539,17 @@ dependencies = [
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "impartial-ord"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc680e03d1a08bdc1c01b60e0c39723430ced63d8cabd93cee0449b6ba916a25"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include_dir"
|
||||
version = "0.7.4"
|
||||
@@ -3516,6 +3569,40 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexed_db_futures"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69ff41758cbd104e91033bb53bc449bec7eea65652960c81eddf3fc146ecea19"
|
||||
dependencies = [
|
||||
"accessory",
|
||||
"cfg-if",
|
||||
"delegate-display",
|
||||
"derive_more 2.1.1",
|
||||
"fancy_constructor",
|
||||
"indexed_db_futures_macros_internal",
|
||||
"js-sys",
|
||||
"sealed 0.6.0",
|
||||
"smallvec",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexed_db_futures_macros_internal"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caeba94923b68f254abef921cea7e7698bf4675fdd89d7c58bf1ed885b49a27d"
|
||||
dependencies = [
|
||||
"macroific 2.0.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -3561,17 +3648,6 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inherent"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c38228f24186d9cc68c729accb4d413be9eaed6ad07ff79e0270d9e56f3de13"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.0"
|
||||
@@ -4007,9 +4083,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -4109,6 +4185,102 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "macroific"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285"
|
||||
dependencies = [
|
||||
"macroific_attr_parse 2.0.0",
|
||||
"macroific_core 2.0.0",
|
||||
"macroific_macro 2.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c437abb3bf40939b00d1dff858ed2050d948bc230dee39546ed16795fcf10ec2"
|
||||
dependencies = [
|
||||
"macroific_attr_parse 3.0.1",
|
||||
"macroific_core 3.0.1",
|
||||
"macroific_macro 3.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_attr_parse"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sealed 0.6.0",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_attr_parse"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeb7a82ec1aa16094da719be18615d79cb2574b037358e4e6c6d96eedcff3a93"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sealed 0.7.0",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_core"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sealed 0.6.0",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_core"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26c8e789484d2fa216eee9d5e0565ba80ccda389bcf0e111143a7b8f4792336e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sealed 0.7.0",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_macro"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b"
|
||||
dependencies = [
|
||||
"macroific_attr_parse 2.0.0",
|
||||
"macroific_core 2.0.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macroific_macro"
|
||||
version = "3.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e30d63cb102648965753f0d47a6ab171b1cbb1fb5c363f4784ea986782fc7b77"
|
||||
dependencies = [
|
||||
"macroific_attr_parse 3.0.1",
|
||||
"macroific_core 3.0.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -6242,9 +6414,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "r2d2_sqlite"
|
||||
version = "0.25.0"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb14dba8247a6a15b7fdbc7d389e2e6f03ee9f184f87117706d509c092dfe846"
|
||||
checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6"
|
||||
dependencies = [
|
||||
"r2d2",
|
||||
"rusqlite",
|
||||
@@ -7130,10 +7302,20 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"chrono",
|
||||
@@ -7142,6 +7324,7 @@ dependencies = [
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7423,20 +7606,20 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sea-query"
|
||||
version = "0.32.6"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64c91783d1514b99754fc6a4079081dcc2c587dadbff65c48c7f62297443536a"
|
||||
checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"inherent",
|
||||
"itoa",
|
||||
"sea-query-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sea-query-derive"
|
||||
version = "0.4.3"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
|
||||
checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537"
|
||||
dependencies = [
|
||||
"darling 0.20.11",
|
||||
"heck 0.4.1",
|
||||
@@ -7448,9 +7631,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sea-query-rusqlite"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3743bbdfb24b1a84cc1a6fbf4b1188e6851f6e00ea20944b44c56bf03a585bb4"
|
||||
checksum = "1ec6038023c8517c623e5bf9606b3c54d40bc8296bb6b2986040428dd84deddd"
|
||||
dependencies = [
|
||||
"rusqlite",
|
||||
"sea-query",
|
||||
@@ -7462,6 +7645,28 @@ version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "sealed"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sealed"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
@@ -7861,9 +8066,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
@@ -8031,6 +8236,34 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-vfs"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f7a5c9ac229421d577bb5a9bb59048838509958b218dd4e0b3c1214a87c361e"
|
||||
dependencies = [
|
||||
"indexed_db_futures",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.0"
|
||||
@@ -8165,6 +8398,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -11035,6 +11279,7 @@ dependencies = [
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-rpc",
|
||||
"yaak-rpc-schema",
|
||||
"yaak-sse",
|
||||
"yaak-sync",
|
||||
"yaak-system-appearance",
|
||||
@@ -11135,6 +11380,7 @@ name = "yaak-database"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"getrandom 0.2.16",
|
||||
"include_dir",
|
||||
"log 0.4.29",
|
||||
"nanoid",
|
||||
@@ -11147,6 +11393,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11372,6 +11619,22 @@ dependencies = [
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-rpc-schema"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"ts-rs",
|
||||
"yaak-git",
|
||||
"yaak-grpc",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-sse",
|
||||
"yaak-sync",
|
||||
"yaak-templates",
|
||||
"yaak-ws",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-sse"
|
||||
version = "0.1.0"
|
||||
@@ -11437,6 +11700,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"p12",
|
||||
"pem",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-platform-verifier",
|
||||
@@ -11447,6 +11711,23 @@ 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"
|
||||
|
||||
@@ -5,6 +5,7 @@ members = [
|
||||
# Common/foundation crates
|
||||
"crates/common/yaak-database",
|
||||
"crates/common/yaak-rpc",
|
||||
"crates/common/yaak-rpc-schema",
|
||||
# Shared crates (no Tauri dependency)
|
||||
"crates/yaak-core",
|
||||
"crates/yaak-common",
|
||||
@@ -19,6 +20,7 @@ members = [
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-tls",
|
||||
"crates/yaak-ws",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-api",
|
||||
"crates/yaak-proxy",
|
||||
# Proxy-specific crates
|
||||
@@ -63,6 +65,7 @@ ts-rs = "11.1.0"
|
||||
# Internal crates - common/foundation
|
||||
yaak-database = { path = "crates/common/yaak-database" }
|
||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
||||
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
|
||||
|
||||
# Internal crates - shared
|
||||
yaak-core = { path = "crates/yaak-core" }
|
||||
|
||||
@@ -44,6 +44,25 @@ After bootstrapping, start the app in development mode:
|
||||
npm start
|
||||
```
|
||||
|
||||
## Run the App in a Browser
|
||||
|
||||
The client can also run as a plain web page, with no Tauri and no local process
|
||||
behind it. Set `YAAK_TARGET=web` and start the frontend on its own:
|
||||
|
||||
```shell
|
||||
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||
```
|
||||
|
||||
That flag picks the browser host in `packages/platform/src/web/`, which answers
|
||||
commands from an IndexedDB database the page owns instead of from the Rust
|
||||
engine. Data persists across reloads and is shared between tabs on the same
|
||||
origin. Sending HTTP is not available yet — the Send button reports that and
|
||||
everything else about the request is still saved. `packages/platform/src/web/README.md`
|
||||
lists which commands the browser host implements and which it declines.
|
||||
|
||||
Desktop builds are unaffected: without the flag the platform package installs
|
||||
the Tauri host exactly as before.
|
||||
|
||||
## SQLite Migrations
|
||||
|
||||
New migrations can be created from the `src-tauri/` directory:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { SettingsTab } from "../components/Settings/Settings";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
@@ -14,11 +15,19 @@ export const openSettings = createFastMutation<void, string, SettingsTabWithSubt
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId/settings",
|
||||
params: { workspaceId },
|
||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||
});
|
||||
const to = "/workspaces/$workspaceId/settings" as const;
|
||||
const params = { workspaceId };
|
||||
const search = { tab: (tab ?? undefined) as SettingsTab | undefined };
|
||||
|
||||
// Settings is its own window where the host has windows to give. Where it
|
||||
// doesn't — a browser tab — the same route opens in place, which is the
|
||||
// whole difference: it is already a route, not a separate app.
|
||||
if (!platform.capabilities.multiWindow) {
|
||||
await router.navigate({ to, params, search });
|
||||
return;
|
||||
}
|
||||
|
||||
const location = router.buildLocation({ to, params, search });
|
||||
|
||||
await rpc("cmd_new_child_window", {
|
||||
url: location.href,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||
@@ -24,7 +25,9 @@ export const switchWorkspace = createFastMutation<
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
if (inNewWindow) {
|
||||
// A host without windows opens the workspace here instead. Refusing would
|
||||
// strand the user on the workspace they were trying to leave.
|
||||
if (inNewWindow && platform.capabilities.multiWindow) {
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RpcPayload } from "@yaakapp-internal/platform";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { RpcSchema } from "@yaakapp-internal/tauri-client";
|
||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||
|
||||
/**
|
||||
* Every backend command the app can call: the generated wire schema, one field
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import type { EphemeralHttpResponse } from "@yaakapp-internal/tauri-client";
|
||||
import type { EphemeralHttpResponse } from "@yaakapp-internal/rpc-schema";
|
||||
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
|
||||
@@ -17,9 +17,37 @@ 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({
|
||||
|
||||
@@ -10,9 +10,9 @@ chrono = { workspace = true, features = ["serde"] }
|
||||
log = { workspace = true }
|
||||
include_dir = "0.7"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = "0.25.0"
|
||||
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
|
||||
r2d2_sqlite = "0.32"
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
ts-rs = { workspace = true, features = ["chrono-impl"] }
|
||||
|
||||
@@ -39,7 +39,7 @@ md5 = "0.8.0"
|
||||
notify = "8.0.0"
|
||||
pretty_graphql = "0.2"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = "0.25.0"
|
||||
r2d2_sqlite = "0.32"
|
||||
mime_guess = "2.0.5"
|
||||
rand = "0.9.0"
|
||||
reqwest = { workspace = true, features = [
|
||||
@@ -73,6 +73,7 @@ url = "2"
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
ts-rs = { workspace = true }
|
||||
yaak-rpc = { workspace = true }
|
||||
yaak-rpc-schema = { workspace = true }
|
||||
uuid = "1.12.1"
|
||||
yaak-api = { workspace = true }
|
||||
yaak-common = { workspace = true }
|
||||
|
||||
-4
@@ -1,7 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type GitWatchResult = { unlistenEvent: string, };
|
||||
|
||||
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
|
||||
|
||||
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
|
||||
@@ -12,8 +10,6 @@ export type UpdateResponse = { "type": "ack" } | { "type": "action", action: Upd
|
||||
|
||||
export type UpdateResponseAction = "install" | "skip";
|
||||
|
||||
export type WatchResult = { unlistenEvent: string, };
|
||||
|
||||
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
|
||||
|
||||
export type YaakNotificationAction = { label: string, url: string, };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ts-rs owns bindings/index.ts and rewrites it on export, so this hand-written
|
||||
// entry point is where the generated files come together.
|
||||
export * from "./bindings/gen_rpc";
|
||||
// ts-rs owns bindings/index.ts and rewrites it on export. What remains here
|
||||
// after the RPC schema moved to @yaakapp-internal/rpc-schema is the
|
||||
// desktop-only surface: updater and notification types.
|
||||
export * from "./bindings/index";
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::error::{Error, Result};
|
||||
use chrono::Utc;
|
||||
use log::{debug, error, warn};
|
||||
use notify::Watcher;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
@@ -10,18 +9,11 @@ use tauri::{AppHandle, Listener, Runtime};
|
||||
use tokio::select;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::sleep;
|
||||
use ts_rs::TS;
|
||||
use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
|
||||
use yaak_rpc_schema::GitWatchResult;
|
||||
|
||||
const GIT_STATUS_COALESCE_WINDOW: Duration = Duration::from_millis(250);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub(crate) struct GitWatchResult {
|
||||
unlisten_event: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn watch_git_worktree_status<R, F>(
|
||||
app_handle: AppHandle<R>,
|
||||
dir: &Path,
|
||||
|
||||
@@ -8,7 +8,6 @@ use crate::import::{import_data, import_url};
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::render::{render_grpc_request, render_json_value, render_template};
|
||||
use crate::rpc_ext::EphemeralHttpResponse;
|
||||
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
|
||||
use crate::uri_scheme::handle_deep_link;
|
||||
use error::Result as YaakResult;
|
||||
@@ -57,6 +56,7 @@ use yaak_plugins::events::{
|
||||
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;
|
||||
@@ -184,22 +184,6 @@ impl<R: Runtime> PluginContextExt<R> for WebviewWindow<R> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, ts_rs::TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct AppMetaData {
|
||||
is_dev: bool,
|
||||
version: String,
|
||||
cli_version: Option<String>,
|
||||
name: String,
|
||||
app_data_dir: String,
|
||||
app_log_dir: String,
|
||||
vendored_plugin_dir: String,
|
||||
default_project_dir: String,
|
||||
feature_updater: bool,
|
||||
feature_license: bool,
|
||||
}
|
||||
|
||||
async fn cmd_metadata<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<AppMetaData> {
|
||||
let app_data_dir = app_handle.path().app_data_dir()?;
|
||||
let app_log_dir = app_handle.path().app_log_dir()?;
|
||||
|
||||
@@ -144,28 +144,10 @@ pub(crate) fn models_upsert<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
let db = window.db();
|
||||
let blobs = window.blob_manager();
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, &blobs)?.id,
|
||||
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
|
||||
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
|
||||
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
|
||||
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
yaak_models::models_ops::upsert_model(&db, &blobs, model, source)
|
||||
}
|
||||
|
||||
// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a
|
||||
@@ -181,21 +163,7 @@ pub(crate) async fn models_delete<R: Runtime>(
|
||||
// Use transaction for deletions because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
|
||||
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
|
||||
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
|
||||
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, &blobs)?.id,
|
||||
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
|
||||
};
|
||||
Ok(id)
|
||||
yaak_models::models_ops::delete_model(tx, &blobs, model, source)
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -207,31 +175,10 @@ pub(crate) fn models_duplicate<R: Runtime>(
|
||||
model_type: String,
|
||||
model_id: String,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
// Use transaction for duplications because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
// Fetch the model fresh from the DB so the duplicate doesn't come from
|
||||
// a stale frontend snapshot
|
||||
let id = match model_type.as_str() {
|
||||
"environment" => {
|
||||
tx.duplicate_environment(&tx.get_environment(&model_id)?, source)?.id
|
||||
}
|
||||
"folder" => tx.duplicate_folder(&tx.get_folder(&model_id)?, source)?.id,
|
||||
"grpc_request" => {
|
||||
tx.duplicate_grpc_request(&tx.get_grpc_request(&model_id)?, source)?.id
|
||||
}
|
||||
"http_request" => {
|
||||
tx.duplicate_http_request(&tx.get_http_request(&model_id)?, source)?.id
|
||||
}
|
||||
"websocket_request" => {
|
||||
tx.duplicate_websocket_request(&tx.get_websocket_request(&model_id)?, source)?.id
|
||||
}
|
||||
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
yaak_models::models_ops::duplicate_model(tx, &model_type, &model_id, source)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,10 @@ use crate::error::Result;
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use chrono::Utc;
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Listener, Runtime};
|
||||
use tokio::sync::watch;
|
||||
use ts_rs::TS;
|
||||
use yaak_rpc_schema::WatchResult;
|
||||
use yaak_sync::error::Error::InvalidSyncDirectory;
|
||||
use yaak_sync::sync::{
|
||||
FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,
|
||||
@@ -57,13 +56,6 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub(crate) struct WatchResult {
|
||||
unlisten_event: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_watch<R, F>(
|
||||
app_handle: AppHandle<R>,
|
||||
sync_dir: &Path,
|
||||
|
||||
@@ -9,12 +9,20 @@ chrono = { version = "0.4.38", features = ["serde"] }
|
||||
include_dir = "0.7"
|
||||
log = { workspace = true }
|
||||
nanoid = "0.4.0"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = { version = "0.25.0" }
|
||||
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
|
||||
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
|
||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ts-rs = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = { version = "0.32" }
|
||||
|
||||
# nanoid pulls getrandom, which needs to be told how to reach the browser's
|
||||
# CSPRNG on wasm32-unknown-unknown. Native targets are unaffected.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
uuid = { version = "1", features = ["js"] }
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use r2d2::PooledConnection;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use crate::pool::SqliteConn;
|
||||
use rusqlite::{Connection, Statement, ToSql, Transaction};
|
||||
|
||||
pub enum ConnectionOrTx<'a> {
|
||||
Connection(PooledConnection<SqliteConnectionManager>),
|
||||
Connection(SqliteConn),
|
||||
Transaction(&'a Transaction<'a>),
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::error::Error::ModelNotFound;
|
||||
use crate::error::Result;
|
||||
use crate::traits::UpsertModelInfo;
|
||||
use crate::update_source::UpdateSource;
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{
|
||||
Asterisk, Expr, Func, IntoColumnRef, IntoIden, OnConflict, Query, SimpleExpr,
|
||||
SqliteQueryBuilder,
|
||||
|
||||
@@ -7,7 +7,7 @@ pub enum Error {
|
||||
SqlError(#[from] rusqlite::Error),
|
||||
|
||||
#[error("SQL Pool error: {0}")]
|
||||
SqlPoolError(#[from] r2d2::Error),
|
||||
SqlPoolError(#[from] crate::pool::PoolError),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod connection_or_tx;
|
||||
pub mod db_context;
|
||||
pub mod error;
|
||||
pub mod migrate;
|
||||
pub mod pool;
|
||||
pub mod traits;
|
||||
pub mod update_source;
|
||||
pub mod util;
|
||||
@@ -11,13 +12,15 @@ pub use connection_or_tx::ConnectionOrTx;
|
||||
pub use db_context::DbContext;
|
||||
pub use error::{Error, Result};
|
||||
pub use migrate::run_migrations;
|
||||
pub use pool::{PoolError, SqliteConn, SqlitePool};
|
||||
pub use traits::{UpsertModelInfo, upsert_date};
|
||||
pub use update_source::{ModelChangeEvent, UpdateSource};
|
||||
pub use util::{generate_id, generate_id_of_length, generate_prefixed_id};
|
||||
|
||||
// Re-export pool types that consumers will need
|
||||
// Re-export types that consumers will need
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use r2d2;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use r2d2_sqlite;
|
||||
pub use rusqlite;
|
||||
pub use sea_query;
|
||||
pub use sea_query_rusqlite;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use crate::error::Result;
|
||||
use crate::pool::SqlitePool;
|
||||
use include_dir::Dir;
|
||||
use log::{debug, info};
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
const TRACKING_TABLE: &str = "_sqlx_migrations";
|
||||
@@ -11,7 +10,7 @@ const TRACKING_TABLE: &str = "_sqlx_migrations";
|
||||
///
|
||||
/// Migrations are sorted by filename (use timestamp prefixes like `00000001_init.sql`).
|
||||
/// Applied migrations are tracked in `_sqlx_migrations`.
|
||||
pub fn run_migrations(pool: &Pool<SqliteConnectionManager>, dir: &Dir<'_>) -> Result<()> {
|
||||
pub fn run_migrations(pool: &SqlitePool, dir: &Dir<'_>) -> Result<()> {
|
||||
info!("Running migrations");
|
||||
|
||||
// Create tracking table
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Where connections come from.
|
||||
//!
|
||||
//! Every query in the model layer asks a pool for a connection, uses it, and
|
||||
//! hands it back. That is the whole contract, and it is the one place the
|
||||
//! desktop and the browser genuinely differ: the desktop has threads and wants
|
||||
//! an r2d2 pool; a browser tab has one thread, no way to spawn another, and one
|
||||
//! connection is exactly enough. Everything above this module is identical on
|
||||
//! both.
|
||||
//!
|
||||
//! On native targets `SqlitePool` *is* `r2d2::Pool` — a type alias, so nothing
|
||||
//! that already builds pools changes. On wasm it is one connection that every
|
||||
//! `get()` hands out a shared handle to.
|
||||
//!
|
||||
//! A `SqliteConn` only ever derefs immutably. The code above this layer opens
|
||||
//! transactions with [`rusqlite::Transaction::new_unchecked`], which takes
|
||||
//! `&Connection`; the `&mut` that `Connection::transaction` demands is a
|
||||
//! compile-time guard against nesting a transaction on one connection, and it
|
||||
//! is what would have forced the wasm pool to lend its connection exclusively.
|
||||
//! The model layer nests connections freely — a helper that already holds one
|
||||
//! calls another that asks for its own — so an exclusive lend would panic on
|
||||
//! the second ask. Sharing the handle instead makes nested *reads* work the way
|
||||
//! they do on the desktop; nested *write transactions* fail on both, only
|
||||
//! differently (here SQLite refuses the inner `BEGIN`; natively the inner
|
||||
//! connection blocks on `busy_timeout` and then fails).
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod imp {
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
|
||||
pub type SqlitePool = r2d2::Pool<SqliteConnectionManager>;
|
||||
pub type SqliteConn = r2d2::PooledConnection<SqliteConnectionManager>;
|
||||
pub type PoolError = r2d2::Error;
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod imp {
|
||||
use rusqlite::Connection;
|
||||
use std::ops::Deref;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// One connection, shared by everyone who asks.
|
||||
///
|
||||
/// `Rc` rather than `Arc` because a `Connection` is `!Sync`, so wrapping
|
||||
/// it in an `Arc` would buy no `Send`/`Sync` anyway — and there is one
|
||||
/// thread here to be honest about.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SqlitePool {
|
||||
conn: Rc<Connection>,
|
||||
}
|
||||
|
||||
impl SqlitePool {
|
||||
pub fn single(conn: Connection) -> Self {
|
||||
Self { conn: Rc::new(conn) }
|
||||
}
|
||||
|
||||
/// Another handle to the connection. Cannot fail; the `Result` keeps
|
||||
/// the signature identical to r2d2's so callers are written once.
|
||||
pub fn get(&self) -> Result<SqliteConn, PoolError> {
|
||||
Ok(SqliteConn(self.conn.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a `get()` would return if it could. It can't, so this has no
|
||||
/// variants; it exists so `Error::SqlPoolError` has the same shape on both
|
||||
/// targets.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PoolError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SqliteConn(Rc<Connection>);
|
||||
|
||||
impl Deref for SqliteConn {
|
||||
type Target = Connection;
|
||||
fn deref(&self) -> &Connection {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use imp::*;
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "yaak-rpc-schema"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
ts-rs = { workspace = true }
|
||||
yaak-git = { workspace = true }
|
||||
yaak-grpc = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-sse = { workspace = true }
|
||||
yaak-sync = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
yaak-ws = { workspace = true }
|
||||
@@ -0,0 +1,44 @@
|
||||
# yaak-rpc-schema
|
||||
|
||||
The wire schema for the app's RPC surface: every command name, its request
|
||||
payload, and its response type, declared once.
|
||||
|
||||
Every host that serves the Yaak UI — the desktop app today, the browser bridge
|
||||
and anything after it — imports these types and implements the commands against
|
||||
them. That is what keeps a request's shape from drifting between hosts, and it
|
||||
is why the TypeScript bindings (`bindings/gen_rpc.ts`, exposed to the frontend
|
||||
as `@yaakapp-internal/rpc-schema`) are generated from one place.
|
||||
|
||||
Nothing here depends on Tauri or on any host. Request structs are plain data,
|
||||
and so are the few response types declared here rather than in an engine crate.
|
||||
Command *bodies* live with the host that runs them.
|
||||
|
||||
## Adding a command
|
||||
|
||||
1. Add its request struct and an entry in `with_commands!` in `src/lib.rs`.
|
||||
2. Write the adapter in each host — the desktop's live in
|
||||
`crates-tauri/yaak-app-client/src/rpc_ext.rs`. A host that does not support
|
||||
the command still has to say so; a missing adapter fails to compile.
|
||||
3. Regenerate the bindings: `cargo test -p yaak-rpc-schema` writes
|
||||
`bindings/gen_rpc.ts`, which is committed.
|
||||
|
||||
## How hosts consume the list
|
||||
|
||||
`with_commands!` takes the name of a `macro_rules!` macro and calls it with the
|
||||
full `name(Req) -> Res` list. Each host writes a small macro that receives that
|
||||
list and builds its router:
|
||||
|
||||
```rust
|
||||
macro_rules! register_commands {
|
||||
( $( $name:ident ( $req:ty ) -> $res:ty ),* $(,)? ) => {
|
||||
pub fn build_router() -> RpcRouter<MyCtx> {
|
||||
let mut router = RpcRouter::new();
|
||||
$( router.register(stringify!($name), rpc_handler_async!($name)); )*
|
||||
router
|
||||
}
|
||||
};
|
||||
}
|
||||
yaak_rpc_schema::with_commands!(register_commands);
|
||||
```
|
||||
|
||||
The schema decides *what* commands exist; the host decides *how* each one runs.
|
||||
crates-tauri/yaak-app-client/bindings/gen_rpc.ts → crates/common/yaak-rpc-schema/bindings/gen_rpc.ts
Generated
+11
-5
File diff suppressed because one or more lines are too long
Generated
Generated
@@ -0,0 +1,4 @@
|
||||
// The RPC wire schema, generated by ts-rs from the Rust declarations in
|
||||
// src/lib.rs. `RpcSchema` maps every command name to its (request, response)
|
||||
// pair; the app's `rpc()` helper derives its command union from it.
|
||||
export * from "./bindings/gen_rpc";
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/rpc-schema",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@ use log::{debug, info, warn};
|
||||
use reqwest::{Client, ClientBuilder, Proxy, redirect};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use yaak_models::models::DnsOverride;
|
||||
use yaak_tls::{ClientCertificateConfig, get_tls_config, load_client_identity_pkcs12};
|
||||
use yaak_tls::{
|
||||
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
|
||||
};
|
||||
|
||||
pub const HTTP2_MAX_RESPONSE_HEADER_LIST_SIZE: u32 = 1024 * 1024;
|
||||
|
||||
@@ -61,12 +63,19 @@ static IDENTITY_IMPORT: Mutex<()> = Mutex::new(());
|
||||
fn build_native_tls_identity(
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
) -> Result<Option<native_tls::Identity>> {
|
||||
let Some((pkcs12, password)) = load_client_identity_pkcs12(client_cert)? else {
|
||||
let Some(material) = load_native_client_identity(client_cert)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let _guard = IDENTITY_IMPORT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
Ok(Some(native_tls::Identity::from_pkcs12(&pkcs12, &password)?))
|
||||
Ok(Some(match material {
|
||||
NativeClientIdentity::Pkcs12 { data, password } => {
|
||||
native_tls::Identity::from_pkcs12(&data, &password)?
|
||||
}
|
||||
NativeClientIdentity::Pkcs8 { chain_pem, key_pem } => {
|
||||
native_tls::Identity::from_pkcs8(&chain_pem, &key_pem)?
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -11,11 +11,9 @@ hex = { workspace = true }
|
||||
include_dir = "0.7"
|
||||
log = { workspace = true }
|
||||
nanoid = "0.4.0"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = { version = "0.25.0" }
|
||||
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
|
||||
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
@@ -23,3 +21,7 @@ sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
|
||||
yaak-core = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = { version = "0.32" }
|
||||
|
||||
@@ -2,9 +2,8 @@ use crate::error::Result;
|
||||
use crate::util::generate_prefixed_id;
|
||||
use include_dir::{Dir, include_dir};
|
||||
use log::{debug, info};
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use yaak_database::{SqliteConn, SqlitePool};
|
||||
|
||||
static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations");
|
||||
|
||||
@@ -29,11 +28,11 @@ impl BodyChunk {
|
||||
// whole app whenever the pool is exhausted.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlobManager {
|
||||
pool: Pool<SqliteConnectionManager>,
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl BlobManager {
|
||||
pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
@@ -45,7 +44,7 @@ impl BlobManager {
|
||||
|
||||
/// Context for blob database operations.
|
||||
pub struct BlobContext {
|
||||
conn: r2d2::PooledConnection<SqliteConnectionManager>,
|
||||
conn: SqliteConn,
|
||||
}
|
||||
|
||||
impl BlobContext {
|
||||
@@ -131,7 +130,7 @@ impl BlobContext {
|
||||
}
|
||||
|
||||
/// Run migrations for the blob database.
|
||||
pub fn migrate_blob_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
|
||||
pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
|
||||
info!("Running blob database migrations");
|
||||
|
||||
// Create migrations tracking table
|
||||
@@ -198,9 +197,9 @@ pub fn migrate_blob_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_pool() -> Pool<SqliteConnectionManager> {
|
||||
let manager = SqliteConnectionManager::memory();
|
||||
let pool = Pool::builder().max_size(1).build(manager).unwrap();
|
||||
fn create_test_pool() -> SqlitePool {
|
||||
let manager = r2d2_sqlite::SqliteConnectionManager::memory();
|
||||
let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap();
|
||||
migrate_blob_db(&pool).unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ pub enum Error {
|
||||
SqlError(#[from] rusqlite::Error),
|
||||
|
||||
#[error("SQL Pool error: {0}")]
|
||||
SqlPoolError(#[from] r2d2::Error),
|
||||
SqlPoolError(#[from] yaak_database::PoolError),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
use crate::blob_manager::{BlobManager, migrate_blob_db};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::error::Result;
|
||||
use crate::migrate::migrate_db;
|
||||
use crate::query_manager::QueryManager;
|
||||
use crate::util::ModelPayload;
|
||||
use log::info;
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use std::fs::create_dir_all;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use yaak_database::SqlitePool;
|
||||
|
||||
pub mod blob_manager;
|
||||
pub mod client_db;
|
||||
@@ -17,22 +14,85 @@ mod connection_or_tx;
|
||||
pub mod error;
|
||||
pub mod migrate;
|
||||
pub mod models;
|
||||
pub mod models_ops;
|
||||
pub mod queries;
|
||||
pub mod query_manager;
|
||||
pub mod render;
|
||||
pub mod util;
|
||||
|
||||
fn sqlite_file_manager(path: impl Into<PathBuf>) -> SqliteConnectionManager {
|
||||
SqliteConnectionManager::file(path.into()).with_init(|conn| {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.busy_timeout(Duration::from_millis(5000))
|
||||
})
|
||||
/// Per-connection setup, applied by every pool on every connection it opens.
|
||||
fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
conn.busy_timeout(std::time::Duration::from_millis(5000))
|
||||
}
|
||||
|
||||
fn sqlite_memory_manager() -> SqliteConnectionManager {
|
||||
SqliteConnectionManager::memory()
|
||||
.with_init(|conn| conn.busy_timeout(Duration::from_millis(5000)))
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn init_file_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
init_connection(conn)
|
||||
}
|
||||
|
||||
/// The two ways a pool comes to exist, one per target.
|
||||
///
|
||||
/// On the desktop and CLI, an r2d2 pool over a file. In a browser, a single
|
||||
/// connection over whatever VFS the host registered before calling in — the
|
||||
/// path is a name inside that VFS, not a place on disk. Everything downstream
|
||||
/// of `SqlitePool` is target-agnostic; this is the only fork.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod open {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn file_pool(path: impl Into<PathBuf>, max_size: u32, min_idle: u32) -> Result<SqlitePool> {
|
||||
let path: PathBuf = path.into();
|
||||
// Create parent directories if needed
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let manager = SqliteConnectionManager::file(path).with_init(|c| init_file_connection(c));
|
||||
Pool::builder()
|
||||
.max_size(max_size)
|
||||
.min_idle(Some(min_idle))
|
||||
.connection_timeout(Duration::from_secs(10))
|
||||
.build(manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn memory_pool() -> Result<SqlitePool> {
|
||||
let manager = SqliteConnectionManager::memory().with_init(|c| init_connection(c));
|
||||
// In-memory DB doesn't support multiple connections
|
||||
Pool::builder().max_size(1).build(manager).map_err(|e| Error::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod open {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn file_pool(
|
||||
path: impl Into<PathBuf>,
|
||||
_max_size: u32,
|
||||
_min_idle: u32,
|
||||
) -> Result<SqlitePool> {
|
||||
// No WAL: the browser VFSs are single-connection and journal their own
|
||||
// way; the pragma is accepted and ignored on some and rejected on
|
||||
// others, so it is not applied at all here.
|
||||
let conn = Connection::open(path.into())?;
|
||||
init_connection(&conn)?;
|
||||
Ok(SqlitePool::single(conn))
|
||||
}
|
||||
|
||||
pub fn memory_pool() -> Result<SqlitePool> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
init_connection(&conn)?;
|
||||
Ok(SqlitePool::single(conn))
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the database managers for standalone (non-Tauri) usage.
|
||||
@@ -46,40 +106,16 @@ pub fn init_standalone(
|
||||
let db_path = db_path.as_ref();
|
||||
let blob_path = blob_path.as_ref();
|
||||
|
||||
// Create parent directories if needed
|
||||
if let Some(parent) = db_path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
if let Some(parent) = blob_path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Main database pool. Sized for concurrent in-flight queries, not concurrent app
|
||||
// features — connections are held per-statement, so even heavy fan-out (e.g. many
|
||||
// gRPC streams) only needs a handful at once. Keep max_size modest: WAL connections
|
||||
// hold ~3 file descriptors each, and macOS GUI apps get a 256 fd soft limit.
|
||||
info!("Initializing app database {db_path:?}");
|
||||
let manager = sqlite_file_manager(db_path);
|
||||
let pool = Pool::builder()
|
||||
.max_size(20)
|
||||
.min_idle(Some(2))
|
||||
.connection_timeout(Duration::from_secs(10))
|
||||
.build(manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
let pool = open::file_pool(db_path, 20, 2)?;
|
||||
migrate_db(&pool)?;
|
||||
|
||||
info!("Initializing blobs database {blob_path:?}");
|
||||
|
||||
// Blob database pool
|
||||
let blob_manager = sqlite_file_manager(blob_path);
|
||||
let blob_pool = Pool::builder()
|
||||
.max_size(10)
|
||||
.min_idle(Some(1))
|
||||
.connection_timeout(Duration::from_secs(10))
|
||||
.build(blob_manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
let blob_pool = open::file_pool(blob_path, 10, 1)?;
|
||||
migrate_blob_db(&blob_pool)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
@@ -92,22 +128,10 @@ pub fn init_standalone(
|
||||
/// Initialize the database managers with in-memory SQLite databases.
|
||||
/// Useful for testing and CI environments.
|
||||
pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
|
||||
// Main database pool
|
||||
let manager = sqlite_memory_manager();
|
||||
let pool = Pool::builder()
|
||||
.max_size(1) // In-memory DB doesn't support multiple connections
|
||||
.build(manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
let pool = open::memory_pool()?;
|
||||
migrate_db(&pool)?;
|
||||
|
||||
// Blob database pool
|
||||
let blob_manager = sqlite_memory_manager();
|
||||
let blob_pool = Pool::builder()
|
||||
.max_size(1)
|
||||
.build(blob_manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
let blob_pool = open::memory_pool()?;
|
||||
migrate_blob_db(&blob_pool)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
@@ -2,14 +2,13 @@ use crate::error::Error::MigrationError;
|
||||
use crate::error::Result;
|
||||
use include_dir::{Dir, DirEntry, include_dir};
|
||||
use log::{debug, info};
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
||||
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
|
||||
use sha2::{Digest, Sha384};
|
||||
use yaak_database::SqlitePool;
|
||||
|
||||
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
|
||||
|
||||
pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
|
||||
pub fn migrate_db(pool: &SqlitePool) -> Result<()> {
|
||||
info!("Running database migrations");
|
||||
|
||||
// Ensure the table exists
|
||||
@@ -43,8 +42,10 @@ pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
|
||||
let mut ran_migrations = 0;
|
||||
for entry in entries {
|
||||
num_migrations += 1;
|
||||
let mut conn = pool.get()?;
|
||||
let mut tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let conn = pool.get()?;
|
||||
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
|
||||
// the pool never hands out `&mut`.
|
||||
let mut tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
|
||||
match run_migration(entry, &mut tx) {
|
||||
Ok(ran) => {
|
||||
if ran {
|
||||
@@ -74,7 +75,7 @@ pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
|
||||
}
|
||||
|
||||
fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> Result<bool> {
|
||||
let start = std::time::Instant::now();
|
||||
let start = elapsed_timer();
|
||||
let (version, description) = split_migration_filename(migration_path.path().to_str().unwrap())
|
||||
.expect("Failed to parse migration filename");
|
||||
|
||||
@@ -97,7 +98,7 @@ fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> R
|
||||
// Split on `;`? → optional depending on how your SQL is structured
|
||||
tx.execute_batch(&sql)?;
|
||||
|
||||
let execution_time = start.elapsed().as_nanos() as i64;
|
||||
let execution_time = start();
|
||||
let checksum = sha384_hex_prefixed(sql.as_bytes());
|
||||
|
||||
// NOTE: The success column is never used. It's just there for sqlx compatibility.
|
||||
@@ -109,6 +110,21 @@ fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> R
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Nanoseconds since the timer was started, for the sqlx-compatible
|
||||
/// `execution_time` column. `Instant` does not exist on `wasm32-unknown-unknown`
|
||||
/// (there is no monotonic clock to ask), and the column is bookkeeping, so
|
||||
/// there it reads as zero rather than taking the migrator down with it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn elapsed_timer() -> impl Fn() -> i64 {
|
||||
let start = std::time::Instant::now();
|
||||
move || start.elapsed().as_nanos() as i64
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn elapsed_timer() -> impl Fn() -> i64 {
|
||||
|| 0
|
||||
}
|
||||
|
||||
fn split_migration_filename(filename: &str) -> Option<(String, String)> {
|
||||
// Remove the .sql extension
|
||||
let trimmed = filename.strip_suffix(".sql")?;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Generic model writes, shared by every host.
|
||||
//!
|
||||
//! `upsert`, `delete` and `duplicate` take an `AnyModel` and fan out to the
|
||||
//! typed query for its variant. That fan-out is long, mechanical, and has to
|
||||
//! grow a new arm every time a model is added — exactly the code that should
|
||||
//! not exist twice. The host supplies the database handles and the
|
||||
//! `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;
|
||||
|
||||
pub fn upsert_model(
|
||||
db: &ClientDb,
|
||||
blobs: &BlobManager,
|
||||
model: AnyModel,
|
||||
source: &UpdateSource,
|
||||
) -> Result<String> {
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, blobs)?.id,
|
||||
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
|
||||
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
|
||||
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
|
||||
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Deletes cascade, so callers run this inside a transaction.
|
||||
pub fn delete_model(
|
||||
tx: &ClientDb,
|
||||
blobs: &BlobManager,
|
||||
model: AnyModel,
|
||||
source: &UpdateSource,
|
||||
) -> Result<String> {
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
|
||||
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, blobs)?.id,
|
||||
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
|
||||
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, blobs)?.id,
|
||||
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Duplicates recurse, so callers run this inside a transaction.
|
||||
///
|
||||
/// The model is re-read from the database rather than taken from the caller, so
|
||||
/// a duplicate never comes from a stale frontend snapshot.
|
||||
pub fn duplicate_model(
|
||||
tx: &ClientDb,
|
||||
model_type: &str,
|
||||
model_id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<String> {
|
||||
let id = match model_type {
|
||||
"environment" => tx.duplicate_environment(&tx.get_environment(model_id)?, source)?.id,
|
||||
"folder" => tx.duplicate_folder(&tx.get_folder(model_id)?, source)?.id,
|
||||
"grpc_request" => tx.duplicate_grpc_request(&tx.get_grpc_request(model_id)?, source)?.id,
|
||||
"http_request" => tx.duplicate_http_request(&tx.get_http_request(model_id)?, source)?.id,
|
||||
"websocket_request" => {
|
||||
tx.duplicate_websocket_request(&tx.get_websocket_request(model_id)?, source)?.id
|
||||
}
|
||||
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use crate::error::Result;
|
||||
use crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden};
|
||||
use crate::util::UpdateSource;
|
||||
use chrono::{Duration, Utc};
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::models::{GrpcConnection, GrpcConnectionIden, GrpcConnectionState};
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
use crate::util::UpdateSource;
|
||||
use log::debug;
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
use crate::util::UpdateSource;
|
||||
use log::{debug, error};
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
use std::fs;
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::models::{KeyValue, KeyValueIden, UpsertModelInfo};
|
||||
use crate::util::UpdateSource;
|
||||
use chrono::NaiveDateTime;
|
||||
use log::error;
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{PluginKeyValue, PluginKeyValueIden};
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::Keyword::CurrentTimestamp;
|
||||
use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{SyncState, SyncStateIden, UpsertModelInfo};
|
||||
use crate::util::UpdateSource;
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::models::{WebsocketConnection, WebsocketConnectionIden, WebsocketConne
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
use crate::util::UpdateSource;
|
||||
use log::debug;
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::util::ModelPayload;
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::TransactionBehavior;
|
||||
use rusqlite::{Transaction, TransactionBehavior};
|
||||
use std::sync::mpsc;
|
||||
use yaak_database::{ConnectionOrTx, DbContext};
|
||||
use yaak_database::{ConnectionOrTx, DbContext, SqlitePool};
|
||||
|
||||
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
|
||||
// blocking `get()` serializes every DB access behind the slowest waiter, freezing the
|
||||
// whole app whenever the pool is exhausted.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QueryManager {
|
||||
pool: Pool<SqliteConnectionManager>,
|
||||
pool: SqlitePool,
|
||||
events_tx: mpsc::Sender<ModelPayload>,
|
||||
}
|
||||
|
||||
impl QueryManager {
|
||||
pub fn new(pool: Pool<SqliteConnectionManager>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
||||
pub fn new(pool: SqlitePool, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
||||
QueryManager { pool, events_tx }
|
||||
}
|
||||
|
||||
@@ -46,9 +44,10 @@ impl QueryManager {
|
||||
where
|
||||
E: From<crate::error::Error>,
|
||||
{
|
||||
let mut conn = self.pool.get().expect("Failed to get new DB connection from the pool");
|
||||
let tx = conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
|
||||
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
|
||||
// the pool never hands out `&mut`.
|
||||
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
|
||||
.expect("Failed to start DB transaction");
|
||||
|
||||
let ctx = DbContext::new(ConnectionOrTx::Transaction(&tx));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { WatchResult } from "@yaakapp-internal/tauri-client";
|
||||
import type { WatchResult } from "@yaakapp-internal/rpc-schema";
|
||||
import { SyncOp } from "./bindings/gen_sync";
|
||||
import { WatchEvent } from "./bindings/gen_watch";
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 }
|
||||
|
||||
+129
-11
@@ -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_client_identity_pkcs12`] builds from PEM
|
||||
/// Password for the PKCS#12 blob [`load_native_client_identity`] 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,16 +107,33 @@ fn load_client_cert(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Load the configured client certificate as PKCS#12 DER, along with the
|
||||
/// password needed to open it.
|
||||
/// 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.
|
||||
///
|
||||
/// 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(
|
||||
/// 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(
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
) -> Result<Option<(Vec<u8>, String)>> {
|
||||
) -> Result<Option<NativeClientIdentity>> {
|
||||
let config = match client_cert {
|
||||
None => return Ok(None),
|
||||
Some(c) => c,
|
||||
@@ -127,7 +144,10 @@ pub fn load_client_identity_pkcs12(
|
||||
if let Some(pfx_path) = &config.pfx_file {
|
||||
if !pfx_path.is_empty() {
|
||||
let data = fs::read(Path::new(pfx_path))?;
|
||||
return Ok(Some((data, config.passphrase.clone().unwrap_or_default())));
|
||||
return Ok(Some(NativeClientIdentity::Pkcs12 {
|
||||
data,
|
||||
password: config.passphrase.clone().unwrap_or_default(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,13 +156,35 @@ pub fn load_client_identity_pkcs12(
|
||||
};
|
||||
|
||||
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((pfx.to_der(), IN_MEMORY_PKCS12_PASSWORD.to_string())))
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-encode a private key as PKCS#8 DER, wrapping PKCS#1 and SEC1 keys.
|
||||
@@ -379,3 +421,79 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "yaak-web"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# The desktop's model layer, compiled for a browser tab. See src/lib.rs.
|
||||
#
|
||||
# Building needs a clang with a WebAssembly backend: sqlite-wasm-rs compiles
|
||||
# sqlite3.c to wasm at build time, and Apple's clang cannot target it. See
|
||||
# build-wasm.cjs, which points cc at Homebrew LLVM when it is present.
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = false # Matches yaak-templates; wasm-opt has caused errors in CI
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# The whole crate is `#![cfg(target_arch = "wasm32")]`: on a native target it
|
||||
# is empty, so a workspace-wide `cargo test` neither builds SQLite's wasm shim
|
||||
# for the host (which fails) nor links a browser-only runtime. Everything that
|
||||
# only exists for wasm is a target-scoped dependency for the same reason.
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
console_error_panic_hook = "0.1"
|
||||
js-sys = "0.3"
|
||||
serde-wasm-bindgen = "0.6.5"
|
||||
sqlite-wasm-rs = "0.5"
|
||||
sqlite-wasm-vfs = "0.2"
|
||||
wasm-bindgen = "0.2.100"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
@@ -0,0 +1,79 @@
|
||||
const { execSync, spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
// Same shape as crates/yaak-templates/build-wasm.cjs, plus one wrinkle: this
|
||||
// crate links SQLite, and sqlite-wasm-rs compiles sqlite3.c to wasm at build
|
||||
// time. That needs a C compiler with a WebAssembly backend, which Apple's
|
||||
// clang is not. So the build looks for one, and when it finds none it keeps
|
||||
// the committed pkg/ and says so — desktop developers never need this crate
|
||||
// rebuilt, and failing their `npm run bootstrap` over it would be wrong.
|
||||
|
||||
if (process.env.SKIP_WASM_BUILD === "1") {
|
||||
console.log("Skipping wasm-pack build (SKIP_WASM_BUILD=1)");
|
||||
return;
|
||||
}
|
||||
|
||||
/** A clang that can emit wasm32, or null. */
|
||||
function findWasmClang() {
|
||||
const candidates = [
|
||||
process.env.CC_wasm32_unknown_unknown,
|
||||
"/opt/homebrew/opt/llvm/bin/clang", // Homebrew LLVM, Apple Silicon
|
||||
"/usr/local/opt/llvm/bin/clang", // Homebrew LLVM, Intel
|
||||
"clang", // Linux distros' clang usually has the backend built in
|
||||
].filter(Boolean);
|
||||
|
||||
for (const clang of candidates) {
|
||||
const probe = spawnSync(clang, ["--print-targets"], { encoding: "utf8" });
|
||||
if (probe.status === 0 && /\bwasm32\b/.test(probe.stdout)) return clang;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const clang = findWasmClang();
|
||||
if (clang == null) {
|
||||
console.log(
|
||||
[
|
||||
"yaak-web: no C compiler with a WebAssembly backend found; keeping the committed pkg/.",
|
||||
" To rebuild: install LLVM (macOS: `brew install llvm`) or point CC_wasm32_unknown_unknown at one.",
|
||||
].join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// llvm-ar lives next to clang in every LLVM distribution
|
||||
const ar = path.join(path.dirname(clang), "llvm-ar");
|
||||
|
||||
// Remap machine-specific paths that rustc embeds into the binary (panic
|
||||
// location strings), so builds are reproducible across machines
|
||||
const sysroot = execSync("rustc --print sysroot").toString().trim();
|
||||
const cargoHome = process.env.CARGO_HOME ?? path.join(os.homedir(), ".cargo");
|
||||
|
||||
execSync("wasm-pack build --target bundler", {
|
||||
stdio: "inherit",
|
||||
cwd: __dirname,
|
||||
env: {
|
||||
...process.env,
|
||||
CC_wasm32_unknown_unknown: clang,
|
||||
AR_wasm32_unknown_unknown: fs.existsSync(ar) ? ar : (process.env.AR_wasm32_unknown_unknown ?? ""),
|
||||
RUSTFLAGS: `--remap-path-prefix=${cargoHome}=/cargo --remap-path-prefix=${sysroot}=/rustc`,
|
||||
},
|
||||
});
|
||||
|
||||
// Rewrite the generated entry to use Vite's ?init import style instead of
|
||||
// the ES Module Integration style that wasm-pack generates, which Vite/rolldown
|
||||
// does not support in production builds.
|
||||
const entry = path.join(__dirname, "pkg", "yaak_web.js");
|
||||
fs.writeFileSync(
|
||||
entry,
|
||||
[
|
||||
'import init from "./yaak_web_bg.wasm?init";',
|
||||
'export * from "./yaak_web_bg.js";',
|
||||
'import * as bg from "./yaak_web_bg.js";',
|
||||
'const instance = await init({ "./yaak_web_bg.js": bg });',
|
||||
"bg.__wbg_set_wasm(instance.exports);",
|
||||
"instance.exports.__wbindgen_start();",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
// The desktop's model layer, compiled to wasm for the browser. See src/lib.rs.
|
||||
//
|
||||
// This is loaded by the SharedWorker in packages/platform/src/web/worker.ts and
|
||||
// nowhere else: it owns a SQLite database, and there must be exactly one of it
|
||||
// per origin.
|
||||
export { blob_delete, blob_get, blob_put, boot, rpc } from "./pkg";
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"bootstrap": "npm run build",
|
||||
"build": "run-s build:*",
|
||||
"build:pack": "node build-wasm.cjs",
|
||||
"build:clean": "rimraf ./pkg/.gitignore"
|
||||
},
|
||||
"devDependencies": {
|
||||
"rimraf": "^6.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "yaak-web",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"yaak_web_bg.wasm",
|
||||
"yaak_web.js",
|
||||
"yaak_web_bg.js",
|
||||
"yaak_web.d.ts"
|
||||
],
|
||||
"main": "yaak_web.js",
|
||||
"types": "yaak_web.d.ts",
|
||||
"sideEffects": [
|
||||
"./yaak_web.js",
|
||||
"./snippets/*"
|
||||
]
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
/* 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;
|
||||
@@ -0,0 +1,6 @@
|
||||
import init from "./yaak_web_bg.wasm?init";
|
||||
export * from "./yaak_web_bg.js";
|
||||
import * as bg from "./yaak_web_bg.js";
|
||||
const instance = await init({ "./yaak_web_bg.js": bg });
|
||||
bg.__wbg_set_wasm(instance.exports);
|
||||
instance.exports.__wbindgen_start();
|
||||
@@ -0,0 +1,975 @@
|
||||
/**
|
||||
* @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
@@ -0,0 +1,32 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const blob_delete: (a: number, b: number) => [number, number];
|
||||
export const blob_get: (a: number, b: number) => [number, number, number, number];
|
||||
export const blob_put: (a: number, b: number, c: number, d: number) => [number, number];
|
||||
export const boot: () => any;
|
||||
export const rpc: (a: number, b: number, c: any, d: number, e: number) => [number, number, number];
|
||||
export const rust_sqlite_wasm_abort: () => void;
|
||||
export const rust_sqlite_wasm_assert_fail: (a: number, b: number, c: number, d: number) => void;
|
||||
export const rust_sqlite_wasm_calloc: (a: number, b: number) => number;
|
||||
export const rust_sqlite_wasm_free: (a: number) => void;
|
||||
export const rust_sqlite_wasm_getentropy: (a: number, b: number) => number;
|
||||
export const rust_sqlite_wasm_localtime: (a: number) => number;
|
||||
export const rust_sqlite_wasm_malloc: (a: number) => number;
|
||||
export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
|
||||
export const sqlite3_os_end: () => number;
|
||||
export const sqlite3_os_init: () => number;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__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;
|
||||
@@ -0,0 +1,356 @@
|
||||
//! The desktop's model layer, running in a browser.
|
||||
//!
|
||||
//! Nothing here is model logic. This crate registers a persistent VFS, opens
|
||||
//! the database through the same `init_standalone` the CLI uses, and answers
|
||||
//! the `models_*` commands by calling the same `ClientDb` queries the desktop
|
||||
//! does. A browser tab therefore stores exactly what a desktop install stores,
|
||||
//! migrations and all — the only thing that differs is where the SQLite pages
|
||||
//! live (IndexedDB) and who is calling in (a worker instead of Tauri).
|
||||
//!
|
||||
//! It is meant to be loaded once, in one place — a SharedWorker — because two
|
||||
//! SQLite instances over the same IndexedDB pages would corrupt them. The
|
||||
//! JavaScript side owns that; this crate assumes it is the only writer.
|
||||
//!
|
||||
//! The command surface is deliberately narrow: what the frontend needs to keep
|
||||
//! its model store coherent, and blob storage. Sending, plugins, git, sync and
|
||||
//! everything else with a socket or a filesystem behind it lives elsewhere.
|
||||
|
||||
// Nothing in here means anything off wasm32, and building it there would drag
|
||||
// SQLite's wasm C shim into a native compile. So on any other target the crate
|
||||
// is empty — a workspace-wide `cargo test` passes through it.
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use yaak_models::blob_manager::{BlobManager, BodyChunk};
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_models::models_ops;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
|
||||
/// Names inside the VFS, not paths on any disk. Two files because the desktop
|
||||
/// keeps two: models in one, blobs in the other.
|
||||
const DB_NAME: &str = "yaak.db";
|
||||
const BLOB_DB_NAME: &str = "yaak-blobs.db";
|
||||
const VFS_NAME: &str = "yaak-idb";
|
||||
|
||||
struct Host {
|
||||
queries: QueryManager,
|
||||
blobs: BlobManager,
|
||||
events: mpsc::Receiver<ModelPayload>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOST: RefCell<Option<Host>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Errors */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// What a failed command hands back to JavaScript: a real `Error`, so it
|
||||
/// throws like one, with `message` set to the model layer's own text.
|
||||
fn js_error(e: impl std::fmt::Display) -> JsValue {
|
||||
js_sys::Error::new(&e.to_string()).into()
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, JsValue>;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Boot */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// Register the IndexedDB-backed VFS and open the database.
|
||||
///
|
||||
/// Migrations run inside `init_standalone`, exactly as they do for the CLI.
|
||||
/// Safe to call more than once; later calls are no-ops.
|
||||
#[wasm_bindgen]
|
||||
pub async fn boot() -> Result<()> {
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
if HOST.with(|h| h.borrow().is_some()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// "Relaxed" means writes land in memory first and are flushed to
|
||||
// IndexedDB shortly after, rather than on every commit. It is the right
|
||||
// trade for a client app: a tab closing mid-flush loses at most the last
|
||||
// few writes, and the alternative (OPFS sync access handles) needs a
|
||||
// dedicated worker per file and is not available everywhere.
|
||||
let cfg = sqlite_wasm_vfs::relaxed_idb::RelaxedIdbCfgBuilder::new()
|
||||
.vfs_name(VFS_NAME)
|
||||
.preload(sqlite_wasm_vfs::relaxed_idb::Preload::All)
|
||||
.build();
|
||||
sqlite_wasm_vfs::relaxed_idb::install::<sqlite_wasm_rs::WasmOsCallback>(&cfg, true)
|
||||
.await
|
||||
.map_err(js_error)?;
|
||||
|
||||
let (queries, blobs, events) =
|
||||
yaak_models::init_standalone(DB_NAME, BLOB_DB_NAME).map_err(js_error)?;
|
||||
|
||||
HOST.with(|h| *h.borrow_mut() = Some(Host { queries, blobs, events }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_host<T>(f: impl FnOnce(&Host) -> Result<T>) -> Result<T> {
|
||||
HOST.with(|h| {
|
||||
let h = h.borrow();
|
||||
let host = h.as_ref().ok_or_else(|| js_error("yaak-web: call boot() before rpc()"))?;
|
||||
f(host)
|
||||
})
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Commands */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// A command's outcome, plus every model write it caused.
|
||||
///
|
||||
/// The writes ride along with the result rather than being fetched separately
|
||||
/// so the caller can announce them atomically with completion — a tab that
|
||||
/// awaits `models_upsert` must see its echo before or with the response, never
|
||||
/// after, or the store races the reply.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RpcOutcome {
|
||||
result: serde_json::Value,
|
||||
events: Vec<ModelPayload>,
|
||||
}
|
||||
|
||||
/// Run one command as `label` (the calling tab's identity, which stands in for
|
||||
/// the desktop's window label on every write it makes).
|
||||
///
|
||||
/// The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are
|
||||
/// declared locally and dispatched by name, which is the one place this host
|
||||
/// does not share the desktop's guarantees: the desktop builds its router from
|
||||
/// the schema, so every command has a handler by construction. Here a renamed
|
||||
/// command would surface as a runtime "not a command this host answers".
|
||||
///
|
||||
/// The fix is `yaak-commands` (the `Host` trait), not more machinery here —
|
||||
/// its `models::*` handlers are already this file, typed. Three things have to
|
||||
/// give before a wasm host can register them:
|
||||
///
|
||||
/// 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread
|
||||
/// and the connection pool is an `Rc`.
|
||||
/// 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn
|
||||
/// onto here.
|
||||
/// 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP
|
||||
/// stack and the Node sidecar and do not build for wasm32.
|
||||
///
|
||||
/// None of those are hard; they are just not this PR.
|
||||
#[wasm_bindgen]
|
||||
pub fn rpc(cmd: &str, payload: JsValue, label: &str) -> Result<JsValue> {
|
||||
let source = UpdateSource::from_window_label(label);
|
||||
|
||||
let result = with_host(|host| dispatch(host, cmd, payload, &source))?;
|
||||
let events = with_host(|host| Ok(host.events.try_iter().collect::<Vec<_>>()))?;
|
||||
|
||||
use serde::Serialize as _;
|
||||
RpcOutcome { result, events }
|
||||
.serialize(&serde_wasm_bindgen::Serializer::json_compatible())
|
||||
.map_err(js_error)
|
||||
}
|
||||
|
||||
fn from_js<T: for<'de> Deserialize<'de>>(payload: JsValue) -> Result<T> {
|
||||
serde_wasm_bindgen::from_value(payload).map_err(js_error)
|
||||
}
|
||||
|
||||
fn to_json<T: Serialize>(value: T) -> Result<serde_json::Value> {
|
||||
serde_json::to_value(value).map_err(js_error)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspaceModelsReq {
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ModelReq {
|
||||
model: AnyModel,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DuplicateReq {
|
||||
model_type: String,
|
||||
model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspaceIdReq {
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RequestIdReq {
|
||||
request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpsertIntrospectionReq {
|
||||
workspace_id: String,
|
||||
request_id: String,
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
fn dispatch(
|
||||
host: &Host,
|
||||
cmd: &str,
|
||||
payload: JsValue,
|
||||
source: &UpdateSource,
|
||||
) -> Result<serde_json::Value> {
|
||||
match cmd {
|
||||
// The one big read. Same list, same order, and the same four lazy
|
||||
// creates (settings, first workspace, cookie jar, base environment) as
|
||||
// `models_workspace_models` on the desktop — this call is where an
|
||||
// empty database becomes a usable one. Returned as a JSON *string*
|
||||
// because that is what the desktop returns and what the store parses.
|
||||
"models_workspace_models" => {
|
||||
let req: WorkspaceModelsReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
let mut list: Vec<AnyModel> = Vec::new();
|
||||
|
||||
list.push(db.get_settings().into());
|
||||
list.extend(db.list_workspaces().map_err(js_error)?.into_iter().map(Into::into));
|
||||
list.extend(db.list_key_values().map_err(js_error)?.into_iter().map(Into::into));
|
||||
// No plugin runtime to resolve these against; the rows are still
|
||||
// the truth about what is installed.
|
||||
list.extend(db.list_plugins().map_err(js_error)?.into_iter().map(Into::into));
|
||||
|
||||
if let Some(wid) = req.workspace_id.as_deref() {
|
||||
let e = js_error;
|
||||
list.extend(db.list_cookie_jars(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(
|
||||
db.list_environments_ensure_base(wid).map_err(e)?.into_iter().map(Into::into),
|
||||
);
|
||||
list.extend(db.list_folders(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(db.list_grpc_connections(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(db.list_grpc_requests(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(db.list_http_requests(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(
|
||||
db.list_http_responses(wid, None).map_err(e)?.into_iter().map(Into::into),
|
||||
);
|
||||
list.extend(
|
||||
db.list_websocket_connections(wid).map_err(e)?.into_iter().map(Into::into),
|
||||
);
|
||||
list.extend(
|
||||
db.list_websocket_requests(wid).map_err(e)?.into_iter().map(Into::into),
|
||||
);
|
||||
list.extend(db.list_workspace_metas(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
}
|
||||
|
||||
to_json(serde_json::to_string(&list).map_err(js_error)?)
|
||||
}
|
||||
|
||||
"models_upsert" => {
|
||||
let req: ModelReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
let id =
|
||||
models_ops::upsert_model(&db, &host.blobs, req.model, source).map_err(js_error)?;
|
||||
to_json(id)
|
||||
}
|
||||
|
||||
// Deletes and duplicates cascade, so they run in a transaction, as on
|
||||
// the desktop.
|
||||
"models_delete" => {
|
||||
let req: ModelReq = from_js(payload)?;
|
||||
let id = host
|
||||
.queries
|
||||
.with_tx(|tx| models_ops::delete_model(tx, &host.blobs, req.model, source))
|
||||
.map_err(js_error)?;
|
||||
to_json(id)
|
||||
}
|
||||
|
||||
"models_duplicate" => {
|
||||
let req: DuplicateReq = from_js(payload)?;
|
||||
let id = host
|
||||
.queries
|
||||
.with_tx(|tx| {
|
||||
models_ops::duplicate_model(tx, &req.model_type, &req.model_id, source)
|
||||
})
|
||||
.map_err(js_error)?;
|
||||
to_json(id)
|
||||
}
|
||||
|
||||
"models_get_settings" => to_json(host.queries.connect().get_settings()),
|
||||
|
||||
"models_get_graphql_introspection" => {
|
||||
let req: RequestIdReq = from_js(payload)?;
|
||||
to_json(host.queries.connect().get_graphql_introspection(&req.request_id))
|
||||
}
|
||||
|
||||
"models_upsert_graphql_introspection" => {
|
||||
let req: UpsertIntrospectionReq = from_js(payload)?;
|
||||
let saved = host
|
||||
.queries
|
||||
.connect()
|
||||
.upsert_graphql_introspection(
|
||||
&req.workspace_id,
|
||||
&req.request_id,
|
||||
req.content,
|
||||
source,
|
||||
)
|
||||
.map_err(js_error)?;
|
||||
to_json(saved)
|
||||
}
|
||||
|
||||
// Nothing here can open a socket, so no connection ever produced any.
|
||||
"models_grpc_events" | "models_websocket_events" => to_json(Vec::<()>::new()),
|
||||
|
||||
"cmd_get_workspace_meta" => {
|
||||
let req: WorkspaceIdReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
let workspace = db.get_workspace(&req.workspace_id).map_err(js_error)?;
|
||||
to_json(db.get_or_create_workspace_meta(&workspace.id).map_err(js_error)?)
|
||||
}
|
||||
|
||||
other => Err(js_error(format!("yaak-web: `{other}` is not a command this host answers"))),
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Blobs */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// The bytes stored under an id, or none. Ids are the desktop's: a response's
|
||||
/// own id for its body, `{responseId}.request` for the request that produced
|
||||
/// it. Bytes cross to JS as a `Uint8Array` rather than through JSON.
|
||||
#[wasm_bindgen]
|
||||
pub fn blob_get(id: &str) -> Result<Option<Vec<u8>>> {
|
||||
with_host(|host| {
|
||||
let chunks = host.blobs.connect().get_chunks(id).map_err(js_error)?;
|
||||
if chunks.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(chunks.into_iter().flat_map(|c| c.data).collect()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Store bytes under an id, replacing anything already there. Chunked the way
|
||||
/// the desktop chunks, so a body written here reads back on a desktop that
|
||||
/// imports the database, and vice versa.
|
||||
#[wasm_bindgen]
|
||||
pub fn blob_put(id: &str, bytes: &[u8]) -> Result<()> {
|
||||
const CHUNK: usize = 512 * 1024;
|
||||
with_host(|host| {
|
||||
let ctx = host.blobs.connect();
|
||||
ctx.delete_chunks(id).map_err(js_error)?;
|
||||
for (i, part) in bytes.chunks(CHUNK).enumerate() {
|
||||
ctx.insert_chunk(&BodyChunk::new(id, i as i32, part.to_vec())).map_err(js_error)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn blob_delete(id: &str) -> Result<()> {
|
||||
with_host(|host| host.blobs.connect().delete_chunks(id).map_err(js_error))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignorePatterns": "crates/yaak-templates/pkg/**\ncrates/yaak-web/pkg/**\n**/bindings/gen_*.ts\npackage-lock.json\nCargo.lock"
|
||||
}
|
||||
Generated
+23
-1
@@ -59,6 +59,7 @@
|
||||
"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",
|
||||
@@ -66,6 +67,7 @@
|
||||
"crates/yaak-sse",
|
||||
"crates/yaak-sync",
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-ws",
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
"apps/yaak-client",
|
||||
@@ -290,6 +292,10 @@
|
||||
"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"
|
||||
@@ -324,6 +330,13 @@
|
||||
"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"
|
||||
@@ -5753,6 +5766,10 @@
|
||||
"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
|
||||
@@ -5785,6 +5802,10 @@
|
||||
"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
|
||||
@@ -17189,7 +17210,8 @@
|
||||
"@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"
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@yaakapp-internal/rpc-schema": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"packages/plugin-runtime": {
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"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",
|
||||
@@ -65,6 +66,7 @@
|
||||
"crates/yaak-sse",
|
||||
"crates/yaak-sync",
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-ws",
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
"apps/yaak-client",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"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",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { setPlatform } from "./registry";
|
||||
import { createTauriPlatform } from "./tauri";
|
||||
|
||||
// Desktop is the only host today, so it is 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.
|
||||
// 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. A browser build selects its own host here, and
|
||||
// because nothing else in the app imports a host directly, that is the whole
|
||||
// change.
|
||||
// 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());
|
||||
|
||||
export * from "./capabilities";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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";
|
||||
@@ -0,0 +1,193 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 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",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
/**
|
||||
* The process that owns the database.
|
||||
*
|
||||
* On the desktop that is the Rust binary: it holds SQLite, every window talks
|
||||
* to it, and it pushes model writes to all of them. In a browser this worker
|
||||
* plays that part. It loads the model layer compiled to wasm, opens the one
|
||||
* database, answers each tab's commands over its port, and fans every
|
||||
* `model_writes` out to every port — so two tabs are coherent for the same
|
||||
* reason two desktop windows are, not because of a side channel.
|
||||
*
|
||||
* It 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. It still takes a Web Lock before opening the database,
|
||||
* for the one overlap the browser doesn't rule out — a tab reloading itself,
|
||||
* whose old worker may still be letting go while the new one comes up.
|
||||
*/
|
||||
|
||||
import { DB_LOCK_NAME, type FromWorker, type ToWorker } from "./protocol";
|
||||
|
||||
/**
|
||||
* The wasm is imported lazily, inside `boot()`, rather than at the top of the
|
||||
* module. That keeps this script's own evaluation instant, so a tab's connect
|
||||
* gets its `hello` immediately regardless of how long the model layer takes to
|
||||
* download and compile — and the tab can therefore tell "this worker is dead"
|
||||
* from "this worker is busy" with a short timeout.
|
||||
*/
|
||||
type Engine = typeof import("@yaakapp-internal/web");
|
||||
let engine: Engine | null = null;
|
||||
|
||||
const ports = new Set<MessagePort>();
|
||||
|
||||
/** Resolves once `boot()` has, or rejects with why it couldn't. */
|
||||
let booted: Promise<void> | null = null;
|
||||
let bootError: string | null = null;
|
||||
|
||||
function send(port: MessagePort, message: FromWorker, transfer: Transferable[] = []): void {
|
||||
port.postMessage(message, transfer);
|
||||
}
|
||||
|
||||
function broadcast(message: FromWorker): void {
|
||||
for (const port of ports) send(port, message);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to wait for the database lock before concluding someone else has it
|
||||
* for good. The wait exists for one case: a tab reloading itself. Its old
|
||||
* worker still holds the lock while it is torn down, and the new worker only
|
||||
* needs it to let go — which takes milliseconds, not seconds. Anything longer
|
||||
* is a live worker in another tab, and the honest answer is to say so.
|
||||
*/
|
||||
const LOCK_TIMEOUT_MS = 3000;
|
||||
|
||||
const ALREADY_OPEN =
|
||||
"Yaak's database is held by another worker that isn't letting go. Close Yaak's other tabs and reload.";
|
||||
|
||||
/**
|
||||
* Take the lock, or explain why not.
|
||||
*
|
||||
* Held for the life of the worker: the callback's promise never settles, so
|
||||
* the browser keeps the lock until this worker is gone. Requested with a
|
||||
* timeout rather than `ifAvailable`, so a dying predecessor's brief hold is
|
||||
* waited out but a live one is reported.
|
||||
*/
|
||||
function acquireDatabaseLock(): Promise<void> {
|
||||
// The tab checked for Web Locks before it ever connected; a worker without
|
||||
// them would be a browser lying about its own features.
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
navigator.locks
|
||||
.request(DB_LOCK_NAME, { signal: AbortSignal.timeout(LOCK_TIMEOUT_MS) }, () => {
|
||||
resolve();
|
||||
return new Promise<void>(() => {});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const name = (err as { name?: string } | null)?.name;
|
||||
reject(name === "AbortError" || name === "TimeoutError" ? new Error(ALREADY_OPEN) : err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the database, once, for everyone.
|
||||
*
|
||||
* Lock first, then load the model layer, then open — in that order, so a
|
||||
* worker that will never own the database also never downloads and compiles
|
||||
* the wasm for it.
|
||||
*/
|
||||
function bootOnce(): Promise<void> {
|
||||
if (booted != null) return booted;
|
||||
|
||||
booted = (async () => {
|
||||
await acquireDatabaseLock();
|
||||
const loaded = await import("@yaakapp-internal/web");
|
||||
await loaded.boot();
|
||||
engine = loaded;
|
||||
})();
|
||||
|
||||
booted.catch((err) => {
|
||||
bootError = errorMessage(err);
|
||||
});
|
||||
|
||||
return booted;
|
||||
}
|
||||
|
||||
async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
if (message.type === "goodbye") {
|
||||
ports.delete(port);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every command waits for boot rather than the tab having to. Tabs post
|
||||
// the moment they load; the port queues; this drains once the DB is open.
|
||||
try {
|
||||
await booted;
|
||||
} catch {
|
||||
send(port, { type: "error", id: message.id, message: bootError ?? "Database failed to open" });
|
||||
return;
|
||||
}
|
||||
const { rpc, blob_get, blob_put, blob_delete } = engine!;
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "rpc": {
|
||||
const outcome = rpc(message.cmd, message.payload, message.label) as {
|
||||
result: unknown;
|
||||
events: unknown[];
|
||||
};
|
||||
// Result first, to the caller; then the writes, to everyone including
|
||||
// the caller. The store applies its own echo the same as any other
|
||||
// window's, so it must arrive — and the caller's `await` resolving
|
||||
// before its echo lands is fine, because it resolves on the same tick
|
||||
// and the store reads on the next.
|
||||
send(port, { type: "result", id: message.id, result: outcome.result });
|
||||
if (outcome.events.length > 0) {
|
||||
broadcast({ type: "event", event: "model_writes", payload: outcome.events });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "blob_get": {
|
||||
const bytes = blob_get(message.blobId);
|
||||
if (bytes == null) {
|
||||
send(port, { type: "result", id: message.id, result: null });
|
||||
} else {
|
||||
// Copy into a fresh buffer we can transfer: the wasm's memory
|
||||
// cannot leave the worker.
|
||||
const out = new Uint8Array(bytes.byteLength);
|
||||
out.set(bytes);
|
||||
send(port, { type: "result", id: message.id, result: out.buffer }, [out.buffer]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "blob_put": {
|
||||
blob_put(message.blobId, new Uint8Array(message.bytes));
|
||||
send(port, { type: "result", id: message.id, result: null });
|
||||
return;
|
||||
}
|
||||
case "blob_delete": {
|
||||
blob_delete(message.blobId);
|
||||
send(port, { type: "result", id: message.id, result: null });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
send(port, { type: "error", id: message.id, message: errorMessage(err) });
|
||||
}
|
||||
}
|
||||
|
||||
function attach(port: MessagePort): void {
|
||||
ports.add(port);
|
||||
port.onmessage = (e: MessageEvent<ToWorker>) => void handle(port, e.data);
|
||||
port.start?.();
|
||||
|
||||
// Proof of life, before boot: the tab is timing this.
|
||||
send(port, { type: "hello" });
|
||||
|
||||
bootOnce().then(
|
||||
() => send(port, { type: "ready" }),
|
||||
(err) => send(port, { type: "boot_error", message: errorMessage(err) }),
|
||||
);
|
||||
}
|
||||
|
||||
// Each tab arrives as a connect event with its own port.
|
||||
(self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
|
||||
attach(e.ports[0]!);
|
||||
};
|
||||
+1
-1
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
"*": "vp check --fix",
|
||||
},
|
||||
lint: {
|
||||
ignorePatterns: ["npm/**", "crates/yaak-templates/pkg/**", "**/bindings/gen_*.ts"],
|
||||
ignorePatterns: ["npm/**", "crates/yaak-templates/pkg/**", "crates/yaak-web/pkg/**", "**/bindings/gen_*.ts"],
|
||||
options: {
|
||||
typeAware: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user