diff --git a/Cargo.lock b/Cargo.lock index b8ba4b2f..e6288f86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -1375,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" @@ -1859,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" @@ -2350,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" @@ -3491,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" @@ -3510,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" @@ -4092,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" @@ -7456,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" @@ -8037,6 +8248,22 @@ dependencies = [ "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" @@ -8171,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" @@ -11473,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" diff --git a/Cargo.toml b/Cargo.toml index 5f9ced20..d8eeaa4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index cf5683a2..df43caee 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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: diff --git a/apps/yaak-client/commands/openSettings.tsx b/apps/yaak-client/commands/openSettings.tsx index 3ab0aa44..0bef76a9 100644 --- a/apps/yaak-client/commands/openSettings.tsx +++ b/apps/yaak-client/commands/openSettings.tsx @@ -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 { 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({ diff --git a/crates-tauri/yaak-app-client/src/models_ext.rs b/crates-tauri/yaak-app-client/src/models_ext.rs index 92b2b2dc..7803ad89 100644 --- a/crates-tauri/yaak-app-client/src/models_ext.rs +++ b/crates-tauri/yaak-app-client/src/models_ext.rs @@ -147,7 +147,7 @@ pub(crate) fn models_upsert( let db = window.db(); let blobs = window.blob_manager(); let source = &UpdateSource::from_window_label(window.label()); - yaak::models_ops::upsert_model(&db, &blobs, model, source) + 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 @@ -163,7 +163,7 @@ pub(crate) async fn models_delete( // Use transaction for deletions because it might recurse window.with_tx(|tx| { let source = &UpdateSource::from_window_label(window.label()); - yaak::models_ops::delete_model(tx, &blobs, model, source) + yaak_models::models_ops::delete_model(tx, &blobs, model, source) }) }) .await @@ -178,7 +178,7 @@ pub(crate) fn models_duplicate( // Use transaction for duplications because it might recurse window.with_tx(|tx| { let source = &UpdateSource::from_window_label(window.label()); - yaak::models_ops::duplicate_model(tx, &model_type, &model_id, source) + yaak_models::models_ops::duplicate_model(tx, &model_type, &model_id, source) }) } diff --git a/crates/yaak-models/src/lib.rs b/crates/yaak-models/src/lib.rs index b560d2a1..97dfff80 100644 --- a/crates/yaak-models/src/lib.rs +++ b/crates/yaak-models/src/lib.rs @@ -14,6 +14,7 @@ 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; @@ -24,6 +25,7 @@ fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> { conn.busy_timeout(std::time::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")?; diff --git a/crates/yaak/src/models_ops.rs b/crates/yaak-models/src/models_ops.rs similarity index 94% rename from crates/yaak/src/models_ops.rs rename to crates/yaak-models/src/models_ops.rs index ee9c1291..3964b678 100644 --- a/crates/yaak/src/models_ops.rs +++ b/crates/yaak-models/src/models_ops.rs @@ -7,12 +7,12 @@ //! `UpdateSource` identifying who is writing; nothing here knows whether the //! caller is a desktop window or an HTTP request. -use yaak_models::blob_manager::BlobManager; -use yaak_models::client_db::ClientDb; -use yaak_models::error::Error::GenericError; -use yaak_models::error::Result; -use yaak_models::models::AnyModel; -use yaak_models::util::UpdateSource; +use crate::blob_manager::BlobManager; +use crate::client_db::ClientDb; +use crate::error::Error::GenericError; +use crate::error::Result; +use crate::models::AnyModel; +use crate::util::UpdateSource; pub fn upsert_model( db: &ClientDb, diff --git a/crates/yaak-web/Cargo.toml b/crates/yaak-web/Cargo.toml new file mode 100644 index 00000000..fc13fa53 --- /dev/null +++ b/crates/yaak-web/Cargo.toml @@ -0,0 +1,30 @@ +[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"] + +[dependencies] +console_error_panic_hook = "0.1" +js-sys = "0.3" +log = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde-wasm-bindgen = "0.6.5" +serde_json = { workspace = true } +sqlite-wasm-rs = "0.5" +sqlite-wasm-vfs = "0.2" +wasm-bindgen = "0.2.100" +wasm-bindgen-futures = "0.4" +yaak-models = { workspace = true } diff --git a/crates/yaak-web/build-wasm.cjs b/crates/yaak-web/build-wasm.cjs new file mode 100644 index 00000000..cbe5fc6a --- /dev/null +++ b/crates/yaak-web/build-wasm.cjs @@ -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"), +); diff --git a/crates/yaak-web/index.ts b/crates/yaak-web/index.ts new file mode 100644 index 00000000..7311891c --- /dev/null +++ b/crates/yaak-web/index.ts @@ -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"; diff --git a/crates/yaak-web/package.json b/crates/yaak-web/package.json new file mode 100644 index 00000000..30088d49 --- /dev/null +++ b/crates/yaak-web/package.json @@ -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" + } +} diff --git a/crates/yaak-web/pkg/package.json b/crates/yaak-web/pkg/package.json new file mode 100644 index 00000000..cace5c1b --- /dev/null +++ b/crates/yaak-web/pkg/package.json @@ -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/*" + ] +} \ No newline at end of file diff --git a/crates/yaak-web/pkg/yaak_web.d.ts b/crates/yaak-web/pkg/yaak_web.d.ts new file mode 100644 index 00000000..71d0a9f4 --- /dev/null +++ b/crates/yaak-web/pkg/yaak_web.d.ts @@ -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; + +/** + * 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; diff --git a/crates/yaak-web/pkg/yaak_web.js b/crates/yaak-web/pkg/yaak_web.js new file mode 100644 index 00000000..d6309774 --- /dev/null +++ b/crates/yaak-web/pkg/yaak_web.js @@ -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(); diff --git a/crates/yaak-web/pkg/yaak_web_bg.js b/crates/yaak-web/pkg/yaak_web_bg.js new file mode 100644 index 00000000..d99559f5 --- /dev/null +++ b/crates/yaak-web/pkg/yaak_web_bg.js @@ -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} + */ +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; +} diff --git a/crates/yaak-web/pkg/yaak_web_bg.wasm b/crates/yaak-web/pkg/yaak_web_bg.wasm new file mode 100644 index 00000000..f4680c64 Binary files /dev/null and b/crates/yaak-web/pkg/yaak_web_bg.wasm differ diff --git a/crates/yaak-web/pkg/yaak_web_bg.wasm.d.ts b/crates/yaak-web/pkg/yaak_web_bg.wasm.d.ts new file mode 100644 index 00000000..f86bcd3d --- /dev/null +++ b/crates/yaak-web/pkg/yaak_web_bg.wasm.d.ts @@ -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; diff --git a/crates/yaak-web/src/lib.rs b/crates/yaak-web/src/lib.rs new file mode 100644 index 00000000..d768b735 --- /dev/null +++ b/crates/yaak-web/src/lib.rs @@ -0,0 +1,336 @@ +//! 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. + +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, +} + +thread_local! { + static HOST: RefCell> = 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 = std::result::Result; + +/* -------------------------------------------------------------------------- */ +/* 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::(&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(f: impl FnOnce(&Host) -> Result) -> Result { + 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, +} + +/// 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. +#[wasm_bindgen] +pub fn rpc(cmd: &str, payload: JsValue, label: &str) -> Result { + 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::>()))?; + + use serde::Serialize as _; + RpcOutcome { result, events } + .serialize(&serde_wasm_bindgen::Serializer::json_compatible()) + .map_err(js_error) +} + +fn from_js Deserialize<'de>>(payload: JsValue) -> Result { + serde_wasm_bindgen::from_value(payload).map_err(js_error) +} + +fn to_json(value: T) -> Result { + serde_json::to_value(value).map_err(js_error) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkspaceModelsReq { + workspace_id: Option, +} + +#[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, +} + +fn dispatch( + host: &Host, + cmd: &str, + payload: JsValue, + source: &UpdateSource, +) -> Result { + 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 = 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>> { + 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)) +} diff --git a/crates/yaak/src/lib.rs b/crates/yaak/src/lib.rs index c26c2acc..7bc790e8 100644 --- a/crates/yaak/src/lib.rs +++ b/crates/yaak/src/lib.rs @@ -1,7 +1,6 @@ pub mod error; pub mod export; pub mod import; -pub mod models_ops; pub mod plugin_events; pub mod render; pub mod send; diff --git a/package-lock.json b/package-lock.json index 3dd276a5..2187134b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 9d793088..37c9345b 100644 --- a/package.json +++ b/package.json @@ -66,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", diff --git a/packages/platform/package.json b/packages/platform/package.json index 543efd04..d646b421 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -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", diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 84bac354..305b5e6c 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -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"; diff --git a/packages/platform/src/index.web.ts b/packages/platform/src/index.web.ts new file mode 100644 index 00000000..cf45858b --- /dev/null +++ b/packages/platform/src/index.web.ts @@ -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"; diff --git a/packages/platform/src/web/README.md b/packages/platform/src/web/README.md new file mode 100644 index 00000000..5bf17a56 --- /dev/null +++ b/packages/platform/src/web/README.md @@ -0,0 +1,189 @@ +# 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`**, which is what makes "one database, many + tabs" true by construction — 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. Where `SharedWorker` is missing (Android Chrome) or its script + can't be fetched (some embedded browsers), the connection falls back to a + dedicated worker that takes a Web Lock; a second tab then gets a clear + "already open in another tab" instead of 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. diff --git a/packages/platform/src/web/commands.ts b/packages/platform/src/web/commands.ts new file mode 100644 index 00000000..f3eb5b29 --- /dev/null +++ b/packages/platform/src/web/commands.ts @@ -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; + +/** 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> = { + /* ------------------------------- 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> = { + // 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 { + 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`); +} diff --git a/packages/platform/src/web/connection.ts b/packages/platform/src/web/connection.ts new file mode 100644 index 00000000..b43d8580 --- /dev/null +++ b/packages/platform/src/web/connection.ts @@ -0,0 +1,271 @@ +/** + * 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. The one thing that can push it past this is a slow first fetch of + * the script itself, and the cost of a false alarm there is a second connect + * that the browser resolves to the same, now-live worker. The cost of guessing + * high is a user staring at a blank page, so err low. + */ +const HELLO_TIMEOUT_MS = 400; +/** Shared-worker connects to try before settling for a dedicated worker. */ +const MAX_SHARED_ATTEMPTS = 3; + +type Pending = { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + /** Kept so the request can be re-sent if the worker has to be replaced. */ + message: ToWorker; + transfer: Transferable[]; +}; + +export class WorkerConnection { + private port: MessagePort | Worker; + private readonly pending = new Map(); + private readonly listeners = new Map 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)}`; + + /** Whether the database is shared with other tabs, or this tab holds it alone. */ + shared: boolean; + + /** True once the worker has said anything at all; after that, no fallback. */ + private heard = false; + + /** How many times a shared worker was tried before giving up on sharing. */ + private sharedAttempts = 0; + + constructor() { + if (typeof SharedWorker !== "undefined") { + this.port = this.connectShared(); + this.shared = true; + } else { + // No SharedWorker (Android Chrome). One tab owns the database; the + // worker takes a lock and a second tab is told so. + this.port = this.connectDedicated(); + this.shared = false; + } + + // Let the worker forget this port. Not load-bearing — a SharedWorker port + // that never says goodbye is a leaked entry in a Set — but tidy. + window.addEventListener("pagehide", () => this.post({ type: "goodbye" })); + } + + /* + * `new URL("./worker.ts", import.meta.url)` is written out inline at each + * constructor 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 connectShared(): MessagePort { + this.sharedAttempts += 1; + const worker = new SharedWorker(new URL("./worker.ts", import.meta.url), { + type: "module", + name: WORKER_NAME, + }); + // A SharedWorker whose script fails to load fires `error` on the + // SharedWorker object and nothing else — the port just goes quiet. Some + // embedded browsers can't fetch shared-worker scripts at all. + worker.onerror = () => { + if (!this.heard) this.replaceWorker("script failed to load"); + }; + this.attach(worker.port); + this.expectHello(worker.port); + return worker.port; + } + + private connectDedicated(): Worker { + const worker = new Worker(new URL("./worker.ts", import.meta.url), { + type: "module", + name: WORKER_NAME, + }); + this.attach(worker); + this.expectHello(worker); + return worker; + } + + private attach(port: MessagePort | Worker): void { + this.heard = false; + port.onmessage = (e: MessageEvent) => this.receive(e.data); + if (port instanceof MessagePort) port.start(); + } + + /** + * The worker says hello synchronously on connect. If it doesn't, the port is + * attached to nothing that will ever answer — most often a shared worker + * caught mid-teardown, which is what a tab reloading itself hands to the + * next document — and the only move is to connect again. + */ + private expectHello(port: MessagePort | Worker): void { + // Passed in rather than read from `this.port`, which the constructor has + // not assigned yet the first time this runs. + setTimeout(() => { + if (!this.heard && this.port === port) this.replaceWorker("no reply from worker"); + }, HELLO_TIMEOUT_MS); + } + + /** + * Replace a worker that never answered. Tries sharing again a few times — + * a torn-down shared worker is gone by then and a fresh one comes up — and + * only then gives up on sharing and takes a dedicated worker. + */ + private replaceWorker(why: string): void { + if (this.sharedAttempts < MAX_SHARED_ATTEMPTS) { + console.warn(`Reconnecting to the database worker (${why})`); + this.port = this.connectShared(); + this.shared = true; + } else { + console.warn(`Falling back to a dedicated database worker (${why})`); + this.port = this.connectDedicated(); + this.shared = false; + } + // Whatever was posted to the dead port never arrived. Bodies were copied, + // not transferred, precisely so they can be re-sent from here. + for (const p of this.pending.values()) { + this.post(p.message, p.transfer); + } + } + + 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": + this.bootError = message.message; + // 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. + showBootError(message.message); + for (const [id, p] of this.pending) { + this.pending.delete(id); + p.reject(new Error(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(build: (id: number) => ToWorker, transfer: Transferable[] = []): Promise { + if (this.bootError != null) return Promise.reject(new Error(this.bootError)); + const id = this.nextId++; + const message = build(id); + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, message, transfer }); + this.post(message, transfer); + }); + } + + rpc(cmd: string, payload: unknown): Promise { + return this.request((id) => ({ type: "rpc", id, cmd, payload, label: this.label })); + } + + async blobGet(blobId: string): Promise | null> { + const buf = await this.request((id) => ({ type: "blob_get", id, blobId })); + return buf == null ? null : new Uint8Array(buf); + } + + blobPut(blobId: string, bytes: Uint8Array): Promise { + // Copied rather than transferred: transferring would detach the caller's + // buffer, and would leave nothing to re-send if the worker is replaced. + // Bodies are small enough that the copy is cheaper than the bookkeeping. + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return this.request((id) => ({ type: "blob_put", id, blobId, bytes: copy.buffer })); + } + + blobDelete(blobId: string): Promise { + return this.request((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); +} diff --git a/packages/platform/src/web/errors.ts b/packages/platform/src/web/errors.ts new file mode 100644 index 00000000..d6127ef8 --- /dev/null +++ b/packages/platform/src/web/errors.ts @@ -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); +} diff --git a/packages/platform/src/web/index.ts b/packages/platform/src/web/index.ts new file mode 100644 index 00000000..0c871f19 --- /dev/null +++ b/packages/platform/src/web/index.ts @@ -0,0 +1,269 @@ +/** + * 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(cmd: string, payload?: RpcPayload): Promise { + 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).__YAAK_WEB__ = { + label: db.label, + // A getter: the connection may fall back from shared to dedicated after + // this object is built. + get sharedWorker() { + return db.shared; + }, + 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 may still be loading it. + return bytes == null ? null : URL.createObjectURL(new Blob([bytes])); + }, + }, + + rpc(cmd: string, payload?: RpcPayload): Promise { + // `plugin:` commands are Tauri host plugins, not engine commands, and + // never reached the router even on the desktop. + if (cmd.startsWith("plugin:")) return hostPluginCommand(cmd, payload); + return runCommand(cmd, payload ?? {}, db) as Promise; + }, + + async rpcStream( + cmd: string, + payload: RpcPayload, + onMessage: (message: M) => void, + ): Promise> { + // 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(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", + }; +} diff --git a/packages/platform/src/web/protocol.ts b/packages/platform/src/web/protocol.ts new file mode 100644 index 00000000..71ad16f0 --- /dev/null +++ b/packages/platform/src/web/protocol.ts @@ -0,0 +1,45 @@ +/** + * 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 a dedicated (non-shared) worker takes so a second tab cannot + * open a second SQLite over the same pages. Shared workers don't need it: the + * browser guarantees one of them. + */ +export const DB_LOCK_NAME = "yaak-db"; diff --git a/packages/platform/src/web/storage.ts b/packages/platform/src/web/storage.ts new file mode 100644 index 00000000..2ad3d3a8 --- /dev/null +++ b/packages/platform/src/web/storage.ts @@ -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 { + try { + if (navigator.storage?.persist == null) return false; + if (await navigator.storage.persisted()) return true; + return await navigator.storage.persist(); + } catch { + return false; + } +} diff --git a/packages/platform/src/web/worker.ts b/packages/platform/src/web/worker.ts new file mode 100644 index 00000000..daa72f5d --- /dev/null +++ b/packages/platform/src/web/worker.ts @@ -0,0 +1,185 @@ +/// + +/** + * 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 runs as a SharedWorker where the browser has one, which is what makes + * "one database, many tabs" true by construction. Where it doesn't (Android + * Chrome), it runs as a dedicated worker and takes a Web Lock so a second tab + * fails to open loudly instead of opening a second SQLite over the same pages. + */ + +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(); + +/** Resolves once `boot()` has, or rejects with why it couldn't. */ +let booted: Promise | 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); +} + +/** + * Open the database, once, for everyone. + * + * In a dedicated worker this also takes the lock. `ifAvailable` returns null + * rather than queueing, because queueing would mean a second tab silently + * hangs until the first closes — worse than telling it what is going on. + */ +function bootOnce(isShared: boolean): Promise { + if (booted != null) return booted; + + booted = (async () => { + if (!isShared) { + if (typeof navigator.locks === "undefined") { + // No way to guarantee exclusivity; proceed and hope. This is old + // browsers only, and they will get one tab working. + const loaded = await import("@yaakapp-internal/web"); + await loaded.boot(); + engine = loaded; + return; + } + const held = await new Promise((resolve) => { + void navigator.locks.request(DB_LOCK_NAME, { ifAvailable: true }, (lock) => { + if (lock == null) { + resolve(false); + return; + } + resolve(true); + // Hold the lock for as long as this worker lives + return new Promise(() => {}); + }); + }); + if (!held) { + throw new Error( + "Yaak is already open in another tab, and this browser can't share a database between tabs. Close the other tab, or use it instead.", + ); + } + } + 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 { + 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, isShared: boolean): void { + ports.add(port); + port.onmessage = (e: MessageEvent) => void handle(port, e.data); + port.start?.(); + + // Proof of life, before boot: the tab is timing this. + send(port, { type: "hello" }); + + bootOnce(isShared).then( + () => send(port, { type: "ready" }), + (err) => send(port, { type: "boot_error", message: errorMessage(err) }), + ); +} + +// A SharedWorker sees each tab arrive as a connect event; a dedicated worker +// *is* its one tab's port. +const scope = self as unknown as { onconnect?: unknown }; +if ("onconnect" in scope) { + (self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => { + attach(e.ports[0]!, true); + }; +} else { + attach(self as unknown as MessagePort, false); +} diff --git a/vite.config.ts b/vite.config.ts index b0c112b5..fd992ad0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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, },