mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-17 09:02:05 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c24dcee4bb |
@@ -103,18 +103,6 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
||||
# crates/yaak-web compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
|
||||
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
|
||||
# through to host glibc headers ("bits/libc-header-start.h" not found).
|
||||
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
|
||||
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
|
||||
# this compiler, so the shipped binary keeps 22.04's glibc floor.
|
||||
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
|
||||
chmod +x /tmp/llvm.sh
|
||||
sudo /tmp/llvm.sh 18
|
||||
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
|
||||
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
|
||||
Generated
+186
-364
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -2,7 +2,6 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/yaak",
|
||||
"crates/yaak-commands",
|
||||
# Common/foundation crates
|
||||
"crates/common/yaak-database",
|
||||
"crates/common/yaak-rpc",
|
||||
@@ -21,13 +20,14 @@ members = [
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-tls",
|
||||
"crates/yaak-ws",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-api",
|
||||
"crates/yaak-proxy",
|
||||
# Proxy-specific crates
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Headless server crates
|
||||
"crates-server/yaak-server",
|
||||
# Tauri-specific crates
|
||||
"crates-tauri/yaak-app-client",
|
||||
"crates-tauri/yaak-app-proxy",
|
||||
@@ -71,7 +71,6 @@ yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
|
||||
# Internal crates - shared
|
||||
yaak-core = { path = "crates/yaak-core" }
|
||||
yaak = { path = "crates/yaak" }
|
||||
yaak-commands = { path = "crates/yaak-commands" }
|
||||
yaak-common = { path = "crates/yaak-common" }
|
||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||
yaak-git = { path = "crates/yaak-git" }
|
||||
|
||||
@@ -44,25 +44,6 @@ After bootstrapping, start the app in development mode:
|
||||
npm start
|
||||
```
|
||||
|
||||
## Run the App in a Browser
|
||||
|
||||
The client can also run as a plain web page, with no Tauri and no local process
|
||||
behind it. Set `YAAK_TARGET=web` and start the frontend on its own:
|
||||
|
||||
```shell
|
||||
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||
```
|
||||
|
||||
That flag picks the browser host in `packages/platform/src/web/`, which answers
|
||||
commands from an IndexedDB database the page owns instead of from the Rust
|
||||
engine. Data persists across reloads and is shared between tabs on the same
|
||||
origin. Sending HTTP is not available yet — the Send button reports that and
|
||||
everything else about the request is still saved. `packages/platform/src/web/README.md`
|
||||
lists which commands the browser host implements and which it declines.
|
||||
|
||||
Desktop builds are unaffected: without the flag the platform package installs
|
||||
the Tauri host exactly as before.
|
||||
|
||||
## SQLite Migrations
|
||||
|
||||
New migrations can be created from the `src-tauri/` directory:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { SettingsTab } from "../components/Settings/Settings";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
@@ -15,19 +14,11 @@ export const openSettings = createFastMutation<void, string, SettingsTabWithSubt
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
const to = "/workspaces/$workspaceId/settings" as const;
|
||||
const params = { workspaceId };
|
||||
const search = { tab: (tab ?? undefined) as SettingsTab | undefined };
|
||||
|
||||
// Settings is its own window where the host has windows to give. Where it
|
||||
// doesn't — a browser tab — the same route opens in place, which is the
|
||||
// whole difference: it is already a route, not a separate app.
|
||||
if (!platform.capabilities.multiWindow) {
|
||||
await router.navigate({ to, params, search });
|
||||
return;
|
||||
}
|
||||
|
||||
const location = router.buildLocation({ to, params, search });
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId/settings",
|
||||
params: { workspaceId },
|
||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||
});
|
||||
|
||||
await rpc("cmd_new_child_window", {
|
||||
url: location.href,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||
@@ -25,9 +24,7 @@ export const switchWorkspace = createFastMutation<
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
// A host without windows opens the workspace here instead. Refusing would
|
||||
// strand the user on the workspace they were trying to leave.
|
||||
if (inNewWindow && platform.capabilities.multiWindow) {
|
||||
if (inNewWindow) {
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId },
|
||||
|
||||
@@ -1,29 +1,9 @@
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
||||
import { parse as jsonLintParse } from "@prantlf/jsonlint";
|
||||
|
||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
||||
|
||||
// jsonc-parser reports error codes, so these are the words the editor shows for them
|
||||
const MESSAGES: Record<string, string> = {
|
||||
InvalidSymbol: "Invalid symbol",
|
||||
InvalidNumberFormat: "Invalid number format",
|
||||
PropertyNameExpected: "Property name expected",
|
||||
ValueExpected: "Value expected",
|
||||
ColonExpected: "Colon expected",
|
||||
CommaExpected: "Comma expected",
|
||||
CloseBraceExpected: "Closing brace expected",
|
||||
CloseBracketExpected: "Closing bracket expected",
|
||||
EndOfFileExpected: "End of file expected",
|
||||
InvalidCommentToken: "Comments are not allowed",
|
||||
UnexpectedEndOfComment: "Unexpected end of comment",
|
||||
UnexpectedEndOfString: "Unexpected end of string",
|
||||
UnexpectedEndOfNumber: "Unexpected end of number",
|
||||
InvalidUnicode: "Invalid unicode sequence",
|
||||
InvalidEscapeCharacter: "Invalid escape character",
|
||||
InvalidCharacter: "Invalid character",
|
||||
};
|
||||
|
||||
interface JsonLintOptions {
|
||||
allowComments?: boolean;
|
||||
allowTrailingCommas?: boolean;
|
||||
@@ -31,28 +11,34 @@ interface JsonLintOptions {
|
||||
|
||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
const doc = view.state.doc.toString();
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
try {
|
||||
const doc = view.state.doc.toString();
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
jsonLintParse(escapedDoc, {
|
||||
mode: (options?.allowComments ?? true) ? "cjson" : "json",
|
||||
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
|
||||
});
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
} catch (err: any) {
|
||||
if (!("location" in err)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const errors: ParseError[] = [];
|
||||
parse(escapedDoc, errors, {
|
||||
allowTrailingComma: options?.allowTrailingCommas ?? false,
|
||||
disallowComments: !(options?.allowComments ?? true),
|
||||
});
|
||||
|
||||
// Later errors are mostly consequences of the first one, so only that one is shown
|
||||
const error = errors[0];
|
||||
if (error == null) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
from: error.offset,
|
||||
to: error.offset + error.length,
|
||||
severity: "error",
|
||||
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
|
||||
},
|
||||
];
|
||||
// const line = location?.start?.line;
|
||||
// const column = location?.start?.column;
|
||||
if (err.location.start.offset) {
|
||||
return [
|
||||
{
|
||||
from: err.location.start.offset,
|
||||
to: err.location.start.offset,
|
||||
severity: "error",
|
||||
message: err.message,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,9 +333,7 @@ export function formatHotkeyString(trigger: string): string[] {
|
||||
} else if (p === "Alt") {
|
||||
labelParts.push("⌥");
|
||||
} else if (p === "Enter") {
|
||||
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
|
||||
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
|
||||
labelParts.push("↩︎");
|
||||
labelParts.push("↩");
|
||||
} else if (p === "Tab") {
|
||||
labelParts.push("⇥");
|
||||
} else if (p === "Backspace") {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.3",
|
||||
"@mjackson/multipart-parser": "^0.10.1",
|
||||
"@prantlf/jsonlint": "^16.0.0",
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
@@ -53,7 +54,6 @@
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.4.7",
|
||||
@@ -93,12 +93,14 @@
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
"@yaakapp-internal/ui": "^1.0.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"decompress": "^4.2.1",
|
||||
"internal-ip": "^8.0.0",
|
||||
"rollup": "^4.60.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||
"vite-plugin-static-copy": "^3.3.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vite-plugin-top-level-await": "^1.5.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plus": "^0.2.9"
|
||||
"vite-plus": "^0.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@ const WorkspacesWorkspaceIdRequestsRequestIdRoute =
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/workspaces/': typeof WorkspacesIndexRoute
|
||||
'/workspaces': typeof WorkspacesIndexRoute
|
||||
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
|
||||
'/workspaces/$workspaceId/': typeof WorkspacesWorkspaceIdIndexRoute
|
||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
|
||||
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -70,9 +70,9 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/workspaces/'
|
||||
| '/workspaces'
|
||||
| '/workspaces/$workspaceId/settings'
|
||||
| '/workspaces/$workspaceId/'
|
||||
| '/workspaces/$workspaceId'
|
||||
| '/workspaces/$workspaceId/requests/$requestId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -110,14 +110,14 @@ declare module '@tanstack/react-router' {
|
||||
'/workspaces/': {
|
||||
id: '/workspaces/'
|
||||
path: '/workspaces'
|
||||
fullPath: '/workspaces/'
|
||||
fullPath: '/workspaces'
|
||||
preLoaderRoute: typeof WorkspacesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces/$workspaceId/': {
|
||||
id: '/workspaces/$workspaceId/'
|
||||
path: '/workspaces/$workspaceId'
|
||||
fullPath: '/workspaces/$workspaceId/'
|
||||
fullPath: '/workspaces/$workspaceId'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import { defineConfig, normalizePath } from "vite-plus";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -16,38 +17,9 @@ const standardFontsDir = normalizePath(
|
||||
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Which host the platform package installs. `web` builds Yaak to run in a plain
|
||||
* browser tab, with its own IndexedDB store instead of the Rust engine; anything
|
||||
* else builds the desktop app exactly as before.
|
||||
*/
|
||||
const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => {
|
||||
return {
|
||||
resolve: {
|
||||
alias:
|
||||
yaakTarget === "web"
|
||||
? {
|
||||
// Resolve the platform package to its browser entry, so a web
|
||||
// build never pulls `@tauri-apps/*` into the graph at all. A
|
||||
// build-time branch inside the package would not manage that:
|
||||
// the dead branch folds away, but the imports it guarded stay.
|
||||
"@yaakapp-internal/platform": path.resolve(
|
||||
import.meta.dirname,
|
||||
"../../packages/platform/src/index.web.ts",
|
||||
),
|
||||
}
|
||||
: {},
|
||||
},
|
||||
// The browser host runs the model layer in a worker; that bundle needs the
|
||||
// same wasm handling as the main one. Top-level await needs no transform
|
||||
// because the build targets esnext.
|
||||
worker: {
|
||||
format: "es" as const,
|
||||
plugins: () => [wasm()],
|
||||
},
|
||||
plugins: [
|
||||
wasm(),
|
||||
tanstackRouter({
|
||||
@@ -58,6 +30,7 @@ export default defineConfig(async () => {
|
||||
}),
|
||||
svgr(),
|
||||
react(),
|
||||
topLevelAwait(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{ src: cMapsDir, dest: "" },
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plus": "^0.2.9"
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||
"vite-plus": "^0.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,11 +181,7 @@ async fn dev(args: PluginPathArg) -> CommandResult {
|
||||
ui::info(&format!("Rebuilding plugin {display_path}"));
|
||||
}
|
||||
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
|
||||
// Assets are staged on every rebuild, so a changed asset or
|
||||
// declaration is picked up without restarting.
|
||||
let result = copy_build_assets(&watch_root)
|
||||
.and_then(|()| generate_plugin_metadata(&watch_root));
|
||||
match result {
|
||||
match generate_plugin_metadata(&watch_root) {
|
||||
Ok(()) => ui::success(&format!(
|
||||
"Generated plugin metadata at {}",
|
||||
watch_root.join("build/metadata.json").display()
|
||||
@@ -412,7 +408,6 @@ struct PublishResponse {
|
||||
|
||||
async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
|
||||
prepare_build_output_dir(plugin_dir)?;
|
||||
copy_build_assets(plugin_dir)?;
|
||||
let mut bundler = Bundler::new(bundler_options(plugin_dir, false))
|
||||
.map_err(|err| format!("Failed to initialize Rolldown: {err}"))?;
|
||||
let output = bundler.write().await.map_err(|err| format!("Plugin build failed:\n{err}"))?;
|
||||
@@ -503,63 +498,6 @@ fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
|
||||
.map_err(|e| format!("Failed to create build directory {}: {e}", build_dir.display()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PluginManifest {
|
||||
#[serde(default)]
|
||||
yaak: PluginManifestConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PluginManifestConfig {
|
||||
/// Files to place beside the bundle, as paths relative to the plugin
|
||||
/// directory. Publishing ships everything in `build/`, so these travel with
|
||||
/// the plugin.
|
||||
#[serde(default, rename = "buildAssets")]
|
||||
build_assets: Vec<String>,
|
||||
}
|
||||
|
||||
/// Copy the plugin's declared assets into `build/`.
|
||||
///
|
||||
/// This runs after the directory is cleared and before the bundle is written,
|
||||
/// because a bundle may read an asset from its own directory at import time and
|
||||
/// metadata generation imports the bundle.
|
||||
fn copy_build_assets(plugin_dir: &Path) -> CommandResult {
|
||||
let manifest_path = plugin_dir.join("package.json");
|
||||
let manifest: PluginManifest = serde_json::from_str(
|
||||
&fs::read_to_string(&manifest_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", manifest_path.display()))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to parse {}: {e}", manifest_path.display()))?;
|
||||
|
||||
let build_dir = plugin_dir.join("build");
|
||||
let mut names = HashSet::new();
|
||||
for asset in manifest.yaak.build_assets {
|
||||
let src = plugin_dir.join(&asset);
|
||||
let name = src
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("yaak.buildAssets entry is not a file path: {asset}"))?;
|
||||
// A copy that later gets overwritten would pass the build and fail on
|
||||
// load, so anything the build itself writes, or a second asset with
|
||||
// the same name, is rejected up front. Names are compared without
|
||||
// case, because a plugin is installed on case-insensitive filesystems
|
||||
// wherever it was built.
|
||||
let key = name.to_string_lossy().to_lowercase();
|
||||
if key == "index.js" || key == "metadata.json" {
|
||||
return Err(format!("Build asset {asset} would be overwritten by the build output"));
|
||||
}
|
||||
if !names.insert(key) {
|
||||
return Err(format!("Two build assets share the name {}", name.display()));
|
||||
}
|
||||
if !src.is_file() {
|
||||
return Err(format!("Build asset does not exist: {}", src.display()));
|
||||
}
|
||||
fs::copy(&src, build_dir.join(name))
|
||||
.map_err(|e| format!("Failed to copy build asset {}: {e}", src.display()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bundler_options(plugin_dir: &Path, watch: bool) -> BundlerOptions {
|
||||
BundlerOptions {
|
||||
input: Some(vec![InputItem { import: "./src/index.ts".to_string(), ..Default::default() }]),
|
||||
@@ -812,10 +750,7 @@ describe("Example Plugin", () => {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
copy_build_assets, create_publish_archive, generate_plugin_metadata,
|
||||
prepare_build_output_dir,
|
||||
};
|
||||
use super::{create_publish_archive, generate_plugin_metadata};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
@@ -860,100 +795,6 @@ mod tests {
|
||||
assert!(!names.contains("ignored/secret.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_build_output_dir_clears_stale_output() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
let build = root.join("build");
|
||||
fs::create_dir_all(&build).expect("create build");
|
||||
fs::write(build.join("index.js"), "stale").expect("write index.js");
|
||||
fs::write(build.join("left-behind.js"), "stale").expect("write extra");
|
||||
|
||||
prepare_build_output_dir(root).expect("prepare build dir");
|
||||
|
||||
// Publishing ships everything under build/, so nothing may survive.
|
||||
assert!(build.is_dir());
|
||||
assert_eq!(fs::read_dir(&build).expect("read build").count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_places_declared_files_beside_the_bundle() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::create_dir_all(root.join("vendor")).expect("create vendor");
|
||||
fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
|
||||
copy_build_assets(root).expect("copy assets");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("build/core_bg.wasm")).expect("read copied asset"),
|
||||
"asset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_is_a_noop_without_declarations() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("package.json"), r#"{"name":"demo"}"#).expect("write package.json");
|
||||
|
||||
copy_build_assets(root).expect("copy assets");
|
||||
|
||||
assert_eq!(fs::read_dir(root.join("build")).expect("read build").count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_rejects_names_the_build_writes() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("index.js"), "asset").expect("write asset");
|
||||
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["index.js"]}}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("reserved name should fail");
|
||||
assert!(err.contains("overwritten by the build output"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_rejects_duplicate_names() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::create_dir_all(root.join("a")).expect("create a");
|
||||
fs::create_dir_all(root.join("b")).expect("create b");
|
||||
// Differ only by case: one file on macOS and Windows.
|
||||
fs::write(root.join("a/core.wasm"), "one").expect("write a");
|
||||
fs::write(root.join("b/Core.wasm"), "two").expect("write b");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"yaak":{"buildAssets":["a/core.wasm","b/Core.wasm"]}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("duplicate name should fail");
|
||||
assert!(err.contains("share the name"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_fails_on_a_missing_asset() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["nope.wasm"]}}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("missing asset should fail");
|
||||
assert!(err.contains("Build asset does not exist"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_plugin_metadata_detects_api_types() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use crate::context::CliExecutionContext;
|
||||
use arboard::Clipboard;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use console::Term;
|
||||
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
|
||||
use serde_json::Value;
|
||||
@@ -14,7 +12,6 @@ use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::render::{render_grpc_request, render_http_request};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
@@ -134,7 +131,6 @@ async fn build_plugin_reply(
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
&host_context.query_manager,
|
||||
&FileResponseBodyStore::new(&host_context.query_manager),
|
||||
&event.payload,
|
||||
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
|
||||
) {
|
||||
@@ -227,15 +223,7 @@ async fn build_plugin_reply(
|
||||
.await
|
||||
{
|
||||
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
|
||||
SendHttpRequestResponse {
|
||||
http_response: result.response,
|
||||
// Nothing saved this body, so the reply is the only
|
||||
// place the plugin can get it.
|
||||
body: result
|
||||
.response_body
|
||||
.returned_bytes()
|
||||
.map(|b| BASE64_STANDARD.encode(b)),
|
||||
},
|
||||
SendHttpRequestResponse { http_response: result.response },
|
||||
)),
|
||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to send HTTP request in CLI: {err}"),
|
||||
|
||||
@@ -10,9 +10,9 @@ chrono = { workspace = true, features = ["serde"] }
|
||||
log = { workspace = true }
|
||||
include_dir = "0.7"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = "0.32"
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||
r2d2_sqlite = "0.25.0"
|
||||
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
ts-rs = { workspace = true, features = ["chrono-impl"] }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "yaak-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "yaak-bridge"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7", features = ["ws", "macros"] }
|
||||
charset = "0.1"
|
||||
chrono = { workspace = true }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
dirs = "6"
|
||||
env_logger = "0.11"
|
||||
eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client", version = "0.14.0" }
|
||||
futures = "0.3"
|
||||
include_dir = "0.7"
|
||||
log = { workspace = true }
|
||||
mime_guess = "2"
|
||||
pretty_graphql = "0.2"
|
||||
rand = "0.8"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_urlencoded = "0.7"
|
||||
tokio = { workspace = true, features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"io-util",
|
||||
"net",
|
||||
"signal",
|
||||
"time",
|
||||
"sync",
|
||||
] }
|
||||
tower-http = { version = "0.6", features = ["cors", "fs", "trace"] }
|
||||
yaak = { workspace = true }
|
||||
yaak-common = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-rpc = { workspace = true }
|
||||
yaak-rpc-schema = { workspace = true }
|
||||
yaak-sse = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
@@ -0,0 +1,94 @@
|
||||
# Yaak Bridge
|
||||
|
||||
A headless binary that runs the real Yaak engine for a browser tab.
|
||||
|
||||
The tab is the unmodified Yaak UI. Everything a page cannot do — send an HTTP
|
||||
request and see every response header, follow redirects, keep a cookie jar, run
|
||||
the plugin runtime, read a response body off disk — happens in this process,
|
||||
reached over local HTTP and a WebSocket.
|
||||
|
||||
This is the reason a browser Yaak can be credible at all. An in-page `fetch`
|
||||
sender only ever sees the CORS-safelisted response headers: measured against
|
||||
httpbin, a server that sent 8 headers yielded 2. Through the bridge the same
|
||||
request yields all 8, plus the redirect chain, `Set-Cookie`, connection timings
|
||||
and client certificates.
|
||||
|
||||
## Running it
|
||||
|
||||
Start the bridge:
|
||||
|
||||
```bash
|
||||
cargo run -p yaak-server -- --port 9444
|
||||
```
|
||||
|
||||
It binds `127.0.0.1` only and prints a bearer token that every route requires.
|
||||
|
||||
Then point a frontend at it. In dev, run Vite separately and tell it where the
|
||||
bridge is:
|
||||
|
||||
```bash
|
||||
YAAK_CLIENT_DEV_PORT=1472 VITE_YAAK_BRIDGE_URL=http://127.0.0.1:9444 npm run dev --workspace apps/yaak-client
|
||||
```
|
||||
|
||||
Open `http://localhost:1472/?bridgeToken=<token>`. The token is consumed from
|
||||
the query, kept for the session, and stripped from the address bar. Without one
|
||||
you get a small connect form.
|
||||
|
||||
To serve the built frontend from the bridge itself instead, so there is only one
|
||||
process:
|
||||
|
||||
```bash
|
||||
npm run build --workspace apps/yaak-client
|
||||
cargo run -p yaak-server -- --web-dir dist/apps/yaak-client
|
||||
```
|
||||
|
||||
## Shape
|
||||
|
||||
| Route | What it carries |
|
||||
| --- | --- |
|
||||
| `POST /rpc` | The yaak-rpc envelope, the same one Tauri's `invoke` wraps on the desktop |
|
||||
| `GET /events` | WebSocket. Server to client: `model_writes`, `stream_{id}`, toasts, plugin events. Client to server: the tab's location, and replies to prompts |
|
||||
| `GET /responses/:id/body` | Response bodies, with Range support. Replaces reading `bodyPath` off disk |
|
||||
| `GET /bridge/info` | Capabilities and the implemented command list |
|
||||
|
||||
Auth is a bearer token in the `Authorization` header, or a `token` query
|
||||
parameter for the two requests the browser issues itself (the WebSocket, and
|
||||
`<img src>`-style body loads). It is dev-grade and deliberately minimal: OTP
|
||||
pairing and request encryption replace it, and `require_token` in `http.rs` is
|
||||
where they go.
|
||||
|
||||
## Relationship to the other hosts
|
||||
|
||||
The engine crates under `crates/` are Tauri-free, and `crates-cli/yaak-cli`
|
||||
already proved they run headless. This crate is structurally the CLI's
|
||||
`CliContext` with an event hub attached — same `init_standalone` database, same
|
||||
`PluginManager` over the same Node sidecar.
|
||||
|
||||
Two things are ported deliberately rather than invented:
|
||||
|
||||
- **Model writes** (`model_writes.rs`) keep the desktop's two paths: an
|
||||
in-memory channel for writes this process made, and a poll of the
|
||||
`model_changes` table so external writers — the CLI, the desktop app open on
|
||||
the same database — show up live in the browser.
|
||||
- **Plugin host requests** (`plugin_events.rs`) let `yaak::plugin_events`
|
||||
answer everything that is only a database question, exactly as the CLI and the
|
||||
desktop do. Only the host-specific arms differ, and where the CLI answers a
|
||||
prompt from a TTY, the bridge round-trips it to the tab the way the desktop
|
||||
round-trips it to a window.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **Settings is unreachable.** The desktop opens it via `cmd_new_child_window`.
|
||||
A tab is one window, `multiWindow` is false, and this task did not add in-page
|
||||
routing for it.
|
||||
- **One tab at a time.** Model writes broadcast correctly to every connected
|
||||
tab, so two tabs stay in sync for reads. What breaks is the session: the
|
||||
tab's reported URL lives in a single slot, so with two tabs in different
|
||||
workspaces a plugin's template render resolves against whichever attached
|
||||
last. Prompts also broadcast, so a dialog raised by one tab appears in both.
|
||||
- **No local files.** There is no file dialog, so request bodies from disk,
|
||||
export, and save-response are unsupported. `cmd_import_data` is registered and
|
||||
works, but only for a path typed by hand on the bridge's machine.
|
||||
- **Command subset.** Roughly 40 of the desktop's 107 commands are implemented.
|
||||
The rest return a structured "not supported on this host" error naming the
|
||||
command; `UNSUPPORTED_COMMANDS` in `rpc/mod.rs` lists them.
|
||||
@@ -0,0 +1,117 @@
|
||||
//! The events channel: everything the browser tab would have received as a
|
||||
//! Tauri window event.
|
||||
//!
|
||||
//! Two directions ride the same WebSocket. Server to client is a broadcast, so
|
||||
//! `model_writes`, `stream_{id}` messages, toasts and plugin events all reach
|
||||
//! the tab through one pipe. Client to server exists because some plugin host
|
||||
//! requests are questions — a prompt round-trips through the UI and comes back
|
||||
//! keyed by the originating event's id, exactly as the desktop app's
|
||||
//! `call_frontend` does with window events.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
/// One frame in either direction: a name and a JSON payload.
|
||||
///
|
||||
/// Deliberately the same shape both ways, and the same shape as the desktop's
|
||||
/// event payloads, so `platform.listen` on the browser side hands the payload
|
||||
/// to callers unwrapped.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventFrame {
|
||||
pub event: String,
|
||||
#[serde(default)]
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EventHub {
|
||||
outbound: broadcast::Sender<EventFrame>,
|
||||
/// Listeners waiting on a named event from the client, keyed by event name.
|
||||
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
|
||||
}
|
||||
|
||||
/// A subscription to one named client-sent event. Deregisters on drop, so a
|
||||
/// prompt that is never answered doesn't leak a listener for the process's life.
|
||||
pub struct InboundSubscription {
|
||||
event: String,
|
||||
rx: mpsc::UnboundedReceiver<serde_json::Value>,
|
||||
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
|
||||
}
|
||||
|
||||
impl InboundSubscription {
|
||||
pub async fn recv(&mut self) -> Option<serde_json::Value> {
|
||||
self.rx.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InboundSubscription {
|
||||
fn drop(&mut self) {
|
||||
let mut inbound = match self.inbound.lock() {
|
||||
Ok(inbound) => inbound,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
if let Some(senders) = inbound.get_mut(&self.event) {
|
||||
senders.retain(|tx| !tx.is_closed());
|
||||
if senders.is_empty() {
|
||||
inbound.remove(&self.event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventHub {
|
||||
pub fn new() -> Self {
|
||||
// Bounded: a tab that stops reading gets dropped frames rather than
|
||||
// growing the server's memory without limit. Model writes are the
|
||||
// high-volume case (imports, bulk deletes) and they arrive in batches.
|
||||
let (outbound, _) = broadcast::channel(1024);
|
||||
Self { outbound, inbound: Arc::new(Mutex::new(HashMap::new())) }
|
||||
}
|
||||
|
||||
/// Send an event to every connected tab. Fails silently when none is
|
||||
/// connected, which is the normal state before a browser attaches.
|
||||
pub fn emit<T: Serialize>(&self, event: impl Into<String>, payload: &T) {
|
||||
let payload = match serde_json::to_value(payload) {
|
||||
Ok(payload) => payload,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to serialize event payload: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = self.outbound.send(EventFrame { event: event.into(), payload });
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<EventFrame> {
|
||||
self.outbound.subscribe()
|
||||
}
|
||||
|
||||
/// Listen for a named event sent *by* the client.
|
||||
pub fn subscribe_inbound(&self, event: impl Into<String>) -> InboundSubscription {
|
||||
let event = event.into();
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let mut inbound = match self.inbound.lock() {
|
||||
Ok(inbound) => inbound,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
inbound.entry(event.clone()).or_default().push(tx);
|
||||
drop(inbound);
|
||||
InboundSubscription { event, rx, inbound: Arc::clone(&self.inbound) }
|
||||
}
|
||||
|
||||
/// Route a frame that arrived from a tab to whoever is waiting on it.
|
||||
pub fn dispatch_inbound(&self, frame: EventFrame) {
|
||||
let mut inbound = match self.inbound.lock() {
|
||||
Ok(inbound) => inbound,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let Some(senders) = inbound.get_mut(&frame.event) else {
|
||||
return;
|
||||
};
|
||||
senders.retain(|tx| tx.send(frame.payload.clone()).is_ok());
|
||||
if senders.is_empty() {
|
||||
inbound.remove(&frame.event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! The front door: one HTTP surface for the browser tab.
|
||||
//!
|
||||
//! Three routes carry everything. `POST /rpc` is the yaak-rpc envelope, byte for
|
||||
//! byte what the desktop puts inside Tauri's `invoke`. `GET /events` is the
|
||||
//! WebSocket that replaces window events, in both directions. And
|
||||
//! `GET /responses/:id/body` replaces reading `bodyPath` off disk, which a tab
|
||||
//! cannot do.
|
||||
|
||||
use crate::events::EventFrame;
|
||||
use crate::rpc::BridgeCtx;
|
||||
use crate::session::SessionContext;
|
||||
use crate::state::BridgeState;
|
||||
use axum::body::Body;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path, Query, Request, State};
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use yaak_rpc::{RpcRequest, RpcResponse, RpcRouter};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub state: Arc<BridgeState>,
|
||||
pub router: Arc<RpcRouter<BridgeCtx>>,
|
||||
}
|
||||
|
||||
pub fn build_app(state: Arc<BridgeState>, router: Arc<RpcRouter<BridgeCtx>>) -> Router {
|
||||
let app_state = AppState { state: state.clone(), router };
|
||||
|
||||
let api = Router::new()
|
||||
.route("/bridge/info", get(bridge_info))
|
||||
.route("/rpc", post(rpc_handler))
|
||||
.route("/events", get(events_handler))
|
||||
.route("/responses/:id/body", get(response_body))
|
||||
.layer(axum::middleware::from_fn_with_state(state.clone(), require_token))
|
||||
// The dev setup serves the frontend from Vite on another port, so the
|
||||
// tab's origin is not the bridge's. Credentials never ride on cookies
|
||||
// here — the token is explicit — so a permissive CORS layer is safe and
|
||||
// is bounded by the token check that runs before it.
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(app_state);
|
||||
|
||||
match std::env::var("YAAK_BRIDGE_WEB_DIR").ok() {
|
||||
// Serving the built frontend makes the bridge a single process to run.
|
||||
// `index.html` is the fallback because the router owns the paths.
|
||||
Some(dir) => api.fallback_service(
|
||||
tower_http::services::ServeDir::new(&dir)
|
||||
.fallback(tower_http::services::ServeFile::new(format!("{dir}/index.html"))),
|
||||
),
|
||||
None => api,
|
||||
}
|
||||
}
|
||||
|
||||
// -- Auth --
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenQuery {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
/// Dev-grade bearer check on every route.
|
||||
///
|
||||
/// The header is the normal path. The query parameter exists because two of
|
||||
/// these are opened by the browser itself — the WebSocket and the `<img src>`
|
||||
/// pointing at a response body — and neither lets the page set headers.
|
||||
///
|
||||
/// This is the seam where OTP pairing and per-session keys go. It is not one
|
||||
/// today: the token is a process-lifetime shared secret, and anything that can
|
||||
/// read the tab's URL can read it.
|
||||
async fn require_token(
|
||||
State(state): State<Arc<BridgeState>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let from_header = request
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|v| v.to_string());
|
||||
|
||||
let from_query = request
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|q| serde_urlencoded::from_str::<TokenQuery>(q).ok())
|
||||
.and_then(|q| q.token);
|
||||
|
||||
let presented = from_header.or(from_query);
|
||||
|
||||
match presented {
|
||||
Some(token) if constant_time_eq(&token, &state.token) => next.run(request).await,
|
||||
_ => (StatusCode::UNAUTHORIZED, "Invalid or missing bridge token").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares without returning early on the first differing byte, so a caller
|
||||
/// can't learn the token one character at a time.
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
// -- Routes --
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BridgeInfo {
|
||||
name: String,
|
||||
version: String,
|
||||
capabilities: crate::state::BridgeCapabilities,
|
||||
/// Commands this build implements. The browser host uses it to fail fast
|
||||
/// with a clear message instead of waiting for a round trip.
|
||||
commands: Vec<String>,
|
||||
}
|
||||
|
||||
async fn bridge_info(State(app): State<AppState>) -> Json<BridgeInfo> {
|
||||
Json(BridgeInfo {
|
||||
name: "Yaak Bridge".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
capabilities: app.state.capabilities.clone(),
|
||||
commands: crate::rpc::implemented_commands(&app.router),
|
||||
})
|
||||
}
|
||||
|
||||
/// One envelope in, one out. Errors are carried inside the envelope, not as an
|
||||
/// HTTP status, so the browser host can reject the caller's promise with the
|
||||
/// backend's own message.
|
||||
async fn rpc_handler(
|
||||
State(app): State<AppState>,
|
||||
Json(req): Json<RpcRequest>,
|
||||
) -> Json<RpcResponse> {
|
||||
let ctx = BridgeCtx { state: app.state.clone(), session: app.state.session.get() };
|
||||
log::debug!("RPC {}", req.cmd);
|
||||
let response = app.router.handle(req, &ctx).await;
|
||||
if let RpcResponse::Error { error, .. } = &response {
|
||||
log::warn!("RPC failed: {error}");
|
||||
}
|
||||
Json(response)
|
||||
}
|
||||
|
||||
async fn events_handler(State(app): State<AppState>, ws: WebSocketUpgrade) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_events_socket(socket, app))
|
||||
}
|
||||
|
||||
/// The tab's first frame reports who and where it is; everything after that is
|
||||
/// a reply to something the server asked.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AttachPayload {
|
||||
label: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
async fn handle_events_socket(socket: WebSocket, app: AppState) {
|
||||
use futures::{SinkExt, StreamExt};
|
||||
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
let mut outbound = app.state.events.subscribe();
|
||||
|
||||
// Server to client.
|
||||
let send_task = tokio::spawn(async move {
|
||||
loop {
|
||||
match outbound.recv().await {
|
||||
Ok(frame) => {
|
||||
let Ok(text) = serde_json::to_string(&frame) else {
|
||||
continue;
|
||||
};
|
||||
if sink.send(Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A tab that fell behind has missed writes, and the model store
|
||||
// would be silently stale. Close instead, so a reconnect
|
||||
// re-reads the workspace from scratch.
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
log::warn!("Events client lagged by {n} frames; closing so it resyncs");
|
||||
break;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Client to server.
|
||||
let state = app.state.clone();
|
||||
let recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(message)) = stream.next().await {
|
||||
let Message::Text(text) = message else {
|
||||
continue;
|
||||
};
|
||||
let Ok(frame) = serde_json::from_str::<EventFrame>(&text) else {
|
||||
log::warn!("Ignoring malformed event frame from browser");
|
||||
continue;
|
||||
};
|
||||
|
||||
// `bridge_attach` is the browser telling us what the desktop would
|
||||
// have read off the window: its label and its current URL.
|
||||
if frame.event == "bridge_attach" {
|
||||
match serde_json::from_value::<AttachPayload>(frame.payload.clone()) {
|
||||
Ok(attach) => {
|
||||
log::info!("Browser attached: {} at {}", attach.label, attach.url);
|
||||
state.session.set(SessionContext {
|
||||
label: attach.label,
|
||||
url: attach.url,
|
||||
});
|
||||
}
|
||||
Err(e) => log::warn!("Bad bridge_attach payload: {e}"),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
state.events.dispatch_inbound(frame);
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = send_task => {},
|
||||
_ = recv_task => {},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BodyQuery {
|
||||
/// Present so the shared token extractor doesn't reject the request; the
|
||||
/// value itself is checked in the middleware.
|
||||
#[allow(dead_code)]
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
/// Stream a response body, with Range support.
|
||||
///
|
||||
/// Keyed by response id rather than by path: the tab hands back a `bodyPath`
|
||||
/// the backend gave it, and resolving that through the database means this
|
||||
/// route can only ever serve a file the engine wrote, not an arbitrary path a
|
||||
/// page asked for. Range matters because the video and audio viewers seek.
|
||||
async fn response_body(
|
||||
State(app): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Query(_q): Query<BodyQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let location = match app.state.locate_response_body(&id) {
|
||||
Ok(location) => location,
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "No such response").into_response(),
|
||||
};
|
||||
|
||||
let Some(body_path) = location.path else {
|
||||
return (StatusCode::NOT_FOUND, "Response has no body").into_response();
|
||||
};
|
||||
|
||||
let mut file = match tokio::fs::File::open(&body_path).await {
|
||||
Ok(file) => file,
|
||||
Err(e) => return (StatusCode::NOT_FOUND, format!("Body unavailable: {e}")).into_response(),
|
||||
};
|
||||
|
||||
let total = match file.metadata().await {
|
||||
Ok(meta) => meta.len(),
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body unreadable: {e}"))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let content_type = if location.content_type.is_empty() {
|
||||
"application/octet-stream".to_string()
|
||||
} else {
|
||||
location.content_type
|
||||
};
|
||||
|
||||
let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok()).and_then(parse_range);
|
||||
|
||||
let (start, end, status) = match range {
|
||||
Some((start, end)) => {
|
||||
let end = end.unwrap_or(total.saturating_sub(1)).min(total.saturating_sub(1));
|
||||
if total == 0 || start > end {
|
||||
return Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
(start, end, StatusCode::PARTIAL_CONTENT)
|
||||
}
|
||||
None => (0, total.saturating_sub(1), StatusCode::OK),
|
||||
};
|
||||
|
||||
let length = if total == 0 { 0 } else { end - start + 1 };
|
||||
|
||||
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to seek body").into_response();
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; length as usize];
|
||||
if let Err(e) = file.read_exact(&mut buf).await {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read body: {e}"))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CONTENT_LENGTH, length);
|
||||
|
||||
if status == StatusCode::PARTIAL_CONTENT {
|
||||
builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"));
|
||||
}
|
||||
|
||||
builder.body(Body::from(buf)).unwrap()
|
||||
}
|
||||
|
||||
/// Parses a single `bytes=start-end` range. Multi-range requests are not
|
||||
/// answered as multipart; the first range is used, which browsers accept.
|
||||
fn parse_range(value: &str) -> Option<(u64, Option<u64>)> {
|
||||
let spec = value.strip_prefix("bytes=")?.split(',').next()?.trim();
|
||||
let (start, end) = spec.split_once('-')?;
|
||||
if start.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let start: u64 = start.parse().ok()?;
|
||||
let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_ranges() {
|
||||
assert_eq!(parse_range("bytes=0-499"), Some((0, Some(499))));
|
||||
assert_eq!(parse_range("bytes=500-"), Some((500, None)));
|
||||
assert_eq!(parse_range("bytes=0-99,200-299"), Some((0, Some(99))));
|
||||
// Suffix ranges ("last 500 bytes") aren't supported; callers get the
|
||||
// whole body, which is correct if wasteful.
|
||||
assert_eq!(parse_range("bytes=-500"), None);
|
||||
assert_eq!(parse_range("nonsense"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_comparison_requires_exact_match() {
|
||||
assert!(constant_time_eq("abc", "abc"));
|
||||
assert!(!constant_time_eq("abc", "abd"));
|
||||
assert!(!constant_time_eq("abc", "abcd"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Yaak Bridge — the local companion that runs the real Yaak engine for a
|
||||
//! browser tab.
|
||||
//!
|
||||
//! The tab is the Yaak UI, unchanged. Everything it cannot do in a page —
|
||||
//! sending an HTTP request and seeing every response header, following
|
||||
//! redirects, keeping a cookie jar, running plugins, reading a response body
|
||||
//! off disk — happens in this process, over a local HTTP and WebSocket
|
||||
//! connection.
|
||||
//!
|
||||
//! Loopback only, and every route needs the token printed at startup.
|
||||
|
||||
mod events;
|
||||
mod http;
|
||||
mod model_writes;
|
||||
mod plugin_events;
|
||||
mod rpc;
|
||||
mod session;
|
||||
mod state;
|
||||
|
||||
use clap::Parser;
|
||||
use rand::Rng;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
const APP_ID: &str = "app.yaak.bridge";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "yaak-bridge", about = "Run the Yaak engine for a browser tab")]
|
||||
struct Args {
|
||||
/// Port to listen on. Loopback only, always.
|
||||
#[arg(long, default_value_t = 9444, env = "YAAK_BRIDGE_PORT")]
|
||||
port: u16,
|
||||
|
||||
/// Where the database, plugins and response bodies live.
|
||||
#[arg(long, env = "YAAK_BRIDGE_DATA_DIR")]
|
||||
data_dir: Option<PathBuf>,
|
||||
|
||||
/// Use a fixed token instead of generating one. For scripted dev loops.
|
||||
#[arg(long, env = "YAAK_BRIDGE_TOKEN")]
|
||||
token: Option<String>,
|
||||
|
||||
/// Where the frontend was built to. Serving it makes this the only process
|
||||
/// to run; without it, point a Vite dev server at this bridge instead.
|
||||
#[arg(long, env = "YAAK_BRIDGE_WEB_DIR")]
|
||||
web_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
let data_dir = args.data_dir.unwrap_or_else(default_data_dir);
|
||||
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
||||
eprintln!("Error: failed to create data dir {}: {e}", data_dir.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if let Some(web_dir) = &args.web_dir {
|
||||
// Read back by the router; keeping it in the environment avoids
|
||||
// threading an option through every layer for a dev-mode convenience.
|
||||
unsafe { std::env::set_var("YAAK_BRIDGE_WEB_DIR", web_dir) };
|
||||
}
|
||||
|
||||
let token = args.token.unwrap_or_else(generate_token);
|
||||
let is_dev = cfg!(debug_assertions);
|
||||
|
||||
let mut state = state::BridgeState::new(data_dir.clone(), APP_ID, token.clone(), is_dev);
|
||||
state.init_plugins().await;
|
||||
let state = Arc::new(state);
|
||||
|
||||
let router = Arc::new(rpc::build_router());
|
||||
let app = http::build_app(state.clone(), router);
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], args.port));
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(listener) => listener,
|
||||
Err(e) => {
|
||||
eprintln!("Error: failed to bind {addr}: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let base = format!("http://127.0.0.1:{}", args.port);
|
||||
println!();
|
||||
println!(" Yaak Bridge listening on {base}");
|
||||
println!(" Data dir: {}", data_dir.display());
|
||||
println!(" Plugins: {}", if state.capabilities.plugins { "running" } else { "unavailable" });
|
||||
println!();
|
||||
if std::env::var("YAAK_BRIDGE_WEB_DIR").is_ok() {
|
||||
println!(" Open: {base}/?bridgeToken={token}");
|
||||
} else {
|
||||
println!(" Token: {token}");
|
||||
println!(" Open your dev server with ?bridgeToken={token}");
|
||||
}
|
||||
println!();
|
||||
|
||||
let shutdown_state = state.clone();
|
||||
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
log::info!("Shutting down");
|
||||
shutdown_state.shutdown().await;
|
||||
});
|
||||
|
||||
if let Err(e) = server.await {
|
||||
eprintln!("Error: server failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_data_dir() -> PathBuf {
|
||||
dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")).join("yaak-bridge")
|
||||
}
|
||||
|
||||
/// A 256-bit random token, hex encoded. Per process, never written to disk.
|
||||
fn generate_token() -> String {
|
||||
let bytes: [u8; 32] = rand::thread_rng().r#gen();
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Pushing model writes to the connected tab.
|
||||
//!
|
||||
//! A direct port of the desktop's two paths (see
|
||||
//! crates-tauri/yaak-app-client/src/models_ext.rs), and for the same reason:
|
||||
//! the in-memory channel is the fast path for writes this process made on a
|
||||
//! client's behalf, while polling the `model_changes` table is what makes an
|
||||
//! external writer — the CLI, a second bridge, the desktop app open on the same
|
||||
//! database — show up live in the browser. Keeping both means the browser
|
||||
//! behaves like the desktop rather than like a cache.
|
||||
|
||||
use crate::events::EventHub;
|
||||
use chrono::Utc;
|
||||
use log::error;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::Duration;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
||||
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
|
||||
|
||||
struct ModelChangeCursor {
|
||||
created_at: String,
|
||||
id: i64,
|
||||
}
|
||||
|
||||
impl ModelChangeCursor {
|
||||
fn from_launch_time() -> Self {
|
||||
Self {
|
||||
created_at: Utc::now().naive_utc().format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
|
||||
id: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(query_manager: &QueryManager, rx: Receiver<ModelPayload>, events: EventHub) {
|
||||
if let Err(err) =
|
||||
query_manager.connect().prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
|
||||
{
|
||||
error!("Failed to prune model_changes rows on startup: {err:?}");
|
||||
}
|
||||
|
||||
// Only stream writes that happen after this process started.
|
||||
let cursor = ModelChangeCursor::from_launch_time();
|
||||
let poll_query_manager = query_manager.clone();
|
||||
let poll_events = events.clone();
|
||||
tokio::spawn(async move {
|
||||
run_model_change_poller(poll_query_manager, poll_events, cursor).await;
|
||||
});
|
||||
|
||||
// `init_standalone` hands back a std (blocking) receiver, so it gets a
|
||||
// thread rather than a task.
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(payload) = rx.recv() {
|
||||
let mut batch: Vec<ModelPayload> = Vec::new();
|
||||
if matches!(payload.update_source, UpdateSource::Window { .. }) {
|
||||
batch.push(payload);
|
||||
}
|
||||
// Coalesce anything already queued into the same frame.
|
||||
while let Ok(next) = rx.try_recv() {
|
||||
if matches!(next.update_source, UpdateSource::Window { .. }) {
|
||||
batch.push(next);
|
||||
}
|
||||
}
|
||||
if batch.is_empty() {
|
||||
continue;
|
||||
}
|
||||
events.emit("model_writes", &batch);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_model_change_poller(
|
||||
query_manager: QueryManager,
|
||||
events: EventHub,
|
||||
mut cursor: ModelChangeCursor,
|
||||
) {
|
||||
loop {
|
||||
while drain_model_changes_batch(&query_manager, &events, &mut cursor) {}
|
||||
tokio::time::sleep(Duration::from_millis(MODEL_CHANGES_POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_model_changes_batch(
|
||||
query_manager: &QueryManager,
|
||||
events: &EventHub,
|
||||
cursor: &mut ModelChangeCursor,
|
||||
) -> bool {
|
||||
let changes = match query_manager.connect().list_model_changes_since(
|
||||
&cursor.created_at,
|
||||
cursor.id,
|
||||
MODEL_CHANGES_POLL_BATCH_SIZE,
|
||||
) {
|
||||
Ok(changes) => changes,
|
||||
Err(err) => {
|
||||
error!("Failed to poll model_changes rows: {err:?}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if changes.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let fetched_count = changes.len();
|
||||
let mut batch: Vec<ModelPayload> = Vec::with_capacity(fetched_count);
|
||||
for change in changes {
|
||||
cursor.created_at = change.created_at;
|
||||
cursor.id = change.id;
|
||||
|
||||
// Window-sourced writes already went out on the in-memory fast path.
|
||||
if matches!(change.payload.update_source, UpdateSource::Window { .. }) {
|
||||
continue;
|
||||
}
|
||||
batch.push(change.payload);
|
||||
}
|
||||
|
||||
// One batch per drain so bulk writes don't flood the tab.
|
||||
if !batch.is_empty() {
|
||||
events.emit("model_writes", &batch);
|
||||
}
|
||||
|
||||
fetched_count == MODEL_CHANGES_POLL_BATCH_SIZE
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
//! The bridge's plugin host.
|
||||
//!
|
||||
//! Same shape as the CLI's bridge (crates-cli/yaak-cli/src/plugin_events.rs):
|
||||
//! subscribe to the plugin manager, let `handle_shared_plugin_event` answer
|
||||
//! everything that is only a database question, and implement the rest here.
|
||||
//!
|
||||
//! Where it differs is that a UI is attached. The CLI answers a prompt from a
|
||||
//! TTY and refuses when there isn't one; the bridge does what the desktop does
|
||||
//! instead — pushes the event to the tab and waits for the reply keyed by the
|
||||
//! event's id. Toasts, clipboard writes and external URLs go the same way,
|
||||
//! because the browser is the only thing here that can show or do them.
|
||||
|
||||
use crate::events::EventHub;
|
||||
use crate::session::SessionStore;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::task::JoinHandle;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::render::{render_grpc_request, render_http_request};
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
use yaak_http::manager::HttpConnectionManager;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::Environment;
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{
|
||||
EmptyPayload, ErrorResponse, GetCookieValueResponse, InternalEvent, InternalEventPayload,
|
||||
ListCookieNamesResponse, ListOpenWorkspacesResponse, PluginContext, PromptTextResponse,
|
||||
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
|
||||
TemplateRenderResponse, WindowInfoResponse, WorkspaceInfo,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_handle::PluginHandle;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, render_json_value_raw};
|
||||
|
||||
pub struct BridgePluginEventBridge {
|
||||
rx_id: String,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
struct BridgeHostContext {
|
||||
query_manager: QueryManager,
|
||||
blob_manager: BlobManager,
|
||||
plugin_manager: Arc<PluginManager>,
|
||||
encryption_manager: Arc<EncryptionManager>,
|
||||
connection_manager: Arc<HttpConnectionManager>,
|
||||
response_dir: PathBuf,
|
||||
events: EventHub,
|
||||
session: SessionStore,
|
||||
}
|
||||
|
||||
impl BridgePluginEventBridge {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn start(
|
||||
plugin_manager: Arc<PluginManager>,
|
||||
query_manager: QueryManager,
|
||||
blob_manager: BlobManager,
|
||||
encryption_manager: Arc<EncryptionManager>,
|
||||
connection_manager: Arc<HttpConnectionManager>,
|
||||
data_dir: PathBuf,
|
||||
events: EventHub,
|
||||
session: SessionStore,
|
||||
) -> Self {
|
||||
let (rx_id, mut rx) = plugin_manager.subscribe("bridge").await;
|
||||
let rx_id_for_task = rx_id.clone();
|
||||
let pm = plugin_manager.clone();
|
||||
let host_context = Arc::new(BridgeHostContext {
|
||||
query_manager,
|
||||
blob_manager,
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
connection_manager,
|
||||
response_dir: data_dir.join("responses"),
|
||||
events,
|
||||
session,
|
||||
});
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
// Events with reply IDs are replies to app-originated requests.
|
||||
if event.reply_id.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(plugin_handle) = pm.get_plugin_by_ref_id(&event.plugin_ref_id).await
|
||||
else {
|
||||
log::warn!(
|
||||
"Ignoring plugin event with unknown plugin ref '{}'",
|
||||
event.plugin_ref_id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let pm = pm.clone();
|
||||
let host_context = host_context.clone();
|
||||
|
||||
// Avoid deadlocks for nested plugin-host requests (for example, template functions
|
||||
// that trigger additional host requests during render) by handling each event in
|
||||
// its own task.
|
||||
tokio::spawn(async move {
|
||||
let plugin_name = plugin_handle.info().name;
|
||||
let Some(reply_payload) = build_plugin_reply(
|
||||
host_context.as_ref(),
|
||||
&event,
|
||||
&plugin_name,
|
||||
&plugin_handle,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = pm.reply(&event, &reply_payload).await {
|
||||
log::warn!("Failed replying to plugin event: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pm.unsubscribe(&rx_id_for_task).await;
|
||||
});
|
||||
|
||||
Self { rx_id, task }
|
||||
}
|
||||
|
||||
pub async fn shutdown(self, plugin_manager: &PluginManager) {
|
||||
plugin_manager.unsubscribe(&self.rx_id).await;
|
||||
self.task.abort();
|
||||
let _ = self.task.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_plugin_reply(
|
||||
host_context: &BridgeHostContext,
|
||||
event: &InternalEvent,
|
||||
plugin_name: &str,
|
||||
plugin_handle: &PluginHandle,
|
||||
) -> Option<InternalEventPayload> {
|
||||
let session = host_context.session.get();
|
||||
let shared_workspace_id =
|
||||
event.context.workspace_id.clone().or_else(|| session.workspace_id());
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
&host_context.query_manager,
|
||||
&event.payload,
|
||||
SharedPluginEventContext {
|
||||
plugin_name,
|
||||
workspace_id: shared_workspace_id.as_deref(),
|
||||
},
|
||||
) {
|
||||
GroupedPluginEvent::Handled(payload) => payload,
|
||||
GroupedPluginEvent::ToHandle(host_request) => match host_request {
|
||||
HostRequest::ErrorResponse(resp) => {
|
||||
log::warn!("[plugin:{plugin_name}] error: {}", resp.error);
|
||||
None
|
||||
}
|
||||
HostRequest::ReloadResponse(_) => None,
|
||||
|
||||
// The tab owns everything the user can see or the OS can do. These
|
||||
// are fire-and-forget: the plugin gets its acknowledgement as soon
|
||||
// as the frame is queued, matching the desktop, which also does not
|
||||
// wait for the webview to paint.
|
||||
HostRequest::ShowToast(req) => {
|
||||
host_context.events.emit("show_toast", &req);
|
||||
Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))
|
||||
}
|
||||
HostRequest::CopyText(req) => {
|
||||
host_context.events.emit("bridge_copy_text", &req);
|
||||
Some(InternalEventPayload::CopyTextResponse(EmptyPayload {}))
|
||||
}
|
||||
HostRequest::OpenExternalUrl(req) => {
|
||||
host_context.events.emit("bridge_open_url", &req);
|
||||
Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {}))
|
||||
}
|
||||
|
||||
// Prompts are questions, so they round-trip: the tab renders the
|
||||
// dialog and emits the answer back under the event's own id.
|
||||
HostRequest::PromptText(_) => {
|
||||
let reply = call_frontend(host_context, event).await;
|
||||
Some(reply.unwrap_or(InternalEventPayload::PromptTextResponse(
|
||||
PromptTextResponse { value: None },
|
||||
)))
|
||||
}
|
||||
|
||||
// A form streams: the tab sends a response per interaction and the
|
||||
// plugin re-renders, until one comes back marked done.
|
||||
HostRequest::PromptForm(_) => {
|
||||
host_context.events.emit("plugin_event", event);
|
||||
if event.reply_id.is_none() {
|
||||
spawn_form_reply_pump(host_context, event, plugin_handle);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
HostRequest::ListOpenWorkspaces(_) => {
|
||||
let workspaces = match host_context.query_manager.connect().list_workspaces() {
|
||||
Ok(workspaces) => workspaces
|
||||
.into_iter()
|
||||
.map(|w| WorkspaceInfo {
|
||||
id: w.id.clone(),
|
||||
name: w.name,
|
||||
label: session.label.clone(),
|
||||
})
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to list workspaces in bridge: {err}"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
Some(InternalEventPayload::ListOpenWorkspacesResponse(ListOpenWorkspacesResponse {
|
||||
workspaces,
|
||||
}))
|
||||
}
|
||||
|
||||
HostRequest::SendHttpRequest(req) => {
|
||||
let mut http_request = req.http_request.clone();
|
||||
if http_request.workspace_id.is_empty() {
|
||||
let Some(workspace_id) = shared_workspace_id.clone() else {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: "workspace_id is required to send HTTP requests in bridge"
|
||||
.to_string(),
|
||||
}));
|
||||
};
|
||||
http_request.workspace_id = workspace_id;
|
||||
}
|
||||
|
||||
let cookie_jar_id = match session.cookie_jar_id() {
|
||||
Some(id) => Some(id),
|
||||
None => match host_context
|
||||
.query_manager
|
||||
.connect()
|
||||
.list_cookie_jars(http_request.workspace_id.as_str())
|
||||
{
|
||||
Ok(jars) => {
|
||||
jars.into_iter().min_by_key(|jar| jar.created_at).map(|jar| jar.id)
|
||||
}
|
||||
Err(err) => {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to list cookie jars in bridge: {err}"),
|
||||
}));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let plugin_context = PluginContext {
|
||||
workspace_id: Some(http_request.workspace_id.clone()),
|
||||
..event.context.clone()
|
||||
};
|
||||
|
||||
match send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
|
||||
query_manager: &host_context.query_manager,
|
||||
blob_manager: &host_context.blob_manager,
|
||||
request: http_request,
|
||||
environment_id: session.environment_id().as_deref(),
|
||||
update_source: UpdateSource::Plugin,
|
||||
cookie_jar_id,
|
||||
response_dir: &host_context.response_dir,
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: None,
|
||||
existing_response: None,
|
||||
plugin_manager: host_context.plugin_manager.clone(),
|
||||
encryption_manager: host_context.encryption_manager.clone(),
|
||||
plugin_context: &plugin_context,
|
||||
cancelled_rx: None,
|
||||
connection_manager: &host_context.connection_manager,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
|
||||
SendHttpRequestResponse { http_response: result.response },
|
||||
)),
|
||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to send HTTP request in bridge: {err}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
HostRequest::RenderHttpRequest(req) => {
|
||||
let mut http_request = req.http_request.clone();
|
||||
if http_request.workspace_id.is_empty() {
|
||||
let Some(workspace_id) = shared_workspace_id.clone() else {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: "workspace_id is required to render HTTP requests in bridge"
|
||||
.to_string(),
|
||||
}));
|
||||
};
|
||||
http_request.workspace_id = workspace_id;
|
||||
}
|
||||
|
||||
let plugin_context = PluginContext {
|
||||
workspace_id: Some(http_request.workspace_id.clone()),
|
||||
..event.context.clone()
|
||||
};
|
||||
|
||||
let environment_chain = match host_context.query_manager.connect().resolve_environments(
|
||||
&http_request.workspace_id,
|
||||
http_request.folder_id.as_deref(),
|
||||
session.environment_id().as_deref(),
|
||||
) {
|
||||
Ok(chain) => chain,
|
||||
Err(err) => {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to resolve environments in bridge: {err}"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let template_callback = PluginTemplateCallback::new(
|
||||
host_context.plugin_manager.clone(),
|
||||
host_context.encryption_manager.clone(),
|
||||
&plugin_context,
|
||||
req.purpose.clone(),
|
||||
);
|
||||
|
||||
match render_http_request(
|
||||
&http_request,
|
||||
environment_chain,
|
||||
&template_callback,
|
||||
&RenderOptions::throw(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(http_request) => Some(InternalEventPayload::RenderHttpRequestResponse(
|
||||
RenderHttpRequestResponse { http_request },
|
||||
)),
|
||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to render HTTP request in bridge: {err}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
HostRequest::RenderGrpcRequest(req) => {
|
||||
let mut grpc_request = req.grpc_request.clone();
|
||||
if grpc_request.workspace_id.is_empty() {
|
||||
let Some(workspace_id) = shared_workspace_id.clone() else {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: "workspace_id is required to render gRPC requests in bridge"
|
||||
.to_string(),
|
||||
}));
|
||||
};
|
||||
grpc_request.workspace_id = workspace_id;
|
||||
}
|
||||
|
||||
let plugin_context = PluginContext {
|
||||
workspace_id: Some(grpc_request.workspace_id.clone()),
|
||||
..event.context.clone()
|
||||
};
|
||||
|
||||
let environment_chain = match host_context.query_manager.connect().resolve_environments(
|
||||
&grpc_request.workspace_id,
|
||||
grpc_request.folder_id.as_deref(),
|
||||
session.environment_id().as_deref(),
|
||||
) {
|
||||
Ok(chain) => chain,
|
||||
Err(err) => {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to resolve environments in bridge: {err}"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let template_callback = PluginTemplateCallback::new(
|
||||
host_context.plugin_manager.clone(),
|
||||
host_context.encryption_manager.clone(),
|
||||
&plugin_context,
|
||||
req.purpose.clone(),
|
||||
);
|
||||
|
||||
match render_grpc_request(
|
||||
&grpc_request,
|
||||
environment_chain,
|
||||
&template_callback,
|
||||
&RenderOptions::throw(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(grpc_request) => Some(InternalEventPayload::RenderGrpcRequestResponse(
|
||||
RenderGrpcRequestResponse { grpc_request },
|
||||
)),
|
||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to render gRPC request in bridge: {err}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
HostRequest::TemplateRender(req) => {
|
||||
let Some(workspace_id) = shared_workspace_id.clone() else {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: "workspace_id is required to render templates in bridge".to_string(),
|
||||
}));
|
||||
};
|
||||
|
||||
let plugin_context =
|
||||
PluginContext { workspace_id: Some(workspace_id.clone()), ..event.context.clone() };
|
||||
|
||||
let folder_id = session.request_id().and_then(|rid| {
|
||||
match host_context.query_manager.connect().get_any_request(&rid) {
|
||||
Ok(AnyRequest::HttpRequest(r)) => r.folder_id,
|
||||
Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,
|
||||
Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,
|
||||
Err(_) => None,
|
||||
}
|
||||
});
|
||||
|
||||
let environment_chain = match host_context.query_manager.connect().resolve_environments(
|
||||
&workspace_id,
|
||||
folder_id.as_deref(),
|
||||
session.environment_id().as_deref(),
|
||||
) {
|
||||
Ok(chain) => chain,
|
||||
Err(err) => {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to resolve environments in bridge: {err}"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let template_callback = PluginTemplateCallback::new(
|
||||
host_context.plugin_manager.clone(),
|
||||
host_context.encryption_manager.clone(),
|
||||
&plugin_context,
|
||||
req.purpose.clone(),
|
||||
);
|
||||
|
||||
match render_json_value(
|
||||
req.data.clone(),
|
||||
environment_chain,
|
||||
&template_callback,
|
||||
&RenderOptions::throw(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(data) => {
|
||||
Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse {
|
||||
data,
|
||||
}))
|
||||
}
|
||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to render template data in bridge: {err}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
HostRequest::ListCookieNames(_) => {
|
||||
let Some(cookie_jar_id) = session.cookie_jar_id() else {
|
||||
return Some(InternalEventPayload::ListCookieNamesResponse(
|
||||
ListCookieNamesResponse { names: Vec::new() },
|
||||
));
|
||||
};
|
||||
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
|
||||
Ok(jar) => Some(InternalEventPayload::ListCookieNamesResponse(
|
||||
ListCookieNamesResponse {
|
||||
names: jar.cookies.into_iter().map(|c| c.name).collect(),
|
||||
},
|
||||
)),
|
||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to load cookie jar in bridge: {err}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
HostRequest::GetCookieValue(req) => {
|
||||
let Some(cookie_jar_id) = session.cookie_jar_id() else {
|
||||
return Some(InternalEventPayload::GetCookieValueResponse(
|
||||
GetCookieValueResponse { value: None },
|
||||
));
|
||||
};
|
||||
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
|
||||
Ok(jar) => {
|
||||
let value =
|
||||
get_cookie_value_from_jar(jar.cookies, &req.name, req.domain.as_deref());
|
||||
Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse {
|
||||
value,
|
||||
}))
|
||||
}
|
||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to load cookie jar in bridge: {err}"),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
HostRequest::WindowInfo(req) => {
|
||||
Some(InternalEventPayload::WindowInfoResponse(WindowInfoResponse {
|
||||
label: req.label.clone(),
|
||||
request_id: session.request_id(),
|
||||
workspace_id: shared_workspace_id.clone(),
|
||||
environment_id: session.environment_id(),
|
||||
}))
|
||||
}
|
||||
|
||||
// A tab is one window. Opening and closing them needs the
|
||||
// multiWindow capability the bridge reports false.
|
||||
HostRequest::OpenWindow(_) => Some(unsupported("open_window_request")),
|
||||
HostRequest::CloseWindow(_) => Some(unsupported("close_window_request")),
|
||||
HostRequest::OtherRequest(payload) => Some(unsupported(&payload.type_name())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(type_name: &str) -> InternalEventPayload {
|
||||
InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Unsupported plugin request in bridge: {type_name}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ask the tab and wait for its answer, keyed by the event's id — the same
|
||||
/// contract as the desktop's `call_frontend`.
|
||||
async fn call_frontend(
|
||||
host_context: &BridgeHostContext,
|
||||
event: &InternalEvent,
|
||||
) -> Option<InternalEventPayload> {
|
||||
// Subscribe before emitting: the tab can answer faster than this task is
|
||||
// rescheduled, and a reply that arrives before the listener exists is lost.
|
||||
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
|
||||
host_context.events.emit("plugin_event", event);
|
||||
|
||||
let value = replies.recv().await?;
|
||||
match serde_json::from_value::<InternalEvent>(value) {
|
||||
Ok(reply) => Some(reply.payload),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse plugin reply from browser: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward every form response the tab sends back to the plugin, until one is
|
||||
/// marked done.
|
||||
fn spawn_form_reply_pump(
|
||||
host_context: &BridgeHostContext,
|
||||
event: &InternalEvent,
|
||||
plugin_handle: &PluginHandle,
|
||||
) {
|
||||
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
|
||||
let plugin_handle = plugin_handle.clone();
|
||||
let plugin_context = event.context.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(value) = replies.recv().await {
|
||||
let Ok(resp) = serde_json::from_value::<InternalEvent>(value) else {
|
||||
log::warn!("Failed to parse form response from browser");
|
||||
continue;
|
||||
};
|
||||
|
||||
let is_done = matches!(
|
||||
&resp.payload,
|
||||
InternalEventPayload::PromptFormResponse(r) if r.done.unwrap_or(false)
|
||||
);
|
||||
|
||||
let event_to_send = plugin_handle.build_event_to_send(
|
||||
&plugin_context,
|
||||
&resp.payload,
|
||||
Some(resp.reply_id.unwrap_or_default()),
|
||||
);
|
||||
if let Err(e) = plugin_handle.send(&event_to_send).await {
|
||||
log::warn!("Failed to forward form response to plugin: {e:?}");
|
||||
}
|
||||
|
||||
if is_done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn render_json_value<T: TemplateCallback>(
|
||||
value: Value,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<Value> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
@@ -0,0 +1,985 @@
|
||||
//! The implemented commands.
|
||||
//!
|
||||
//! Request payloads mirror the desktop's structs in
|
||||
//! crates-tauri/yaak-app-client/src/rpc_ext.rs field for field, because the
|
||||
//! frontend is unchanged and sends the same JSON. They are redeclared rather
|
||||
//! than shared: those live in a Tauri crate this one must not depend on, and
|
||||
//! they are plain data. The command *bodies* are what matter, and they call the
|
||||
//! same engine functions the desktop calls.
|
||||
|
||||
use super::{BridgeCtx, unsupported_command};
|
||||
use mime_guess::{Mime, mime};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use yaak::import::{ImportDataParams, import_data as import_data_shared};
|
||||
use yaak::models_ops::{delete_model, duplicate_model, upsert_model};
|
||||
use yaak::send::{ResponseBody, SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::models::{
|
||||
AnyModel, Environment, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader,
|
||||
HttpResponse, HttpResponseEvent, HttpResponseState, Settings, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallHttpRequestActionRequest, CallWorkspaceActionRequest,
|
||||
FilterResponse, GetFolderActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::native_template_functions::{
|
||||
decrypt_secure_template_function, encrypt_secure_template_function,
|
||||
};
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_rpc::{RpcError, RpcRouter, rpc_handler_async};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_templates::format_json::format_json;
|
||||
use yaak_templates::{
|
||||
RenderErrorBehavior, RenderOptions, TemplateCallback, parse_and_render,
|
||||
render_json_value_raw,
|
||||
};
|
||||
|
||||
type Result<T> = std::result::Result<T, RpcError>;
|
||||
|
||||
/// Any engine error becomes an RPC error with its message, matching how the
|
||||
/// desktop's `rpc` command flattens its error enum before it crosses the wire.
|
||||
fn err(e: impl std::fmt::Display) -> RpcError {
|
||||
RpcError { message: e.to_string() }
|
||||
}
|
||||
|
||||
/// Run database work that opens a transaction off the async runtime.
|
||||
///
|
||||
/// A `rusqlite` transaction borrows a connection that is neither `Send` nor
|
||||
/// `Sync`, so a future holding one cannot be spawned. Moving it to a blocking
|
||||
/// thread satisfies that and is the right shape anyway — these are synchronous
|
||||
/// disk writes that can cascade.
|
||||
async fn blocking<T, F>(f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce() -> std::result::Result<T, yaak_models::error::Error> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
match tokio::task::spawn_blocking(f).await {
|
||||
Ok(result) => result.map_err(err),
|
||||
Err(e) => Err(RpcError { message: format!("Database task failed: {e}") }),
|
||||
}
|
||||
}
|
||||
|
||||
// -- App metadata --
|
||||
|
||||
async fn cmd_metadata(ctx: BridgeCtx, _req: CmdMetadataReq) -> Result<AppMetaData> {
|
||||
let data_dir = ctx.state.data_dir().to_string_lossy().to_string();
|
||||
Ok(AppMetaData {
|
||||
is_dev: ctx.state.is_dev,
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
cli_version: None,
|
||||
name: "Yaak Bridge".to_string(),
|
||||
app_data_dir: data_dir.clone(),
|
||||
app_log_dir: data_dir.clone(),
|
||||
vendored_plugin_dir: ctx
|
||||
.state
|
||||
.data_dir()
|
||||
.join("vendored-plugins")
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
default_project_dir: dirs::home_dir()
|
||||
.map(|d| d.join("YaakProjects"))
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
feature_updater: false,
|
||||
feature_license: false,
|
||||
})
|
||||
}
|
||||
|
||||
// -- Models --
|
||||
|
||||
async fn models_upsert(ctx: BridgeCtx, req: ModelsUpsertReq) -> Result<String> {
|
||||
let db = ctx.state.db();
|
||||
upsert_model(&db, ctx.state.blob_manager(), req.model, &ctx.update_source()).map_err(err)
|
||||
}
|
||||
|
||||
/// Deletes run on a blocking thread, as they do on the desktop: a transaction
|
||||
/// holds a raw sqlite connection, which is neither `Send` nor cheap to hold —
|
||||
/// dropping a workspace with thousands of requests would otherwise stall the
|
||||
/// runtime and every other request with it.
|
||||
async fn models_delete(ctx: BridgeCtx, req: ModelsDeleteReq) -> Result<String> {
|
||||
let source = ctx.update_source();
|
||||
blocking(move || {
|
||||
ctx.state
|
||||
.query_manager()
|
||||
.with_tx(|tx| delete_model(tx, ctx.state.blob_manager(), req.model, &source))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn models_duplicate(ctx: BridgeCtx, req: ModelsDuplicateReq) -> Result<String> {
|
||||
let source = ctx.update_source();
|
||||
blocking(move || {
|
||||
ctx.state
|
||||
.query_manager()
|
||||
.with_tx(|tx| duplicate_model(tx, &req.model_type, &req.model_id, &source))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn models_get_settings(ctx: BridgeCtx, _req: ModelsGetSettingsReq) -> Result<Settings> {
|
||||
Ok(ctx.state.db().get_settings())
|
||||
}
|
||||
|
||||
/// Everything the frontend's model store needs for a workspace, as one JSON
|
||||
/// string.
|
||||
///
|
||||
/// The desktop escapes non-ASCII into `\uXXXX` before handing this to the
|
||||
/// webview; that is a workaround for Tauri's IPC and would only corrupt a
|
||||
/// perfectly good UTF-8 HTTP response body, so the bridge returns the string as
|
||||
/// serialized. The frontend `JSON.parse`s either form identically.
|
||||
async fn models_workspace_models(ctx: BridgeCtx, req: ModelsWorkspaceModelsReq) -> Result<String> {
|
||||
let mut l: Vec<AnyModel> = Vec::new();
|
||||
|
||||
{
|
||||
let db = ctx.state.db();
|
||||
l.push(db.get_settings().into());
|
||||
l.append(&mut db.list_workspaces().map_err(err)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_key_values().map_err(err)?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
let plugins = ctx.state.db().list_plugins().map_err(err)?;
|
||||
if let Some(plugin_manager) = ctx.state.plugin_manager() {
|
||||
let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;
|
||||
l.append(&mut plugins.into_iter().map(Into::into).collect());
|
||||
} else {
|
||||
l.append(&mut plugins.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
if let Some(wid) = req.workspace_id.as_deref() {
|
||||
let db = ctx.state.db();
|
||||
l.append(&mut db.list_cookie_jars(wid).map_err(err)?.into_iter().map(Into::into).collect());
|
||||
l.append(
|
||||
&mut db
|
||||
.list_environments_ensure_base(wid)
|
||||
.map_err(err)?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
);
|
||||
l.append(&mut db.list_folders(wid).map_err(err)?.into_iter().map(Into::into).collect());
|
||||
l.append(
|
||||
&mut db.list_grpc_connections(wid).map_err(err)?.into_iter().map(Into::into).collect(),
|
||||
);
|
||||
l.append(
|
||||
&mut db.list_grpc_requests(wid).map_err(err)?.into_iter().map(Into::into).collect(),
|
||||
);
|
||||
l.append(
|
||||
&mut db.list_http_requests(wid).map_err(err)?.into_iter().map(Into::into).collect(),
|
||||
);
|
||||
l.append(
|
||||
&mut db
|
||||
.list_http_responses(wid, None)
|
||||
.map_err(err)?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
);
|
||||
l.append(
|
||||
&mut db
|
||||
.list_websocket_connections(wid)
|
||||
.map_err(err)?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
);
|
||||
l.append(
|
||||
&mut db.list_websocket_requests(wid).map_err(err)?.into_iter().map(Into::into).collect(),
|
||||
);
|
||||
l.append(
|
||||
&mut db.list_workspace_metas(wid).map_err(err)?.into_iter().map(Into::into).collect(),
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::to_string(&l).map_err(err)
|
||||
}
|
||||
|
||||
async fn models_websocket_events(
|
||||
ctx: BridgeCtx,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
ctx.state.db().list_websocket_events(&req.connection_id).map_err(err)
|
||||
}
|
||||
|
||||
async fn models_grpc_events(ctx: BridgeCtx, req: ModelsGrpcEventsReq) -> Result<Vec<GrpcEvent>> {
|
||||
ctx.state.db().list_grpc_events(&req.connection_id).map_err(err)
|
||||
}
|
||||
|
||||
async fn models_get_graphql_introspection(
|
||||
ctx: BridgeCtx,
|
||||
req: ModelsGetGraphqlIntrospectionReq,
|
||||
) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(ctx.state.db().get_graphql_introspection(&req.request_id))
|
||||
}
|
||||
|
||||
async fn models_upsert_graphql_introspection(
|
||||
ctx: BridgeCtx,
|
||||
req: ModelsUpsertGraphqlIntrospectionReq,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
ctx.state
|
||||
.db()
|
||||
.upsert_graphql_introspection(
|
||||
&req.workspace_id,
|
||||
&req.request_id,
|
||||
req.content,
|
||||
&ctx.update_source(),
|
||||
)
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_get_workspace_meta(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdGetWorkspaceMetaReq,
|
||||
) -> Result<WorkspaceMeta> {
|
||||
let db = ctx.state.db();
|
||||
let workspace = db.get_workspace(&req.workspace_id).map_err(err)?;
|
||||
db.get_or_create_workspace_meta(&workspace.id).map_err(err)
|
||||
}
|
||||
|
||||
// -- Sending --
|
||||
|
||||
/// Send a saved request.
|
||||
///
|
||||
/// Same sequence as the desktop (crates-tauri/.../lib.rs `cmd_send_http_request`):
|
||||
/// create the response row first so the UI has something to show, wire up
|
||||
/// cancellation, then hand off to the engine. Nothing is streamed back to the
|
||||
/// tab directly — every state change is a database write, and the model-writes
|
||||
/// push carries it, which is exactly how the desktop does it too.
|
||||
async fn cmd_send_http_request(ctx: BridgeCtx, req: CmdSendHttpRequestReq) -> Result<HttpResponse> {
|
||||
let request = ctx.state.db().get_http_request(&req.request_id).map_err(err)?;
|
||||
let source = ctx.update_source();
|
||||
|
||||
let response = ctx
|
||||
.state
|
||||
.db()
|
||||
.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&source,
|
||||
ctx.state.blob_manager(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||
let mut cancels =
|
||||
ctx.state.events.subscribe_inbound(format!("cancel_http_response_{}", response.id));
|
||||
tokio::spawn(async move {
|
||||
if cancels.recv().await.is_some() {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
});
|
||||
|
||||
let result = send_persisted(&ctx, request, response.clone(), &req, cancel_rx).await;
|
||||
|
||||
match result {
|
||||
Ok(response) => Ok(response),
|
||||
Err(e) => {
|
||||
// Mirror the desktop: a failure is a closed response carrying the
|
||||
// error, not a rejected command, so the UI shows it in place.
|
||||
let existing = ctx.state.db().get_http_response(&response.id).map_err(err)?;
|
||||
ctx.state
|
||||
.db()
|
||||
.upsert_http_response(
|
||||
&HttpResponse {
|
||||
state: HttpResponseState::Closed,
|
||||
error: Some(e.message),
|
||||
..existing
|
||||
},
|
||||
&source,
|
||||
ctx.state.blob_manager(),
|
||||
)
|
||||
.map_err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_persisted(
|
||||
ctx: &BridgeCtx,
|
||||
request: HttpRequest,
|
||||
response: HttpResponse,
|
||||
req: &CmdSendHttpRequestReq,
|
||||
cancel_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) -> Result<HttpResponse> {
|
||||
let plugin_manager = ctx.plugins()?;
|
||||
let response_dir = ctx.state.response_dir();
|
||||
|
||||
let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
|
||||
query_manager: ctx.state.query_manager(),
|
||||
blob_manager: ctx.state.blob_manager(),
|
||||
request,
|
||||
environment_id: req.environment_id.as_deref(),
|
||||
update_source: ctx.update_source(),
|
||||
cookie_jar_id: req.cookie_jar_id.clone(),
|
||||
response_dir: &response_dir,
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: None,
|
||||
existing_response: Some(response),
|
||||
plugin_manager,
|
||||
encryption_manager: ctx.state.encryption_manager.clone(),
|
||||
plugin_context: &ctx.plugin_context(),
|
||||
cancelled_rx: Some(cancel_rx),
|
||||
connection_manager: ctx.state.connection_manager(),
|
||||
})
|
||||
.await
|
||||
.map_err(err)?;
|
||||
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
/// Send without saving. An empty request id keeps the engine from persisting
|
||||
/// anything, so the body comes back in memory and rides along with the
|
||||
/// response — there is no row to look up later and no file to serve.
|
||||
async fn cmd_send_ephemeral_request(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdSendEphemeralRequestReq,
|
||||
) -> Result<EphemeralHttpResponse> {
|
||||
let mut request = req.request;
|
||||
request.id = String::new();
|
||||
let plugin_manager = ctx.plugins()?;
|
||||
let response_dir = ctx.state.response_dir();
|
||||
|
||||
let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
|
||||
query_manager: ctx.state.query_manager(),
|
||||
blob_manager: ctx.state.blob_manager(),
|
||||
request,
|
||||
environment_id: req.environment_id.as_deref(),
|
||||
update_source: ctx.update_source(),
|
||||
cookie_jar_id: req.cookie_jar_id,
|
||||
response_dir: &response_dir,
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: None,
|
||||
existing_response: Some(HttpResponse::default()),
|
||||
plugin_manager,
|
||||
encryption_manager: ctx.state.encryption_manager.clone(),
|
||||
plugin_context: &ctx.plugin_context(),
|
||||
cancelled_rx: None,
|
||||
connection_manager: ctx.state.connection_manager(),
|
||||
})
|
||||
.await
|
||||
.map_err(err)?;
|
||||
|
||||
// Blanking the request id above is what makes this send unsaved, so the
|
||||
// engine always hands the body back. Failing loudly beats returning an
|
||||
// empty body that reads as "the server sent nothing".
|
||||
let ResponseBody::Returned(body) = result.response_body else {
|
||||
return Err(RpcError { message: "Unsaved response did not return a body".to_string() });
|
||||
};
|
||||
|
||||
Ok(EphemeralHttpResponse { response: result.response, body })
|
||||
}
|
||||
|
||||
// -- Reading responses --
|
||||
|
||||
/// The frontend hands back an id and never a path, so the only bodies reachable
|
||||
/// here are ones the engine wrote and the database still knows about.
|
||||
async fn cmd_http_response_body(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdHttpResponseBodyReq,
|
||||
) -> Result<FilterResponse> {
|
||||
let location = ctx.state.locate_response_body(&req.response_id).map_err(err)?;
|
||||
let Some(body_path) = location.path else {
|
||||
return Ok(FilterResponse { content: String::new(), error: None });
|
||||
};
|
||||
|
||||
let content_type = location.content_type.as_str();
|
||||
let body = read_response_body(&body_path, content_type)
|
||||
.await
|
||||
.ok_or_else(|| RpcError { message: "Failed to find response body".to_string() })?;
|
||||
|
||||
match req.filter.as_deref() {
|
||||
Some(filter) if !filter.is_empty() => ctx
|
||||
.plugins()?
|
||||
.filter_data(&ctx.plugin_context(), filter, &body, content_type)
|
||||
.await
|
||||
.map_err(err),
|
||||
_ => Ok(FilterResponse { content: body, error: None }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The desktop host uses this to open the file itself. A tab cannot open a
|
||||
/// path, so the bridge's browser host never calls it — it fetches
|
||||
/// `/responses/:id/body` instead — but the command answers honestly for any
|
||||
/// client that does, with the path on the bridge's machine.
|
||||
async fn cmd_http_response_body_path(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdHttpResponseBodyPathReq,
|
||||
) -> Result<Option<String>> {
|
||||
let location = ctx.state.locate_response_body(&req.response_id).map_err(err)?;
|
||||
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
/// Decode a response body from disk using the charset its Content-Type
|
||||
/// declares. Ported from crates-tauri/yaak-app-client/src/encoding.rs.
|
||||
async fn read_response_body(body_path: impl AsRef<Path>, content_type: &str) -> Option<String> {
|
||||
let body = tokio::fs::read(body_path).await.ok()?;
|
||||
let body_charset = parse_charset(content_type).unwrap_or_else(|| "utf-8".to_string());
|
||||
if let Some(decoder) = charset::Charset::for_label(body_charset.as_bytes()) {
|
||||
let (cow, _real_encoding, _exist_replace) = decoder.decode(&body);
|
||||
return Some(cow.into_owned());
|
||||
}
|
||||
Some(String::from_utf8_lossy(&body).to_string())
|
||||
}
|
||||
|
||||
fn parse_charset(content_type: &str) -> Option<String> {
|
||||
let mime: Mime = Mime::from_str(content_type).ok()?;
|
||||
mime.get_param(mime::CHARSET).map(|v| v.to_string())
|
||||
}
|
||||
|
||||
async fn cmd_http_request_body(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdHttpRequestBodyReq,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let body_id = format!("{}.request", req.response_id);
|
||||
let chunks = ctx.state.blob_manager().connect().get_chunks(&body_id).map_err(err)?;
|
||||
if chunks.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(chunks.into_iter().flat_map(|c| c.data).collect()))
|
||||
}
|
||||
|
||||
async fn cmd_get_http_response_events(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdGetHttpResponseEventsReq,
|
||||
) -> Result<Vec<HttpResponseEvent>> {
|
||||
ctx.state.db().list_http_response_events(&req.response_id).map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_get_sse_events(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdGetSseEventsReq,
|
||||
) -> Result<Vec<ServerSentEvent>> {
|
||||
use eventsource_client::{EventParser, SSE};
|
||||
|
||||
let Some(body_path) = ctx.state.locate_response_body(&req.response_id).map_err(err)?.path
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let body = std::fs::read(&body_path).map_err(err)?;
|
||||
let mut event_parser = EventParser::new();
|
||||
event_parser.process_bytes(body).map_err(err)?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
while let Some(e) = event_parser.get_event() {
|
||||
if let SSE::Event(e) = e {
|
||||
events.push(ServerSentEvent {
|
||||
event_type: e.event_type,
|
||||
data: e.data,
|
||||
id: e.id,
|
||||
retry: e.retry,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_http_responses(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdDeleteAllHttpResponsesReq,
|
||||
) -> Result<()> {
|
||||
ctx.state
|
||||
.db()
|
||||
.delete_all_http_responses_for_request(&req.request_id, &ctx.update_source())
|
||||
.map_err(err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_delete_send_history(ctx: BridgeCtx, req: CmdDeleteSendHistoryReq) -> Result<()> {
|
||||
let source = ctx.update_source();
|
||||
blocking(move || {
|
||||
let blobs = ctx.state.blob_manager();
|
||||
let db = ctx.state.db();
|
||||
for r in db.list_http_responses(&req.workspace_id, None)? {
|
||||
db.delete_http_response(&r, &source, blobs)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Formatting and templates --
|
||||
|
||||
async fn cmd_format_json(_ctx: BridgeCtx, req: CmdFormatJsonReq) -> Result<String> {
|
||||
Ok(format_json(&req.text, " "))
|
||||
}
|
||||
|
||||
async fn cmd_format_graphql(_ctx: BridgeCtx, req: CmdFormatGraphqlReq) -> Result<String> {
|
||||
match pretty_graphql::format_text(&req.text, &Default::default()) {
|
||||
Ok(formatted) => Ok(formatted),
|
||||
Err(_) => Ok(req.text),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_render_template(ctx: BridgeCtx, req: CmdRenderTemplateReq) -> Result<String> {
|
||||
let environment_chain = ctx
|
||||
.state
|
||||
.db()
|
||||
.resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())
|
||||
.map_err(err)?;
|
||||
|
||||
let callback = yaak_plugins::template_callback::PluginTemplateCallback::new(
|
||||
ctx.plugins()?,
|
||||
ctx.state.encryption_manager.clone(),
|
||||
&ctx.plugin_context(),
|
||||
req.purpose.unwrap_or(RenderPurpose::Preview),
|
||||
);
|
||||
|
||||
let options = RenderOptions {
|
||||
error_behavior: match req.ignore_error {
|
||||
Some(true) => RenderErrorBehavior::ReturnEmpty,
|
||||
_ => RenderErrorBehavior::Throw,
|
||||
},
|
||||
};
|
||||
let vars = make_vars_hashmap(environment_chain);
|
||||
parse_and_render(&req.template, &vars, &callback, &options).await.map_err(err)
|
||||
}
|
||||
|
||||
async fn render_json_value<T: TemplateCallback>(
|
||||
value: serde_json::Value,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<serde_json::Value> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
|
||||
async fn cmd_template_tokens_to_string(
|
||||
_ctx: BridgeCtx,
|
||||
req: CmdTemplateTokensToStringReq,
|
||||
) -> Result<String> {
|
||||
Ok(req.tokens.to_string())
|
||||
}
|
||||
|
||||
async fn cmd_decrypt_template(ctx: BridgeCtx, req: CmdDecryptTemplateReq) -> Result<String> {
|
||||
decrypt_secure_template_function(
|
||||
&ctx.state.encryption_manager,
|
||||
&ctx.plugin_context(),
|
||||
&req.template,
|
||||
)
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_secure_template(ctx: BridgeCtx, req: CmdSecureTemplateReq) -> Result<String> {
|
||||
encrypt_secure_template_function(
|
||||
ctx.plugins()?,
|
||||
ctx.state.encryption_manager.clone(),
|
||||
&ctx.plugin_context(),
|
||||
&req.template,
|
||||
)
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_default_headers(_ctx: BridgeCtx, _req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
|
||||
Ok(default_headers())
|
||||
}
|
||||
|
||||
// -- Plugins --
|
||||
|
||||
async fn cmd_get_themes(ctx: BridgeCtx, _req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
|
||||
// Themes are optional: the TypeScript package ships defaults, and an empty
|
||||
// list still renders. Don't fail boot when the runtime is down.
|
||||
let Ok(plugins) = ctx.plugins() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
plugins.get_themes(&ctx.plugin_context()).await.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_init_errors(ctx: BridgeCtx, _req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
|
||||
let Ok(plugins) = ctx.plugins() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
Ok(plugins.take_init_errors().await)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_info(ctx: BridgeCtx, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
|
||||
let plugin = ctx.state.db().get_plugin(&req.id).map_err(err)?;
|
||||
let plugins = ctx.plugins()?;
|
||||
let handle = plugins
|
||||
.get_plugin_by_dir(&plugin.directory)
|
||||
.await
|
||||
.ok_or_else(|| RpcError { message: format!("Plugin not found: {}", req.id) })?;
|
||||
Ok(handle.info())
|
||||
}
|
||||
|
||||
async fn cmd_template_function_summaries(
|
||||
ctx: BridgeCtx,
|
||||
_req: CmdTemplateFunctionSummariesReq,
|
||||
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
ctx.plugins()?.get_template_function_summaries(&ctx.plugin_context()).await.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_config(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdTemplateFunctionConfigReq,
|
||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
ctx.plugins()?
|
||||
.get_template_function_config(
|
||||
&ctx.plugin_context(),
|
||||
&req.function_name,
|
||||
req.values,
|
||||
req.model.id(),
|
||||
)
|
||||
.await
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_summaries(
|
||||
ctx: BridgeCtx,
|
||||
_req: CmdGetHttpAuthenticationSummariesReq,
|
||||
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results =
|
||||
ctx.plugins()?.get_http_authentication_summaries(&ctx.plugin_context()).await.map_err(err)?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_config(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdGetHttpAuthenticationConfigReq,
|
||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
let rendered_values =
|
||||
render_auth_values(&ctx, &req.model, req.environment_id.as_deref(), &req.values).await?;
|
||||
ctx.plugins()?
|
||||
.get_http_authentication_config(
|
||||
&ctx.plugin_context(),
|
||||
&req.auth_name,
|
||||
rendered_values,
|
||||
req.model.id(),
|
||||
)
|
||||
.await
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_authentication_action(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdCallHttpAuthenticationActionReq,
|
||||
) -> Result<()> {
|
||||
let rendered_values =
|
||||
render_auth_values(&ctx, &req.model, req.environment_id.as_deref(), &req.values).await?;
|
||||
ctx.plugins()?
|
||||
.call_http_authentication_action(
|
||||
&ctx.plugin_context(),
|
||||
&req.auth_name,
|
||||
req.action_index,
|
||||
rendered_values,
|
||||
req.model.id(),
|
||||
)
|
||||
.await
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
/// Auth config values are templates, so they are rendered against the model's
|
||||
/// environment chain before the plugin sees them.
|
||||
async fn render_auth_values(
|
||||
ctx: &BridgeCtx,
|
||||
model: &AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
values: &HashMap<String, JsonPrimitive>,
|
||||
) -> Result<HashMap<String, JsonPrimitive>> {
|
||||
let (workspace_id, folder_id) = match model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
_ => {
|
||||
return Err(RpcError {
|
||||
message: "Unsupported model type for authentication config".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let environment_chain = ctx
|
||||
.state
|
||||
.db()
|
||||
.resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)
|
||||
.map_err(err)?;
|
||||
|
||||
let callback = yaak_plugins::template_callback::PluginTemplateCallback::new(
|
||||
ctx.plugins()?,
|
||||
ctx.state.encryption_manager.clone(),
|
||||
&ctx.plugin_context(),
|
||||
RenderPurpose::Preview,
|
||||
);
|
||||
|
||||
let values_json = serde_json::to_value(values).map_err(err)?;
|
||||
let rendered_json =
|
||||
render_json_value(values_json, environment_chain, &callback, &RenderOptions::return_empty())
|
||||
.await
|
||||
.map_err(err)?;
|
||||
serde_json::from_value(rendered_json).map_err(err)
|
||||
}
|
||||
|
||||
// -- Plugin actions --
|
||||
|
||||
async fn cmd_http_request_actions(
|
||||
ctx: BridgeCtx,
|
||||
_req: CmdHttpRequestActionsReq,
|
||||
) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
ctx.plugins()?.get_http_request_actions(&ctx.plugin_context()).await.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_request_action(
|
||||
ctx: BridgeCtx,
|
||||
req: CmdCallHttpRequestActionReq,
|
||||
) -> Result<()> {
|
||||
use yaak_plugins::events::CallHttpRequestActionArgs;
|
||||
|
||||
// Resolve inherited auth and headers before handing the request to the
|
||||
// plugin, so an action sees what a send would see. Scoped so the database
|
||||
// connection is released before the plugin call awaits.
|
||||
let http_request = {
|
||||
let db = ctx.state.db();
|
||||
let mut http_request = req.req.args.http_request.clone();
|
||||
let (authentication_type, authentication, _) =
|
||||
db.resolve_auth_for_http_request(&http_request).map_err(err)?;
|
||||
http_request.authentication_type = authentication_type;
|
||||
http_request.authentication = authentication;
|
||||
http_request.headers = db.resolve_headers_for_http_request(&http_request).map_err(err)?;
|
||||
http_request
|
||||
};
|
||||
|
||||
ctx.plugins()?
|
||||
.call_http_request_action(
|
||||
&ctx.plugin_context(),
|
||||
CallHttpRequestActionRequest {
|
||||
args: CallHttpRequestActionArgs { http_request },
|
||||
..req.req
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_workspace_actions(
|
||||
ctx: BridgeCtx,
|
||||
_req: CmdWorkspaceActionsReq,
|
||||
) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
ctx.plugins()?.get_workspace_actions(&ctx.plugin_context()).await.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_call_workspace_action(ctx: BridgeCtx, req: CmdCallWorkspaceActionReq) -> Result<()> {
|
||||
use yaak_plugins::events::CallWorkspaceActionArgs;
|
||||
|
||||
let workspace = ctx.state.db().get_workspace(&req.req.args.workspace.id).map_err(err)?;
|
||||
ctx.plugins()?
|
||||
.call_workspace_action(
|
||||
&ctx.plugin_context(),
|
||||
CallWorkspaceActionRequest { args: CallWorkspaceActionArgs { workspace }, ..req.req },
|
||||
)
|
||||
.await
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_folder_actions(ctx: BridgeCtx, _req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
ctx.plugins()?.get_folder_actions(&ctx.plugin_context()).await.map_err(err)
|
||||
}
|
||||
|
||||
async fn cmd_call_folder_action(ctx: BridgeCtx, req: CmdCallFolderActionReq) -> Result<()> {
|
||||
use yaak_plugins::events::CallFolderActionArgs;
|
||||
|
||||
let folder = ctx.state.db().get_folder(&req.req.args.folder.id).map_err(err)?;
|
||||
ctx.plugins()?
|
||||
.call_folder_action(
|
||||
&ctx.plugin_context(),
|
||||
CallFolderActionRequest { args: CallFolderActionArgs { folder }, ..req.req },
|
||||
)
|
||||
.await
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
// -- Import --
|
||||
|
||||
async fn cmd_curl_to_request(ctx: BridgeCtx, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
|
||||
let import_result =
|
||||
ctx.plugins()?.import_data(&ctx.plugin_context(), &req.command).await.map_err(err)?;
|
||||
|
||||
let r = import_result
|
||||
.resources
|
||||
.http_requests
|
||||
.first()
|
||||
.ok_or_else(|| RpcError { message: "No curl command found".to_string() })?;
|
||||
|
||||
let mut request = r.clone();
|
||||
request.workspace_id = req.workspace_id;
|
||||
request.id = String::new();
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Import from a path on the *bridge's* machine.
|
||||
///
|
||||
/// The desktop gets this path from a native file dialog. A tab has no way to
|
||||
/// produce one, so in practice this only works for a path typed by hand — which
|
||||
/// is why `localFiles` is reported false. Kept registered because the command
|
||||
/// itself works, and a future upload route can reuse it.
|
||||
async fn cmd_import_data(ctx: BridgeCtx, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
|
||||
let contents = std::fs::read_to_string(&req.file_path).map_err(|e| RpcError {
|
||||
message: format!("Unable to read import file {}: {e}", req.file_path),
|
||||
})?;
|
||||
let plugins = ctx.plugins()?;
|
||||
|
||||
import_data_shared(ImportDataParams {
|
||||
query_manager: ctx.state.query_manager(),
|
||||
plugin_manager: &plugins,
|
||||
plugin_context: &ctx.plugin_context(),
|
||||
workspace_context: WorkspaceContext {
|
||||
workspace_id: ctx.session.workspace_id(),
|
||||
environment_id: ctx.session.environment_id(),
|
||||
cookie_jar_id: ctx.session.cookie_jar_id(),
|
||||
request_id: None,
|
||||
},
|
||||
contents: &contents,
|
||||
})
|
||||
.await
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
// -- Not on this host --
|
||||
|
||||
/// Commands the bridge does not implement. Each still gets an adapter, so the
|
||||
/// schema stays fully covered and the frontend receives a structured error
|
||||
/// naming the command and this host rather than a bare "unknown command".
|
||||
///
|
||||
/// One list, two uses: `unsupported_commands!` emits both the adapters and the
|
||||
/// `UNSUPPORTED_COMMANDS` array `implemented_commands` subtracts.
|
||||
macro_rules! unsupported_commands {
|
||||
( $( $name:ident ( $req:ty ) ),* $(,)? ) => {
|
||||
// The stub never produces a value, so it doesn't need to name the
|
||||
// response type — which keeps git, gRPC and WebSocket crates out of a
|
||||
// binary that will never call them. `Never` serializes fine.
|
||||
$( async fn $name(_ctx: BridgeCtx, _req: $req) -> Result<Never> {
|
||||
Err(unsupported_command(stringify!($name)))
|
||||
} )*
|
||||
pub const UNSUPPORTED_COMMANDS: &[&str] = &[ $( stringify!($name), )* ];
|
||||
};
|
||||
}
|
||||
|
||||
/// A value that cannot exist. The unsupported adapters return `Result<Never>`
|
||||
/// and always take the `Err` branch, so `rpc_handler_async!` has something
|
||||
/// serializable to name without a real response type ever being constructed.
|
||||
#[derive(serde::Serialize)]
|
||||
enum Never {}
|
||||
|
||||
unsupported_commands! {
|
||||
// Multi-window. A tab is one window; Settings opens through this on the desktop and is therefore unreachable in the browser today.
|
||||
cmd_new_child_window(CmdNewChildWindowReq),
|
||||
cmd_new_main_window(CmdNewMainWindowReq),
|
||||
// gRPC and WebSocket sending.
|
||||
cmd_grpc_reflect(CmdGrpcReflectReq),
|
||||
cmd_grpc_go(CmdGrpcGoReq),
|
||||
cmd_grpc_request_actions(CmdGrpcRequestActionsReq),
|
||||
cmd_call_grpc_request_action(CmdCallGrpcRequestActionReq),
|
||||
cmd_delete_all_grpc_connections(CmdDeleteAllGrpcConnectionsReq),
|
||||
cmd_ws_connect(CmdWsConnectReq),
|
||||
cmd_ws_send(CmdWsSendReq),
|
||||
cmd_ws_close(CmdWsCloseReq),
|
||||
cmd_ws_delete_connections(CmdWsDeleteConnectionsReq),
|
||||
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq),
|
||||
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq),
|
||||
// Git-backed workspaces.
|
||||
cmd_git_checkout(CmdGitCheckoutReq),
|
||||
cmd_git_branch(CmdGitBranchReq),
|
||||
cmd_git_delete_branch(CmdGitDeleteBranchReq),
|
||||
cmd_git_delete_remote_branch(CmdGitDeleteRemoteBranchReq),
|
||||
cmd_git_merge_branch(CmdGitMergeBranchReq),
|
||||
cmd_git_rename_branch(CmdGitRenameBranchReq),
|
||||
cmd_git_status(CmdGitStatusReq),
|
||||
cmd_git_branch_info(CmdGitBranchInfoReq),
|
||||
cmd_git_worktree_status(CmdGitWorktreeStatusReq),
|
||||
cmd_git_log(CmdGitLogReq),
|
||||
cmd_git_log_for_file(CmdGitLogForFileReq),
|
||||
cmd_git_file_diff_for_commit(CmdGitFileDiffForCommitReq),
|
||||
cmd_git_initialize(CmdGitInitializeReq),
|
||||
cmd_git_clone(CmdGitCloneReq),
|
||||
cmd_git_commit(CmdGitCommitReq),
|
||||
cmd_git_fetch_all(CmdGitFetchAllReq),
|
||||
cmd_git_push(CmdGitPushReq),
|
||||
cmd_git_pull(CmdGitPullReq),
|
||||
cmd_git_pull_force_reset(CmdGitPullForceResetReq),
|
||||
cmd_git_pull_merge(CmdGitPullMergeReq),
|
||||
cmd_git_add(CmdGitAddReq),
|
||||
cmd_git_unstage(CmdGitUnstageReq),
|
||||
cmd_git_reset_changes(CmdGitResetChangesReq),
|
||||
cmd_git_restore_files(CmdGitRestoreFilesReq),
|
||||
cmd_git_restore_file_from_commit(CmdGitRestoreFileFromCommitReq),
|
||||
cmd_git_add_credential(CmdGitAddCredentialReq),
|
||||
cmd_git_remotes(CmdGitRemotesReq),
|
||||
cmd_git_add_remote(CmdGitAddRemoteReq),
|
||||
cmd_git_rm_remote(CmdGitRmRemoteReq),
|
||||
cmd_git_watch_worktree_status(CmdGitWatchWorktreeStatusReq),
|
||||
// Filesystem sync.
|
||||
cmd_sync_calculate(CmdSyncCalculateReq),
|
||||
cmd_sync_calculate_fs(CmdSyncCalculateFsReq),
|
||||
cmd_sync_apply(CmdSyncApplyReq),
|
||||
cmd_sync_watch(CmdSyncWatchReq),
|
||||
// Workspace encryption.
|
||||
cmd_enable_encryption(CmdEnableEncryptionReq),
|
||||
cmd_disable_encryption(CmdDisableEncryptionReq),
|
||||
cmd_reveal_workspace_key(CmdRevealWorkspaceKeyReq),
|
||||
cmd_set_workspace_key(CmdSetWorkspaceKeyReq),
|
||||
// Things that need a local filesystem the tab can point at.
|
||||
cmd_export_data(CmdExportDataReq),
|
||||
cmd_save_response(CmdSaveResponseReq),
|
||||
cmd_save_base64_to_binary(CmdSaveBase64ToBinaryReq),
|
||||
cmd_plugins_install_from_directory(CmdPluginsInstallFromDirectoryReq),
|
||||
cmd_import_url(CmdImportUrlReq),
|
||||
// Desktop application management.
|
||||
cmd_restart(CmdRestartReq),
|
||||
cmd_check_for_updates(CmdCheckForUpdatesReq),
|
||||
cmd_dismiss_notification(CmdDismissNotificationReq),
|
||||
cmd_send_feedback(CmdSendFeedbackReq),
|
||||
cmd_plugins_search(CmdPluginsSearchReq),
|
||||
cmd_plugins_install(CmdPluginsInstallReq),
|
||||
cmd_plugins_uninstall(CmdPluginsUninstallReq),
|
||||
cmd_plugins_updates(CmdPluginsUpdatesReq),
|
||||
cmd_plugins_update_all(CmdPluginsUpdateAllReq),
|
||||
cmd_reload_plugins(CmdReloadPluginsReq),
|
||||
}
|
||||
|
||||
// -- The router --
|
||||
|
||||
/// Every command in the schema, wired to an adapter here.
|
||||
///
|
||||
/// The list comes from `yaak_rpc_schema`, so this host cannot silently miss a
|
||||
/// command the frontend knows about: a schema entry with no adapter below is a
|
||||
/// compile error, not a runtime "unknown command". Commands the bridge does not
|
||||
/// support still get an adapter — one that says so — which is what lets the
|
||||
/// frontend tell a host that will never do git from one that is out of date.
|
||||
macro_rules! register_commands {
|
||||
( $( $name:ident ( $req:ty ) -> $res:ty ),* $(,)? ) => {
|
||||
pub fn build_router() -> RpcRouter<BridgeCtx> {
|
||||
let mut router = RpcRouter::new();
|
||||
$( router.register(stringify!($name), rpc_handler_async!($name)); )*
|
||||
router
|
||||
}
|
||||
};
|
||||
}
|
||||
yaak_rpc_schema::with_commands!(register_commands);
|
||||
|
||||
/// The names of the commands this host actually implements — everything in
|
||||
/// the schema minus the ones whose adapter is `unsupported`. Reported to the
|
||||
/// browser so it can fail fast with a clear message.
|
||||
pub fn implemented_commands(router: &RpcRouter<BridgeCtx>) -> Vec<String> {
|
||||
let unsupported: std::collections::HashSet<&str> =
|
||||
UNSUPPORTED_COMMANDS.iter().copied().collect();
|
||||
let mut names: Vec<String> = router
|
||||
.commands()
|
||||
.into_iter()
|
||||
.filter(|c| !unsupported.contains(c))
|
||||
.map(|c| c.to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! The bridge's RPC surface.
|
||||
//!
|
||||
//! Same envelope, same command names, same request and response types as the
|
||||
//! desktop — all of that comes from `yaak_rpc_schema` — dispatched through the
|
||||
//! same `RpcRouter`. Only the adapters differ: the desktop's take a Tauri
|
||||
//! window and read the workspace off its URL, while these take a `BridgeCtx`
|
||||
//! carrying the connected tab's reported URL. The bodies underneath call the
|
||||
//! same engine functions in `yaak`, `yaak-models` and `yaak-plugins`.
|
||||
//!
|
||||
//! The router is built from the schema's full command list, so every command
|
||||
//! the frontend knows has an adapter here — the ones this host doesn't
|
||||
//! implement return a structured error naming the command and the host, and
|
||||
//! the frontend surfaces "not supported by the Yaak Bridge" instead of a bare
|
||||
//! failure. Enough is implemented to boot, edit, send and inspect.
|
||||
|
||||
mod commands;
|
||||
|
||||
pub use commands::implemented_commands;
|
||||
|
||||
use crate::session::SessionContext;
|
||||
use crate::state::BridgeState;
|
||||
use std::sync::Arc;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_rpc::{RpcError, RpcRouter};
|
||||
|
||||
/// Per-call context. The tab's identity and location, plus the engine.
|
||||
///
|
||||
/// Mirrors the desktop's `ClientCtx { window }`: the window there answers both
|
||||
/// "who is calling" and "what are they looking at", and those are exactly the
|
||||
/// two things a bridge call needs that the payload doesn't carry.
|
||||
#[derive(Clone)]
|
||||
pub struct BridgeCtx {
|
||||
pub state: Arc<BridgeState>,
|
||||
pub session: SessionContext,
|
||||
}
|
||||
|
||||
impl BridgeCtx {
|
||||
pub fn plugin_context(&self) -> PluginContext {
|
||||
PluginContext::new(Some(self.session.label.clone()), self.session.workspace_id())
|
||||
}
|
||||
|
||||
pub fn update_source(&self) -> yaak_models::util::UpdateSource {
|
||||
yaak_models::util::UpdateSource::from_window_label(&self.session.label)
|
||||
}
|
||||
|
||||
/// The plugin runtime, or an error naming the reason it isn't there.
|
||||
pub fn plugins(&self) -> Result<Arc<yaak_plugins::manager::PluginManager>, RpcError> {
|
||||
self.state.plugin_manager().ok_or_else(|| RpcError {
|
||||
message: "The plugin runtime failed to start, so this command is unavailable"
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_router() -> RpcRouter<BridgeCtx> {
|
||||
commands::build_router()
|
||||
}
|
||||
|
||||
pub fn unsupported_command(cmd: &str) -> RpcError {
|
||||
RpcError {
|
||||
message: format!("`{cmd}` is not supported on this host (Yaak Bridge)"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! What the connected tab is currently looking at.
|
||||
//!
|
||||
//! The desktop reads workspace, environment, cookie jar and request straight off
|
||||
//! the window's URL (crates-tauri/yaak-tauri-utils/src/window.rs). A browser tab
|
||||
//! runs the same router and so has the same URL, but the server cannot see it —
|
||||
//! so the tab reports it, on connect and whenever it changes, and the same
|
||||
//! parsing happens here.
|
||||
//!
|
||||
//! One session for the whole process: this slice serves a single tab. A second
|
||||
//! tab overwrites the first's context rather than getting its own.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SessionContext {
|
||||
/// Identifies the tab, and lands in `UpdateSource::Window { label }` so
|
||||
/// model-write echo suppression works exactly as it does on the desktop.
|
||||
pub label: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl SessionContext {
|
||||
pub fn workspace_id(&self) -> Option<String> {
|
||||
let rest = self.url.split("/workspaces/").nth(1)?;
|
||||
let id: String =
|
||||
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
|
||||
if id.is_empty() { None } else { Some(id) }
|
||||
}
|
||||
|
||||
pub fn request_id(&self) -> Option<String> {
|
||||
let rest = self.url.split("/requests/").nth(1)?;
|
||||
let id: String =
|
||||
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
|
||||
if id.is_empty() { None } else { Some(id) }
|
||||
}
|
||||
|
||||
pub fn environment_id(&self) -> Option<String> {
|
||||
self.query_param("environment_id")
|
||||
}
|
||||
|
||||
pub fn cookie_jar_id(&self) -> Option<String> {
|
||||
self.query_param("cookie_jar_id")
|
||||
}
|
||||
|
||||
fn query_param(&self, key: &str) -> Option<String> {
|
||||
let query = self.url.split('?').nth(1)?;
|
||||
let value = query.split('&').find_map(|pair| {
|
||||
let (k, v) = pair.split_once('=')?;
|
||||
if k != key {
|
||||
return None;
|
||||
}
|
||||
Some(percent_decode(v))
|
||||
})?;
|
||||
|
||||
// The router writes `environment_id=null` when nothing is selected.
|
||||
// Neither of these is an id, and treating them as one sends a lookup
|
||||
// for a model that cannot exist.
|
||||
if value.is_empty() || value == "null" || value == "undefined" {
|
||||
return None;
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.replace('+', " ").into_bytes();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
|
||||
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SessionStore {
|
||||
inner: Arc<RwLock<SessionContext>>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn get(&self) -> SessionContext {
|
||||
match self.inner.read() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&self, context: SessionContext) {
|
||||
let mut guard = match self.inner.write() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
*guard = context;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx(url: &str) -> SessionContext {
|
||||
SessionContext { label: "tab".into(), url: url.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ids_from_a_router_url() {
|
||||
let c = ctx(
|
||||
"http://localhost:1472/workspaces/wk_abc123/requests/rq_def456?environment_id=ev_1&cookie_jar_id=cj_2",
|
||||
);
|
||||
assert_eq!(c.workspace_id().as_deref(), Some("wk_abc123"));
|
||||
assert_eq!(c.request_id().as_deref(), Some("rq_def456"));
|
||||
assert_eq!(c.environment_id().as_deref(), Some("ev_1"));
|
||||
assert_eq!(c.cookie_jar_id().as_deref(), Some("cj_2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_query_values_are_not_ids() {
|
||||
let c = ctx("http://localhost:1472/workspaces/wk_a?environment_id=null&cookie_jar_id=");
|
||||
assert_eq!(c.environment_id(), None);
|
||||
assert_eq!(c.cookie_jar_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_parts_are_none() {
|
||||
let c = ctx("http://localhost:1472/");
|
||||
assert_eq!(c.workspace_id(), None);
|
||||
assert_eq!(c.request_id(), None);
|
||||
assert_eq!(c.environment_id(), None);
|
||||
assert_eq!(c.cookie_jar_id(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! The bridge's engine handles, shared by every route.
|
||||
//!
|
||||
//! Structurally this is `CliContext` (crates-cli/yaak-cli/src/context.rs) with
|
||||
//! an event hub bolted on: the same `init_standalone` database, the same
|
||||
//! `PluginManager` over the same Node sidecar. What differs is that a browser
|
||||
//! tab is attached, so writes have to be pushed out as they happen instead of
|
||||
//! the process exiting when a command finishes.
|
||||
|
||||
use crate::events::EventHub;
|
||||
use crate::plugin_events::BridgePluginEventBridge;
|
||||
use crate::session::SessionStore;
|
||||
use include_dir::{Dir, include_dir};
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::manager::HttpConnectionManager;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
|
||||
));
|
||||
static EMBEDDED_VENDORED_PLUGINS: Dir<'_> =
|
||||
include_dir!("$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app-client/vendored/plugins");
|
||||
|
||||
/// What this host can do, mirroring `PlatformCapabilities` in
|
||||
/// packages/platform/src/types.ts.
|
||||
///
|
||||
/// Reported to the browser rather than hardcoded there, because the honest
|
||||
/// answer depends on how the bridge was built — these become cargo features as
|
||||
/// the surface grows, and the tab should not have to guess.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BridgeCapabilities {
|
||||
pub grpc: bool,
|
||||
pub websocket: bool,
|
||||
pub git: bool,
|
||||
pub sync: bool,
|
||||
pub tls_options: bool,
|
||||
pub cookie_jar: bool,
|
||||
pub local_files: bool,
|
||||
pub timeline: bool,
|
||||
pub multi_window: bool,
|
||||
pub plugins: bool,
|
||||
pub encryption: bool,
|
||||
pub updater: bool,
|
||||
pub clipboard_read: bool,
|
||||
pub system_fonts: bool,
|
||||
pub license: bool,
|
||||
}
|
||||
|
||||
impl BridgeCapabilities {
|
||||
/// The first slice: real HTTP sending with full fidelity, real plugins, a
|
||||
/// real cookie jar and timeline. Everything the bridge has no route for is
|
||||
/// reported false so the UI hides it rather than calling and failing.
|
||||
fn for_this_build(plugins: bool) -> Self {
|
||||
Self {
|
||||
grpc: false,
|
||||
websocket: false,
|
||||
git: false,
|
||||
sync: false,
|
||||
// The engine does the TLS, so client certs and custom CAs are real.
|
||||
tls_options: true,
|
||||
cookie_jar: true,
|
||||
// The bridge has a filesystem but the tab has no way to pick a path
|
||||
// on it: there is no dialog implementation on this host.
|
||||
local_files: false,
|
||||
timeline: true,
|
||||
multi_window: false,
|
||||
plugins,
|
||||
encryption: false,
|
||||
updater: false,
|
||||
clipboard_read: false,
|
||||
system_fonts: false,
|
||||
license: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a response's body is, and what it is meant to be read as.
|
||||
pub struct ResponseBodyLocation {
|
||||
/// None when the response has no stored body.
|
||||
pub path: Option<PathBuf>,
|
||||
/// The response's declared `Content-Type`, empty when it has none.
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
pub struct BridgeState {
|
||||
data_dir: PathBuf,
|
||||
query_manager: QueryManager,
|
||||
blob_manager: BlobManager,
|
||||
pub encryption_manager: Arc<EncryptionManager>,
|
||||
connection_manager: Arc<HttpConnectionManager>,
|
||||
plugin_manager: Option<Arc<PluginManager>>,
|
||||
plugin_event_bridge: Mutex<Option<BridgePluginEventBridge>>,
|
||||
pub events: EventHub,
|
||||
pub session: SessionStore,
|
||||
pub capabilities: BridgeCapabilities,
|
||||
/// Dev-grade shared secret, minted per process. The seam where OTP pairing
|
||||
/// and per-session keys will go; deliberately not persisted.
|
||||
pub token: String,
|
||||
pub is_dev: bool,
|
||||
}
|
||||
|
||||
impl BridgeState {
|
||||
pub fn new(data_dir: PathBuf, app_id: &str, token: String, is_dev: bool) -> Self {
|
||||
let db_path = data_dir.join("db.sqlite");
|
||||
let blob_path = data_dir.join("blobs.sqlite");
|
||||
let (query_manager, blob_manager, rx) =
|
||||
match yaak_models::init_standalone(&db_path, &blob_path) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
eprintln!("Error: Failed to initialize database: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
||||
let events = EventHub::new();
|
||||
|
||||
// A Settings row has to exist before the frontend's first render — the
|
||||
// singular model atom throws without one. `get_settings` upserts a
|
||||
// default when it finds nothing, so touching it here is enough.
|
||||
let _ = query_manager.connect().get_settings();
|
||||
|
||||
crate::model_writes::start(&query_manager, rx, events.clone());
|
||||
|
||||
Self {
|
||||
data_dir,
|
||||
query_manager,
|
||||
blob_manager,
|
||||
encryption_manager,
|
||||
connection_manager: Arc::new(HttpConnectionManager::new()),
|
||||
plugin_manager: None,
|
||||
plugin_event_bridge: Mutex::new(None),
|
||||
events,
|
||||
session: SessionStore::default(),
|
||||
capabilities: BridgeCapabilities::for_this_build(false),
|
||||
token,
|
||||
is_dev,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the Node plugin runtime and the host-request bridge. Mirrors
|
||||
/// `CliContext::init_plugins`; a failure here is survivable, but sending
|
||||
/// loses auth and template functions, so the capability flips off.
|
||||
pub async fn init_plugins(&mut self) {
|
||||
let vendored_plugin_dir = self.data_dir.join("vendored-plugins");
|
||||
let installed_plugin_dir = self.data_dir.join("installed-plugins");
|
||||
let node_bin_path = PathBuf::from("node");
|
||||
|
||||
prepare_embedded_vendored_plugins(&vendored_plugin_dir)
|
||||
.expect("Failed to prepare bundled plugins");
|
||||
|
||||
let plugin_runtime_main =
|
||||
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
|
||||
prepare_embedded_plugin_runtime(&self.data_dir)
|
||||
.expect("Failed to prepare embedded plugin runtime")
|
||||
});
|
||||
|
||||
match PluginManager::new(
|
||||
vendored_plugin_dir,
|
||||
installed_plugin_dir,
|
||||
node_bin_path,
|
||||
plugin_runtime_main,
|
||||
&self.query_manager,
|
||||
&PluginContext::new_empty(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(plugin_manager) => {
|
||||
let plugin_manager = Arc::new(plugin_manager);
|
||||
let plugin_event_bridge = BridgePluginEventBridge::start(
|
||||
plugin_manager.clone(),
|
||||
self.query_manager.clone(),
|
||||
self.blob_manager.clone(),
|
||||
self.encryption_manager.clone(),
|
||||
self.connection_manager.clone(),
|
||||
self.data_dir.clone(),
|
||||
self.events.clone(),
|
||||
self.session.clone(),
|
||||
)
|
||||
.await;
|
||||
self.plugin_manager = Some(plugin_manager);
|
||||
*self.plugin_event_bridge.lock().await = Some(plugin_event_bridge);
|
||||
self.capabilities.plugins = true;
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to initialize plugins: {err}");
|
||||
self.capabilities.plugins = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
pub fn response_dir(&self) -> PathBuf {
|
||||
self.data_dir.join("responses")
|
||||
}
|
||||
|
||||
/// Find a response's body from its id alone.
|
||||
///
|
||||
/// The tab hands back an id and never a path, so the only bodies reachable
|
||||
/// through the bridge are ones the engine wrote and the database still
|
||||
/// knows about. Every route and command that reads a body goes through
|
||||
/// here for that reason.
|
||||
pub fn locate_response_body(
|
||||
&self,
|
||||
response_id: &str,
|
||||
) -> yaak_models::error::Result<ResponseBodyLocation> {
|
||||
let response = self.db().get_http_response(response_id)?;
|
||||
Ok(ResponseBodyLocation {
|
||||
path: response.body_path.map(PathBuf::from),
|
||||
content_type: response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
|
||||
.map(|h| h.value.clone())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn db(&self) -> ClientDb<'_> {
|
||||
self.query_manager.connect()
|
||||
}
|
||||
|
||||
pub fn query_manager(&self) -> &QueryManager {
|
||||
&self.query_manager
|
||||
}
|
||||
|
||||
pub fn blob_manager(&self) -> &BlobManager {
|
||||
&self.blob_manager
|
||||
}
|
||||
|
||||
pub fn connection_manager(&self) -> &HttpConnectionManager {
|
||||
&self.connection_manager
|
||||
}
|
||||
|
||||
pub fn plugin_manager(&self) -> Option<Arc<PluginManager>> {
|
||||
self.plugin_manager.clone()
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
if let Some(plugin_manager) = &self.plugin_manager {
|
||||
if let Some(plugin_event_bridge) = self.plugin_event_bridge.lock().await.take() {
|
||||
plugin_event_bridge.shutdown(plugin_manager).await;
|
||||
}
|
||||
plugin_manager.terminate().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_embedded_plugin_runtime(data_dir: &Path) -> std::io::Result<PathBuf> {
|
||||
let runtime_dir = data_dir.join("vendored").join("plugin-runtime");
|
||||
fs::create_dir_all(&runtime_dir)?;
|
||||
let runtime_main = runtime_dir.join("index.cjs");
|
||||
fs::write(&runtime_main, EMBEDDED_PLUGIN_RUNTIME)?;
|
||||
Ok(runtime_main)
|
||||
}
|
||||
|
||||
fn prepare_embedded_vendored_plugins(vendored_plugin_dir: &Path) -> std::io::Result<()> {
|
||||
fs::create_dir_all(vendored_plugin_dir)?;
|
||||
EMBEDDED_VENDORED_PLUGINS.extract(vendored_plugin_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -39,7 +39,7 @@ md5 = "0.8.0"
|
||||
notify = "8.0.0"
|
||||
pretty_graphql = "0.2"
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = "0.32"
|
||||
r2d2_sqlite = "0.25.0"
|
||||
mime_guess = "2.0.5"
|
||||
rand = "0.9.0"
|
||||
reqwest = { workspace = true, features = [
|
||||
@@ -80,7 +80,6 @@ yaak-common = { workspace = true }
|
||||
yaak-tauri-utils = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak = { workspace = true }
|
||||
yaak-commands = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-fonts = { workspace = true }
|
||||
yaak-git = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::models::HttpRequestHeader;
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_plugins::events::GetThemesResponse;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::native_template_functions::{
|
||||
decrypt_secure_template_function, encrypt_secure_template_function,
|
||||
};
|
||||
|
||||
/// Extension trait for accessing the EncryptionManager from Tauri Manager types.
|
||||
pub trait EncryptionManagerExt<'a, R> {
|
||||
fn crypto(&'a self) -> State<'a, EncryptionManager>;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> EncryptionManagerExt<'a, R> for M {
|
||||
fn crypto(&'a self) -> State<'a, EncryptionManager> {
|
||||
self.state::<EncryptionManager>()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_decrypt_template<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
template: &str,
|
||||
) -> Result<String> {
|
||||
let encryption_manager = window.app_handle().state::<EncryptionManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
Ok(decrypt_secure_template_function(&encryption_manager, &plugin_context, template)?)
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_secure_template<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
template: &str,
|
||||
) -> Result<String> {
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let plugin_context = window.plugin_context();
|
||||
Ok(encrypt_secure_template_function(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&plugin_context,
|
||||
template,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_get_themes<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(plugin_manager.get_themes(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_enable_encryption<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().ensure_workspace_key(workspace_id)?;
|
||||
window.crypto().reveal_workspace_key(workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_reveal_workspace_key<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<String> {
|
||||
Ok(window.crypto().reveal_workspace_key(workspace_id)?)
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
key: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().set_human_key(workspace_id, key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().disable_encryption(workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
|
||||
default_headers()
|
||||
}
|
||||
@@ -41,9 +41,6 @@ pub enum Error {
|
||||
#[error(transparent)]
|
||||
YaakError(#[from] yaak::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
CommandError(#[from] yaak_commands::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use KeyAndValueRef::{Ascii, Binary};
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
||||
@@ -20,6 +21,22 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String>
|
||||
entries
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_grpc_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &GrpcRequest,
|
||||
) -> Result<(GrpcRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_grpc_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let metadata = window.db().resolve_metadata_for_grpc_request(request)?;
|
||||
new_request.metadata = metadata;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_metadata<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
|
||||
@@ -179,3 +179,19 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
Ok(SentHttpRequest { response: result.response, body: result.response_body })
|
||||
}
|
||||
|
||||
pub fn resolve_http_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &HttpRequest,
|
||||
) -> Result<(HttpRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_http_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let headers = window.db().resolve_headers_for_http_request(request)?;
|
||||
new_request.headers = headers;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
@@ -2,18 +2,19 @@ extern crate core;
|
||||
use crate::encoding::read_response_body;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::grpc::{build_metadata, metadata_to_map};
|
||||
use crate::http_request::send_http_request;
|
||||
use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};
|
||||
use crate::http_request::{resolve_http_request, send_http_request};
|
||||
use crate::import::{import_data, import_url};
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::render::{render_grpc_request, render_template};
|
||||
use crate::render::{render_grpc_request, render_json_value, render_template};
|
||||
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
|
||||
use crate::uri_scheme::handle_deep_link;
|
||||
use error::Result as YaakResult;
|
||||
use eventsource_client::{EventParser, SSE};
|
||||
use log::{debug, error, info, warn};
|
||||
use std::path::PathBuf;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -28,32 +29,42 @@ use tauri_plugin_log::{Builder, Target, TargetKind, log};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::time;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak::send::ResponseBody;
|
||||
use yaak_commands::responses::locate_response_body;
|
||||
use yaak_commands::resolve::resolve_grpc_request;
|
||||
use yaak_common::command::new_checked_command;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||
use yaak_grpc::{Code, ServiceDefinition};
|
||||
use yaak_mac_window::AppHandleMacWindowExt;
|
||||
use yaak_models::models::{
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
||||
RenderPurpose, ShowToastRequest,
|
||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
|
||||
CallWorkspaceActionRequest, Color, FilterResponse, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, InternalEvent,
|
||||
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_templates::format_json::format_json;
|
||||
use yaak_templates::strip_json_comments::strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
|
||||
use yaak_tls::find_client_certificate;
|
||||
|
||||
mod commands;
|
||||
mod encoding;
|
||||
mod error;
|
||||
mod feedback;
|
||||
@@ -212,6 +223,56 @@ async fn detect_cli_version_for_binary(program: &str) -> Option<String> {
|
||||
Some(parts.next().unwrap_or(line).to_string())
|
||||
}
|
||||
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
tokens: Tokens,
|
||||
) -> YaakResult<String> {
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
|
||||
RenderPurpose::Preview,
|
||||
);
|
||||
let new_tokens = transform_args(tokens, &cb)?;
|
||||
Ok(new_tokens.to_string())
|
||||
}
|
||||
|
||||
async fn cmd_render_template<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
template: &str,
|
||||
workspace_id: &str,
|
||||
environment_id: Option<&str>,
|
||||
purpose: Option<RenderPurpose>,
|
||||
ignore_error: Option<bool>,
|
||||
) -> YaakResult<String> {
|
||||
let environment_chain =
|
||||
app_handle.db().resolve_environments(workspace_id, None, environment_id)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let result = render_template(
|
||||
template,
|
||||
environment_chain,
|
||||
&PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
|
||||
purpose.unwrap_or(RenderPurpose::Preview),
|
||||
),
|
||||
&RenderOptions {
|
||||
error_behavior: match ignore_error {
|
||||
Some(true) => RenderErrorBehavior::ReturnEmpty,
|
||||
_ => RenderErrorBehavior::Throw,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn cmd_send_feedback<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
feature: String,
|
||||
@@ -238,8 +299,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
||||
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
||||
) -> YaakResult<Vec<ServiceDefinition>> {
|
||||
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_grpc_request(&window.db(), &unrendered_request)?;
|
||||
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
|
||||
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&unrendered_request.workspace_id,
|
||||
@@ -299,8 +359,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
||||
) -> YaakResult<String> {
|
||||
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_grpc_request(&window.db(), &unrendered_request)?;
|
||||
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&unrendered_request.workspace_id,
|
||||
unrendered_request.folder_id.as_deref(),
|
||||
@@ -952,6 +1011,10 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
|
||||
Ok(EphemeralHttpResponse { response: sent.response, body })
|
||||
}
|
||||
|
||||
async fn cmd_format_json(text: &str) -> YaakResult<String> {
|
||||
Ok(format_json(text, " "))
|
||||
}
|
||||
|
||||
async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
|
||||
match pretty_graphql::format_text(text, &Default::default()) {
|
||||
Ok(formatted) => Ok(formatted),
|
||||
@@ -959,13 +1022,44 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a response's body is, and what it is meant to be read as.
|
||||
struct ResponseBodyLocation {
|
||||
/// None when the response has no stored body.
|
||||
path: Option<PathBuf>,
|
||||
/// The response's declared `Content-Type`, empty when it has none.
|
||||
content_type: String,
|
||||
}
|
||||
|
||||
/// Find a response's body from its id alone.
|
||||
///
|
||||
/// The frontend hands back an id and never a path, so the only bodies reachable
|
||||
/// here are ones the engine wrote and the database still knows about. A
|
||||
/// response that was never saved has no entry, and its body came back from the
|
||||
/// send that made it.
|
||||
fn locate_response_body<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
response_id: &str,
|
||||
) -> YaakResult<ResponseBodyLocation> {
|
||||
let response = app_handle.db().get_http_response(response_id)?;
|
||||
|
||||
Ok(ResponseBodyLocation {
|
||||
path: response.body_path.map(PathBuf::from),
|
||||
content_type: response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
|
||||
.map(|h| h.value.clone())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn cmd_http_response_body<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
response_id: &str,
|
||||
filter: Option<&str>,
|
||||
) -> YaakResult<FilterResponse> {
|
||||
let location = locate_response_body(&window.db(), response_id)?;
|
||||
let location = locate_response_body(window.app_handle(), response_id)?;
|
||||
let Some(body_path) = location.path else {
|
||||
return Ok(FilterResponse { content: String::new(), error: None });
|
||||
};
|
||||
@@ -983,11 +1077,41 @@ async fn cmd_http_response_body<R: Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
/// The body's path on this machine, for the desktop host to read or hand to the
|
||||
/// webview's asset protocol.
|
||||
///
|
||||
/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
|
||||
/// the path, and only because it is about to open the file itself. Hosts
|
||||
/// without a filesystem serve the same bytes over HTTP instead.
|
||||
async fn cmd_http_response_body_path<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
response_id: &str,
|
||||
) -> YaakResult<Option<String>> {
|
||||
let location = locate_response_body(&app_handle, response_id)?;
|
||||
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
async fn cmd_http_request_body<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
response_id: &str,
|
||||
) -> YaakResult<Option<Vec<u8>>> {
|
||||
let body_id = format!("{}.request", response_id);
|
||||
let chunks = app_handle.blobs().get_chunks(&body_id)?;
|
||||
|
||||
if chunks.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Concatenate all chunks
|
||||
let body: Vec<u8> = chunks.into_iter().flat_map(|c| c.data).collect();
|
||||
Ok(Some(body))
|
||||
}
|
||||
|
||||
async fn cmd_get_sse_events<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
response_id: &str,
|
||||
) -> YaakResult<Vec<ServerSentEvent>> {
|
||||
let Some(body_path) = locate_response_body(&app_handle.db(), response_id)?.path else {
|
||||
let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
@@ -1010,6 +1134,14 @@ async fn cmd_get_sse_events<R: Runtime>(
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_response_events<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
response_id: &str,
|
||||
) -> YaakResult<Vec<HttpResponseEvent>> {
|
||||
let events: Vec<HttpResponseEvent> = app_handle.db().list_http_response_events(response_id)?;
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
async fn cmd_import_data<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
@@ -1024,19 +1156,299 @@ async fn cmd_import_url<R: Runtime>(
|
||||
import_url(&window, url).await
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_http_request_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_websocket_request_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let websocket_request = window.db().get_websocket_request(&req.args.websocket_request.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_websocket_request_action(
|
||||
&window.plugin_context(),
|
||||
CallWebsocketRequestActionRequest {
|
||||
args: CallWebsocketRequestActionArgs { websocket_request },
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_workspace_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(plugin_manager.get_workspace_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_workspace_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallWorkspaceActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let workspace = window.db().get_workspace(&req.args.workspace.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_workspace_action(
|
||||
&window.plugin_context(),
|
||||
CallWorkspaceActionRequest { args: CallWorkspaceActionArgs { workspace }, ..req },
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_folder_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetFolderActionsResponse>> {
|
||||
Ok(plugin_manager.get_folder_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_folder_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallFolderActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let folder = window.db().get_folder(&req.args.folder.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_folder_action(
|
||||
&window.plugin_context(),
|
||||
CallFolderActionRequest { args: CallFolderActionArgs { folder }, ..req },
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_grpc_request_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_summaries<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
let results = plugin_manager.get_template_function_summaries(&window.plugin_context()).await?;
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_config<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
function_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
_environment_id: Option<&str>,
|
||||
) -> YaakResult<GetTemplateFunctionConfigResponse> {
|
||||
Ok(plugin_manager
|
||||
.get_template_function_config(&window.plugin_context(), function_name, values, model.id())
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results =
|
||||
plugin_manager.get_http_authentication_summaries(&window.plugin_context()).await?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
encryption_manager: State<'_, EncryptionManager>,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
) -> YaakResult<GetHttpAuthenticationConfigResponse> {
|
||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
||||
let (workspace_id, folder_id) = match &model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
_ => return Err(GenericError("Unsupported model type for authentication config".into())),
|
||||
};
|
||||
|
||||
// Resolve environment chain and render the values for token lookup
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&workspace_id,
|
||||
folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager_arc,
|
||||
encryption_manager_arc,
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Preview,
|
||||
);
|
||||
|
||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
||||
let rendered_json =
|
||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::return_empty())
|
||||
.await?;
|
||||
|
||||
// Convert back to HashMap<String, JsonPrimitive>
|
||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
||||
|
||||
Ok(plugin_manager
|
||||
.get_http_authentication_config(
|
||||
&window.plugin_context(),
|
||||
auth_name,
|
||||
rendered_values,
|
||||
model.id(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallHttpRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(plugin_manager
|
||||
.call_http_request_action(
|
||||
&window.plugin_context(),
|
||||
CallHttpRequestActionRequest {
|
||||
args: CallHttpRequestActionArgs {
|
||||
http_request: resolve_http_request(&window, &req.args.http_request)?.0,
|
||||
..req.args
|
||||
},
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(plugin_manager
|
||||
.call_grpc_request_action(
|
||||
&window.plugin_context(),
|
||||
CallGrpcRequestActionRequest {
|
||||
args: CallGrpcRequestActionArgs {
|
||||
grpc_request: resolve_grpc_request(&window, &req.args.grpc_request)?.0,
|
||||
..req.args
|
||||
},
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
encryption_manager: State<'_, EncryptionManager>,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
) -> YaakResult<()> {
|
||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
||||
let (workspace_id, folder_id) = match &model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
_ => return Err(GenericError("Unsupported model type for authentication action".into())),
|
||||
};
|
||||
|
||||
// Resolve environment chain and render the values
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&workspace_id,
|
||||
folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager_arc,
|
||||
encryption_manager_arc,
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Send,
|
||||
);
|
||||
|
||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
||||
let rendered_json =
|
||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
|
||||
|
||||
// Convert back to HashMap<String, JsonPrimitive>
|
||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
||||
|
||||
Ok(plugin_manager
|
||||
.call_http_authentication_action(
|
||||
&window.plugin_context(),
|
||||
auth_name,
|
||||
action_index,
|
||||
rendered_values,
|
||||
&model.id(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_curl_to_request<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
command: &str,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
workspace_id: &str,
|
||||
) -> YaakResult<HttpRequest> {
|
||||
let import_result = plugin_manager.import_data(&window.plugin_context(), command).await?;
|
||||
|
||||
Ok(import_result
|
||||
.resources
|
||||
.http_requests
|
||||
.get(0)
|
||||
.ok_or(GenericError("No curl command found".to_string()))
|
||||
.map(|r| {
|
||||
let mut request = r.clone();
|
||||
request.workspace_id = workspace_id.into();
|
||||
request.id = "".to_string();
|
||||
request
|
||||
})?)
|
||||
}
|
||||
|
||||
async fn cmd_export_data<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
export_path: &str,
|
||||
workspace_ids: Vec<&str>,
|
||||
include_private_environments: bool,
|
||||
) -> YaakResult<()> {
|
||||
let version = app_handle.package_info().version.to_string();
|
||||
Ok(export::export_data(ExportDataParams {
|
||||
query_manager: &app_handle.db_manager(),
|
||||
yaak_version: &version,
|
||||
export_path: Path::new(export_path),
|
||||
workspace_ids,
|
||||
include_private_environments,
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Decodes base64 and writes the bytes to a file the user picked.
|
||||
///
|
||||
@@ -1061,6 +1473,20 @@ async fn cmd_save_base64_to_binary<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_save_response<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
response_id: &str,
|
||||
filepath: &str,
|
||||
) -> YaakResult<()> {
|
||||
let response = app_handle.db().get_http_response(response_id)?;
|
||||
|
||||
let body_path =
|
||||
response.body_path.ok_or(GenericError("Response does not have a body".to_string()))?;
|
||||
fs::copy(body_path, filepath).map_err(|e| GenericError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_send_http_request<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
@@ -1132,6 +1558,101 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
async fn cmd_reload_plugins<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<(String, String)>> {
|
||||
let plugins = app_handle.db().list_plugins()?;
|
||||
let plugin_context =
|
||||
PluginContext::new(Some(window.label().to_string()), window.workspace_id());
|
||||
let errors = plugin_manager.initialize_all_plugins(plugins, &plugin_context).await;
|
||||
Ok(errors)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_info<R: Runtime>(
|
||||
id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<PluginMetadata> {
|
||||
let plugin = app_handle.db().get_plugin(id)?;
|
||||
if let Some(plugin_handle) = plugin_manager
|
||||
.get_plugin_by_dir(plugin.directory.as_str())
|
||||
.await
|
||||
{
|
||||
return Ok(plugin_handle.info());
|
||||
}
|
||||
|
||||
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
|
||||
return Ok(metadata);
|
||||
}
|
||||
|
||||
Ok(fallback_plugin_metadata(&plugin.directory))
|
||||
}
|
||||
|
||||
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
|
||||
let display_name = PathBuf::from(directory)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(directory)
|
||||
.to_string();
|
||||
|
||||
PluginMetadata {
|
||||
version: "Unavailable".to_string(),
|
||||
name: directory.to_string(),
|
||||
display_name,
|
||||
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
|
||||
homepage_url: None,
|
||||
repository_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_grpc_connections<R: Runtime>(
|
||||
request_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(app_handle.db().delete_all_grpc_connections_for_request(
|
||||
request_id,
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_send_history<R: Runtime>(
|
||||
workspace_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(app_handle.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
tx.delete_all_http_responses_for_workspace(workspace_id, source)?;
|
||||
tx.delete_all_grpc_connections_for_workspace(workspace_id, source)?;
|
||||
tx.delete_all_websocket_connections_for_workspace(workspace_id, source)?;
|
||||
Ok(())
|
||||
})?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_http_responses<R: Runtime>(
|
||||
request_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> YaakResult<()> {
|
||||
app_handle.db().delete_all_http_responses_for_request(
|
||||
request_id,
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_get_workspace_meta<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
workspace_id: &str,
|
||||
) -> YaakResult<WorkspaceMeta> {
|
||||
let db = app_handle.db();
|
||||
let workspace = db.get_workspace(workspace_id)?;
|
||||
Ok(db.get_or_create_workspace_meta(&workspace.id)?)
|
||||
}
|
||||
|
||||
async fn cmd_new_child_window<R: Runtime>(
|
||||
parent_window: WebviewWindow<R>,
|
||||
@@ -1451,7 +1972,6 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
|
||||
let ev = match ev {
|
||||
Ok(Some(ev)) => ev,
|
||||
// Nothing to say, or the reply comes later from somewhere else.
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
warn!("Failed to handle plugin event: {e:?}");
|
||||
@@ -1464,10 +1984,7 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
timeout: Some(30000),
|
||||
}),
|
||||
);
|
||||
// Tell the plugin as well as the user. It is awaiting a
|
||||
// reply, and a toast it cannot see would leave it
|
||||
// waiting for one that never comes.
|
||||
InternalEventPayload::ErrorResponse(ErrorResponse { error: e.to_string() })
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,8 +12,10 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::error::Result;
|
||||
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
||||
@@ -121,12 +123,163 @@ impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
|
||||
/// Extension trait for accessing the BlobManager from Tauri Manager types.
|
||||
pub trait BlobManagerExt<'a, R> {
|
||||
fn blob_manager(&'a self) -> State<'a, BlobManager>;
|
||||
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> BlobManagerExt<'a, R> for M {
|
||||
fn blob_manager(&'a self) -> State<'a, BlobManager> {
|
||||
self.state::<BlobManager>()
|
||||
}
|
||||
|
||||
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext {
|
||||
let manager = self.state::<BlobManager>();
|
||||
manager.inner().connect()
|
||||
}
|
||||
}
|
||||
|
||||
// Commands for yaak-models
|
||||
use tauri::WebviewWindow;
|
||||
|
||||
pub(crate) fn models_upsert<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
let db = window.db();
|
||||
let blobs = window.blob_manager();
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
yaak::models_ops::upsert_model(&db, &blobs, model, source)
|
||||
}
|
||||
|
||||
// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a
|
||||
// blocking thread instead of stalling the main thread and all other IPC.
|
||||
pub(crate) async fn models_delete<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let blobs = window.blob_manager();
|
||||
// Use transaction for deletions because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
yaak::models_ops::delete_model(tx, &blobs, model, source)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("Delete task failed: {e}")))?
|
||||
}
|
||||
|
||||
pub(crate) fn models_duplicate<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model_type: String,
|
||||
model_id: String,
|
||||
) -> Result<String> {
|
||||
// Use transaction for duplications because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
yaak::models_ops::duplicate_model(tx, &model_type, &model_id, source)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn models_websocket_events<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(app_handle.db().list_websocket_events(connection_id)?)
|
||||
}
|
||||
|
||||
pub(crate) fn models_grpc_events<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<GrpcEvent>> {
|
||||
Ok(app_handle.db().list_grpc_events(connection_id)?)
|
||||
}
|
||||
|
||||
pub(crate) fn models_get_settings<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Result<Settings> {
|
||||
Ok(app_handle.db().get_settings())
|
||||
}
|
||||
|
||||
pub(crate) fn models_get_graphql_introspection<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
request_id: &str,
|
||||
) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(app_handle.db().get_graphql_introspection(request_id))
|
||||
}
|
||||
|
||||
pub(crate) fn models_upsert_graphql_introspection<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
request_id: &str,
|
||||
workspace_id: &str,
|
||||
content: Option<String>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
let source = UpdateSource::from_window_label(window.label());
|
||||
Ok(app_handle.db().upsert_graphql_introspection(workspace_id, request_id, content, &source)?)
|
||||
}
|
||||
|
||||
pub(crate) async fn models_workspace_models<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: Option<&str>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<String> {
|
||||
let mut l: Vec<AnyModel> = Vec::new();
|
||||
|
||||
// Add the global models
|
||||
{
|
||||
let db = window.db();
|
||||
l.push(db.get_settings().into());
|
||||
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
let plugins = {
|
||||
let db = window.db();
|
||||
db.list_plugins()?
|
||||
};
|
||||
|
||||
let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;
|
||||
l.append(&mut plugins.into_iter().map(Into::into).collect());
|
||||
|
||||
// Add the workspace children
|
||||
if let Some(wid) = workspace_id {
|
||||
let db = window.db();
|
||||
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
let j = serde_json::to_string(&l)?;
|
||||
|
||||
Ok(escape_str_for_webview(&j))
|
||||
}
|
||||
|
||||
fn escape_str_for_webview(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| {
|
||||
let code = c as u32;
|
||||
// ASCII
|
||||
if code <= 0x7F {
|
||||
c.to_string()
|
||||
// BMP characters encoded normally
|
||||
} else if code < 0xFFFF {
|
||||
format!("\\u{:04X}", code)
|
||||
// Beyond BMP encoded a surrogate pairs
|
||||
} else {
|
||||
let high = ((code - 0x10000) >> 10) + 0xD800;
|
||||
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
|
||||
format!("\\u{:04X}\\u{:04X}", high, low)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Initialize database managers as a plugin (for initialization order).
|
||||
|
||||
@@ -7,8 +7,6 @@ use crate::{
|
||||
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
|
||||
workspace_from_window,
|
||||
};
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use chrono::Utc;
|
||||
use log::error;
|
||||
use std::sync::Arc;
|
||||
@@ -18,7 +16,6 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
use yaak_models::models::{HttpResponse, Plugin};
|
||||
@@ -57,7 +54,6 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
app_handle.db_manager().inner(),
|
||||
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
|
||||
&event.payload,
|
||||
SharedPluginEventContext {
|
||||
plugin_name: &plugin_name,
|
||||
@@ -317,13 +313,8 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// An ad-hoc request saves nothing, so the engine hands the body
|
||||
// back and this reply is the only place the plugin can get it.
|
||||
let body = http_response.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b));
|
||||
|
||||
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
|
||||
http_response: http_response.response,
|
||||
body,
|
||||
})))
|
||||
}
|
||||
HostRequest::OpenWindow(req) => {
|
||||
|
||||
@@ -194,6 +194,12 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
|
||||
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
|
||||
}
|
||||
|
||||
pub async fn cmd_plugin_init_errors(
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(plugin_manager.take_init_errors().await)
|
||||
}
|
||||
|
||||
pub async fn cmd_plugins_updates<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<PluginUpdatesResponse> {
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
//! One import path for rendering, wherever the pieces actually live.
|
||||
//!
|
||||
//! The request renderers are engine code; the template renderers moved to
|
||||
//! `yaak-commands` when the template commands did. Callers in this crate do not
|
||||
//! need to track which is which.
|
||||
|
||||
use serde_json::Value;
|
||||
pub use yaak::render::{render_grpc_request, render_http_request};
|
||||
pub use yaak_commands::render::{render_json_value, render_template};
|
||||
use yaak_models::models::Environment;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
pub async fn render_template<T: TemplateCallback>(
|
||||
template: &str,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
parse_and_render(template, vars, cb, &opt).await
|
||||
}
|
||||
|
||||
pub async fn render_json_value<T: TemplateCallback>(
|
||||
value: Value,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<Value> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
|
||||
@@ -8,13 +8,10 @@
|
||||
//! is the only way the frontend reaches any of it — one envelope,
|
||||
//! `{ cmd, payload }`, exactly like the proxy app.
|
||||
//!
|
||||
//! Command bodies live in one of two places. Host-independent ones are in
|
||||
//! `yaak_commands`, written against its `Host` trait, which `ClientCtx`
|
||||
//! implements below; their adapters are one line. The rest still have their
|
||||
//! natural Tauri signatures (window, app handle, managed state) and their
|
||||
//! adapters unpack the request for them. Either way the wire format stays
|
||||
//! transport-agnostic: another host builds its router from the same schema
|
||||
//! with its own `Host`, and the frontend cannot tell.
|
||||
//! Adapters exist so command implementations keep their natural Tauri
|
||||
//! signatures (window, app handle, managed state) while the wire format stays
|
||||
//! transport-agnostic: another host builds its router from the same schema with
|
||||
//! a different context type and its own adapters, and the frontend cannot tell.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::notifications::YaakNotifier;
|
||||
@@ -22,11 +19,7 @@ use crate::updates::YaakUpdater;
|
||||
use log::warn;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, Runtime, State, WebviewWindow};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_git::{
|
||||
BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
|
||||
@@ -34,17 +27,13 @@ use yaak_git::{
|
||||
};
|
||||
use yaak_grpc::manager::GrpcHandle;
|
||||
use yaak_grpc::ServiceDefinition;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{
|
||||
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
|
||||
JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
|
||||
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
|
||||
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
|
||||
@@ -52,15 +41,11 @@ use yaak_plugins::events::{
|
||||
};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::native_template_functions::encrypt_secure_template_function;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_rpc::RpcRouter;
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_sync::sync::SyncOp;
|
||||
use yaak_templates::TemplateCallback;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_ws::WebsocketManager;
|
||||
|
||||
/// Per-call context: the window a command was invoked from.
|
||||
@@ -80,215 +65,6 @@ impl<R: Runtime> Clone for ClientCtx<R> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The desktop is a host: the client is the window, the session is the
|
||||
/// window's URL, and the shared managers are Tauri managed state.
|
||||
impl<R: Runtime> Host for ClientCtx<R> {
|
||||
fn client_id(&self) -> &str {
|
||||
self.window.label()
|
||||
}
|
||||
|
||||
fn session(&self) -> WorkspaceContext {
|
||||
self.window.workspace_context()
|
||||
}
|
||||
|
||||
fn app_version(&self) -> String {
|
||||
self.window.package_info().version.to_string()
|
||||
}
|
||||
|
||||
fn query_manager(&self) -> &QueryManager {
|
||||
self.window.state::<QueryManager>().inner()
|
||||
}
|
||||
|
||||
fn blob_manager(&self) -> &BlobManager {
|
||||
self.window.state::<BlobManager>().inner()
|
||||
}
|
||||
|
||||
fn encryption_manager(&self) -> &EncryptionManager {
|
||||
self.window.state::<EncryptionManager>().inner()
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> ClientCtx<R> {
|
||||
/// The plugin runtime this window talks to. Only the `PluginHost` impl
|
||||
/// below uses it; everything else goes through the trait.
|
||||
fn pm(&self) -> State<'_, PluginManager> {
|
||||
self.window.state::<PluginManager>()
|
||||
}
|
||||
}
|
||||
|
||||
/// The desktop answers all of these out of the `PluginManager` it already
|
||||
/// runs — the Node sidecar. Each is a delegation, which is the point: the
|
||||
/// operations are what the handlers need, and this is one host's way of
|
||||
/// providing them.
|
||||
impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
async fn loaded_plugin_metadata(&self, directory: &str) -> Option<PluginMetadata> {
|
||||
let handle = self.pm().get_plugin_by_dir(directory).await?;
|
||||
Some(handle.info())
|
||||
}
|
||||
|
||||
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
|
||||
self.pm().take_init_errors().await
|
||||
}
|
||||
|
||||
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
|
||||
self.pm().resolve_plugins_for_runtime_from_db(plugins).await
|
||||
}
|
||||
|
||||
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback {
|
||||
PluginTemplateCallback::new(
|
||||
Arc::new((*self.pm()).clone()),
|
||||
Arc::new(self.encryption_manager().clone()),
|
||||
&self.plugin_context(),
|
||||
purpose,
|
||||
)
|
||||
}
|
||||
|
||||
async fn template_function_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(self
|
||||
.window
|
||||
.state::<PluginManager>()
|
||||
.get_template_function_summaries(&self.plugin_context())
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||
Ok(self
|
||||
.window
|
||||
.state::<PluginManager>()
|
||||
.get_template_function_config(&self.plugin_context(), function_name, values, model_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
|
||||
Ok(self.pm().get_themes(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn http_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(self.pm().get_http_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn websocket_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(self.pm().get_websocket_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn grpc_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(self.pm().get_grpc_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(self.pm().get_workspace_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(self.pm().get_folder_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn call_http_request_action(
|
||||
&self,
|
||||
req: CallHttpRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_http_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_grpc_request_action(
|
||||
&self,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_grpc_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_websocket_request_action(
|
||||
&self,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_websocket_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_workspace_action(
|
||||
&self,
|
||||
req: CallWorkspaceActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_workspace_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_folder_action(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_folder_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results = self.pm().get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
async fn http_authentication_config(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
|
||||
Ok(self
|
||||
.pm()
|
||||
.get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn call_http_authentication_action(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self
|
||||
.pm()
|
||||
.call_http_authentication_action(
|
||||
&self.plugin_context(),
|
||||
auth_name,
|
||||
action_index,
|
||||
values,
|
||||
model_id,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn import_data(&self, content: &str) -> yaak_commands::Result<ImportResponse> {
|
||||
Ok(self.pm().import_data(&self.plugin_context(), content).await?)
|
||||
}
|
||||
|
||||
async fn reload_plugins(&self, plugins: Vec<Plugin>) -> Vec<(String, String)> {
|
||||
self.pm().initialize_all_plugins(plugins, &self.plugin_context()).await
|
||||
}
|
||||
|
||||
async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> {
|
||||
let plugin_manager = Arc::new((*self.pm()).clone());
|
||||
let encryption_manager = Arc::new(self.encryption_manager().clone());
|
||||
Ok(encrypt_secure_template_function(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&self.plugin_context(),
|
||||
template,
|
||||
)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// The one Tauri command. The payload is the yaak-rpc envelope's payload;
|
||||
/// a missing payload means an empty one.
|
||||
#[tauri::command]
|
||||
@@ -382,11 +158,11 @@ async fn cmd_metadata<R: Runtime>(ctx: ClientCtx<R>, _req: CmdMetadataReq) -> Re
|
||||
}
|
||||
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateTokensToStringReq) -> Result<String> {
|
||||
Ok(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?)
|
||||
Ok(crate::cmd_template_tokens_to_string(ctx.window.clone(), ctx.window.app_handle().clone(), req.tokens).await?)
|
||||
}
|
||||
|
||||
async fn cmd_render_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdRenderTemplateReq) -> Result<String> {
|
||||
Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?)
|
||||
Ok(crate::cmd_render_template(ctx.window.clone(), ctx.window.app_handle().clone(), &req.template, &req.workspace_id, req.environment_id.as_deref(), req.purpose, req.ignore_error).await?)
|
||||
}
|
||||
|
||||
async fn cmd_send_feedback<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendFeedbackReq) -> Result<()> {
|
||||
@@ -413,8 +189,8 @@ async fn cmd_send_ephemeral_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendE
|
||||
Ok(crate::cmd_send_ephemeral_request(req.request, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.clone(), ctx.window.app_handle().clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_format_json<R: Runtime>(ctx: ClientCtx<R>, req: CmdFormatJsonReq) -> Result<String> {
|
||||
Ok(yaak_commands::data::cmd_format_json(ctx, req).await?)
|
||||
async fn cmd_format_json<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatJsonReq) -> Result<String> {
|
||||
Ok(crate::cmd_format_json(&req.text).await?)
|
||||
}
|
||||
|
||||
async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphqlReq) -> Result<String> {
|
||||
@@ -426,11 +202,11 @@ async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRespo
|
||||
}
|
||||
|
||||
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
|
||||
Ok(yaak_commands::responses::cmd_http_response_body_path(ctx, req).await?)
|
||||
Ok(crate::cmd_http_response_body_path(ctx.window.app_handle().clone(), &req.response_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
|
||||
Ok(yaak_commands::responses::cmd_http_request_body(ctx, req).await?)
|
||||
Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
|
||||
@@ -438,7 +214,7 @@ async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsR
|
||||
}
|
||||
|
||||
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
|
||||
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
|
||||
Ok(crate::cmd_get_http_response_events(ctx.window.app_handle().clone(), &req.response_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
|
||||
@@ -449,72 +225,72 @@ async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) ->
|
||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_http_request_actions(ctx, req).await?)
|
||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(crate::cmd_http_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_websocket_request_actions(ctx, req).await?)
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(crate::cmd_websocket_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWebsocketRequestActionReq) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_websocket_request_action(ctx, req).await?)
|
||||
Ok(crate::cmd_call_websocket_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_workspace_actions(ctx, req).await?)
|
||||
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(crate::cmd_workspace_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_workspace_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWorkspaceActionReq) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_workspace_action(ctx, req).await?)
|
||||
Ok(crate::cmd_call_workspace_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_folder_actions(ctx, req).await?)
|
||||
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(crate::cmd_folder_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_folder_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallFolderActionReq) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_folder_action(ctx, req).await?)
|
||||
Ok(crate::cmd_call_folder_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_grpc_request_actions(ctx, req).await?)
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(crate::cmd_grpc_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_template_function_summaries(ctx, req).await?)
|
||||
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, _req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(crate::cmd_template_function_summaries(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionConfigReq) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?)
|
||||
Ok(crate::cmd_template_function_config(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.function_name, req.values, req.model, req.environment_id.as_deref()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_summaries(ctx, req).await?)
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(crate::cmd_get_http_authentication_summaries(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationConfigReq) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_config(ctx, req).await?)
|
||||
Ok(crate::cmd_get_http_authentication_config(ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<EncryptionManager>(), &req.auth_name, req.values, req.model, req.environment_id.as_deref()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpRequestActionReq) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_http_request_action(ctx, req).await?)
|
||||
Ok(crate::cmd_call_http_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallGrpcRequestActionReq) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_grpc_request_action(ctx, req).await?)
|
||||
Ok(crate::cmd_call_grpc_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpAuthenticationActionReq) -> Result<()> {
|
||||
Ok(yaak_commands::auth::cmd_call_http_authentication_action(ctx, req).await?)
|
||||
Ok(crate::cmd_call_http_authentication_action(ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<EncryptionManager>(), &req.auth_name, req.action_index, req.values, req.model, req.environment_id.as_deref()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_curl_to_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
|
||||
Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?)
|
||||
Ok(crate::cmd_curl_to_request(ctx.window.clone(), &req.command, ctx.window.app_handle().state::<PluginManager>(), &req.workspace_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -> Result<()> {
|
||||
Ok(yaak_commands::data::cmd_export_data(ctx, req).await?)
|
||||
Ok(crate::cmd_export_data(ctx.window.app_handle().clone(), &req.export_path, req.workspace_ids.iter().map(|s| s.as_str()).collect(), req.include_private_environments).await?)
|
||||
}
|
||||
|
||||
async fn cmd_save_base64_to_binary<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveBase64ToBinaryReq) -> Result<()> {
|
||||
@@ -522,35 +298,35 @@ async fn cmd_save_base64_to_binary<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveBa
|
||||
}
|
||||
|
||||
async fn cmd_save_response<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveResponseReq) -> Result<()> {
|
||||
Ok(yaak_commands::responses::cmd_save_response(ctx, req).await?)
|
||||
Ok(crate::cmd_save_response(ctx.window.app_handle().clone(), &req.response_id, &req.filepath).await?)
|
||||
}
|
||||
|
||||
async fn cmd_send_http_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendHttpRequestReq) -> Result<HttpResponse> {
|
||||
Ok(crate::cmd_send_http_request(ctx.window.app_handle().clone(), ctx.window.clone(), req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), req.request_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
|
||||
Ok(yaak_commands::actions::cmd_reload_plugins(ctx, req).await?)
|
||||
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, _req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
|
||||
Ok(crate::cmd_reload_plugins(ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_info<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
|
||||
Ok(yaak_commands::plugins::cmd_plugin_info(ctx, req).await?)
|
||||
Ok(crate::cmd_plugin_info(&req.id, ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_grpc_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllGrpcConnectionsReq) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_all_grpc_connections(ctx, req).await?)
|
||||
Ok(crate::cmd_delete_all_grpc_connections(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_send_history<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteSendHistoryReq) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_send_history(ctx, req).await?)
|
||||
Ok(crate::cmd_delete_send_history(&req.workspace_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_http_responses<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllHttpResponsesReq) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_all_http_responses(ctx, req).await?)
|
||||
Ok(crate::cmd_delete_all_http_responses(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_workspace_meta<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetWorkspaceMetaReq) -> Result<WorkspaceMeta> {
|
||||
Ok(yaak_commands::models::cmd_get_workspace_meta(ctx, req).await?)
|
||||
Ok(crate::cmd_get_workspace_meta(ctx.window.app_handle().clone(), &req.workspace_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_new_child_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewChildWindowReq) -> Result<()> {
|
||||
@@ -566,110 +342,71 @@ async fn cmd_check_for_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdCheckForU
|
||||
}
|
||||
|
||||
async fn cmd_decrypt_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdDecryptTemplateReq) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_decrypt_template(ctx, req).await?)
|
||||
Ok(crate::commands::cmd_decrypt_template(ctx.window.clone(), &req.template).await?)
|
||||
}
|
||||
|
||||
async fn cmd_secure_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdSecureTemplateReq) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?)
|
||||
Ok(crate::commands::cmd_secure_template(ctx.window.app_handle().clone(), ctx.window.clone(), &req.template).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_get_themes(ctx, req).await?)
|
||||
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(crate::commands::cmd_get_themes(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_enable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdEnableEncryptionReq) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_enable_encryption(ctx, req).await?)
|
||||
Ok(crate::commands::cmd_enable_encryption(ctx.window.clone(), &req.workspace_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_reveal_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdRevealWorkspaceKeyReq) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_reveal_workspace_key(ctx, req).await?)
|
||||
Ok(crate::commands::cmd_reveal_workspace_key(ctx.window.clone(), &req.workspace_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_set_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdSetWorkspaceKeyReq) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_set_workspace_key(ctx, req).await?)
|
||||
Ok(crate::commands::cmd_set_workspace_key(ctx.window.clone(), &req.workspace_id, &req.key).await?)
|
||||
}
|
||||
|
||||
async fn cmd_disable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdDisableEncryptionReq) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_disable_encryption(ctx, req).await?)
|
||||
Ok(crate::commands::cmd_disable_encryption(ctx.window.clone(), &req.workspace_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_default_headers<R: Runtime>(ctx: ClientCtx<R>, req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
|
||||
Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?)
|
||||
async fn cmd_default_headers<R: Runtime>(_ctx: ClientCtx<R>, _req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
|
||||
Ok(crate::commands::cmd_default_headers())
|
||||
}
|
||||
|
||||
async fn models_upsert<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertReq) -> Result<String> {
|
||||
Ok(yaak_commands::models::models_upsert(ctx, req).await?)
|
||||
Ok(crate::models_ext::models_upsert(ctx.window.clone(), req.model)?)
|
||||
}
|
||||
|
||||
/// Runs on a blocking thread rather than the async runtime: a cascading delete
|
||||
/// (a workspace with thousands of requests) holds a transaction for its whole
|
||||
/// duration, and stalling the runtime stalls every other IPC call behind it.
|
||||
/// That is this host's concern, so the shared handler stays plain and the
|
||||
/// relocation happens here.
|
||||
async fn models_delete<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDeleteReq) -> Result<String> {
|
||||
let deleted = tauri::async_runtime::spawn_blocking(move || {
|
||||
yaak_commands::models::models_delete_blocking(&ctx, req)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| crate::error::Error::GenericError(format!("Delete task failed: {e}")))?;
|
||||
Ok(deleted?)
|
||||
Ok(crate::models_ext::models_delete(ctx.window.clone(), req.model).await?)
|
||||
}
|
||||
|
||||
async fn models_duplicate<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq) -> Result<String> {
|
||||
Ok(yaak_commands::models::models_duplicate(ctx, req).await?)
|
||||
Ok(crate::models_ext::models_duplicate(ctx.window.clone(), req.model_type, req.model_id)?)
|
||||
}
|
||||
|
||||
async fn models_websocket_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWebsocketEventsReq) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(yaak_commands::models::models_websocket_events(ctx, req).await?)
|
||||
Ok(crate::models_ext::models_websocket_events(ctx.window.app_handle().clone(), &req.connection_id)?)
|
||||
}
|
||||
|
||||
async fn models_grpc_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGrpcEventsReq) -> Result<Vec<GrpcEvent>> {
|
||||
Ok(yaak_commands::models::models_grpc_events(ctx, req).await?)
|
||||
Ok(crate::models_ext::models_grpc_events(ctx.window.app_handle().clone(), &req.connection_id)?)
|
||||
}
|
||||
|
||||
async fn models_get_settings<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetSettingsReq) -> Result<Settings> {
|
||||
Ok(yaak_commands::models::models_get_settings(ctx, req).await?)
|
||||
async fn models_get_settings<R: Runtime>(ctx: ClientCtx<R>, _req: ModelsGetSettingsReq) -> Result<Settings> {
|
||||
Ok(crate::models_ext::models_get_settings(ctx.window.app_handle().clone())?)
|
||||
}
|
||||
|
||||
async fn models_get_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetGraphqlIntrospectionReq) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(yaak_commands::models::models_get_graphql_introspection(ctx, req).await?)
|
||||
Ok(crate::models_ext::models_get_graphql_introspection(ctx.window.app_handle().clone(), &req.request_id)?)
|
||||
}
|
||||
|
||||
async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertGraphqlIntrospectionReq) -> Result<GraphQlIntrospection> {
|
||||
Ok(yaak_commands::models::models_upsert_graphql_introspection(ctx, req).await?)
|
||||
Ok(crate::models_ext::models_upsert_graphql_introspection(ctx.window.app_handle().clone(), &req.request_id, &req.workspace_id, req.content, ctx.window.clone())?)
|
||||
}
|
||||
|
||||
/// Non-ASCII is escaped to `\uXXXX` before the JSON crosses into the webview:
|
||||
/// on Linux, sending Cyrillic (and possibly other) characters through this
|
||||
/// payload leaves every string in the parsed models subtly mis-encoded and
|
||||
/// CodeMirror unable to place the cursor (feedback: "editing the URL sometimes
|
||||
/// freezes the app"). Escape sequences sidestep it. This is a quirk of the
|
||||
/// webview transport, not of the data, so it lives in the adapter rather than
|
||||
/// the shared handler.
|
||||
async fn models_workspace_models<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWorkspaceModelsReq) -> Result<String> {
|
||||
let json = yaak_commands::models::models_workspace_models(ctx, req).await?;
|
||||
Ok(escape_str_for_webview(&json))
|
||||
}
|
||||
|
||||
fn escape_str_for_webview(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| {
|
||||
let code = c as u32;
|
||||
// ASCII
|
||||
if code <= 0x7F {
|
||||
c.to_string()
|
||||
// BMP characters encoded normally
|
||||
} else if code < 0xFFFF {
|
||||
format!("\\u{:04X}", code)
|
||||
// Beyond BMP encoded a surrogate pairs
|
||||
} else {
|
||||
let high = ((code - 0x10000) >> 10) + 0xD800;
|
||||
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
|
||||
format!("\\u{:04X}\\u{:04X}", high, low)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
Ok(crate::models_ext::models_workspace_models(ctx.window.clone(), req.workspace_id.as_deref(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_checkout<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitCheckoutReq) -> Result<String> {
|
||||
@@ -801,7 +538,7 @@ async fn cmd_sync_apply<R: Runtime>(ctx: ClientCtx<R>, req: CmdSyncApplyReq) ->
|
||||
}
|
||||
|
||||
async fn cmd_ws_delete_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsDeleteConnectionsReq) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_ws_delete_connections(ctx, req).await?)
|
||||
Ok(crate::ws_ext::cmd_ws_delete_connections(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_send<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsSendReq) -> Result<WebsocketConnection> {
|
||||
@@ -832,8 +569,8 @@ async fn cmd_plugins_uninstall<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsUni
|
||||
Ok(crate::plugins_ext::cmd_plugins_uninstall(&req.plugin_id, ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_init_errors<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
|
||||
Ok(yaak_commands::plugins::cmd_plugin_init_errors(ctx, req).await?)
|
||||
async fn cmd_plugin_init_errors<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
|
||||
Ok(crate::plugins_ext::cmd_plugin_init_errors(ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdatesReq) -> Result<PluginUpdatesResponse> {
|
||||
|
||||
@@ -18,7 +18,7 @@ use yaak_http::cookies::CookieStore;
|
||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||
use yaak_models::models::{
|
||||
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
|
||||
WebsocketEventType,
|
||||
WebsocketEventType, WebsocketRequest,
|
||||
};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||
@@ -27,9 +27,19 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
use yaak_commands::resolve::resolve_websocket_request;
|
||||
use yaak_ws::{WebsocketManager, render_websocket_request};
|
||||
|
||||
pub async fn cmd_ws_delete_connections<R: Runtime>(
|
||||
request_id: &str,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<()> {
|
||||
Ok(app_handle.db().delete_all_websocket_connections_for_request(
|
||||
request_id,
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_ws_send<R: Runtime>(
|
||||
connection_id: &str,
|
||||
environment_id: Option<&str>,
|
||||
@@ -76,7 +86,7 @@ async fn send_websocket_message<R: Runtime>(
|
||||
environment_id,
|
||||
)?;
|
||||
let (resolved_request, _auth_context_id) =
|
||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||
resolve_websocket_request(&window, &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
@@ -155,7 +165,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
|
||||
let settings = app_handle.db().get_settings();
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||
resolve_websocket_request(&window, &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
@@ -455,6 +465,23 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
/// Resolve inherited authentication and headers for a websocket request
|
||||
fn resolve_websocket_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &WebsocketRequest,
|
||||
) -> Result<(WebsocketRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_websocket_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let headers = window.db().resolve_headers_for_websocket_request(request)?;
|
||||
new_request.headers = headers;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
/// Convert WS URL to HTTP URL for cookie filtering
|
||||
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
|
||||
|
||||
@@ -7,4 +7,3 @@ publish = false
|
||||
[dependencies]
|
||||
tauri = { workspace = true }
|
||||
regex = "1.11.0"
|
||||
yaak-core = { workspace = true }
|
||||
|
||||
@@ -1,53 +1,38 @@
|
||||
use regex::Regex;
|
||||
use tauri::{Runtime, Url, WebviewWindow};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use tauri::{Runtime, WebviewWindow};
|
||||
|
||||
pub trait WorkspaceWindowTrait {
|
||||
fn workspace_id(&self) -> Option<String>;
|
||||
fn cookie_jar_id(&self) -> Option<String>;
|
||||
fn environment_id(&self) -> Option<String>;
|
||||
fn request_id(&self) -> Option<String>;
|
||||
/// All four at once, from a single read of the window URL.
|
||||
fn workspace_context(&self) -> WorkspaceContext;
|
||||
}
|
||||
|
||||
impl<R: Runtime> WorkspaceWindowTrait for WebviewWindow<R> {
|
||||
fn workspace_id(&self) -> Option<String> {
|
||||
workspace_id_from_url(&self.url().unwrap())
|
||||
let url = self.url().unwrap();
|
||||
let re = Regex::new(r"/workspaces/(?<id>\w+)").unwrap();
|
||||
match re.captures(url.as_str()) {
|
||||
None => None,
|
||||
Some(captures) => captures.name("id").map(|c| c.as_str().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cookie_jar_id(&self) -> Option<String> {
|
||||
query_param(&self.url().unwrap(), "cookie_jar_id")
|
||||
let url = self.url().unwrap();
|
||||
let mut query_pairs = url.query_pairs();
|
||||
query_pairs.find(|(k, _v)| k == "cookie_jar_id").map(|(_k, v)| v.to_string())
|
||||
}
|
||||
|
||||
fn environment_id(&self) -> Option<String> {
|
||||
query_param(&self.url().unwrap(), "environment_id")
|
||||
let url = self.url().unwrap();
|
||||
let mut query_pairs = url.query_pairs();
|
||||
query_pairs.find(|(k, _v)| k == "environment_id").map(|(_k, v)| v.to_string())
|
||||
}
|
||||
|
||||
fn request_id(&self) -> Option<String> {
|
||||
query_param(&self.url().unwrap(), "request_id")
|
||||
}
|
||||
|
||||
fn workspace_context(&self) -> WorkspaceContext {
|
||||
let url = self.url().unwrap();
|
||||
WorkspaceContext {
|
||||
workspace_id: workspace_id_from_url(&url),
|
||||
environment_id: query_param(&url, "environment_id"),
|
||||
cookie_jar_id: query_param(&url, "cookie_jar_id"),
|
||||
request_id: query_param(&url, "request_id"),
|
||||
}
|
||||
let mut query_pairs = url.query_pairs();
|
||||
query_pairs.find(|(k, _v)| k == "request_id").map(|(_k, v)| v.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_id_from_url(url: &Url) -> Option<String> {
|
||||
let re = Regex::new(r"/workspaces/(?<id>\w+)").unwrap();
|
||||
match re.captures(url.as_str()) {
|
||||
None => None,
|
||||
Some(captures) => captures.name("id").map(|c| c.as_str().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn query_param(url: &Url, key: &str) -> Option<String> {
|
||||
let mut query_pairs = url.query_pairs();
|
||||
query_pairs.find(|(k, _v)| k == key).map(|(_k, v)| v.to_string())
|
||||
}
|
||||
|
||||
@@ -9,20 +9,12 @@ chrono = { version = "0.4.38", features = ["serde"] }
|
||||
include_dir = "0.7"
|
||||
log = { workspace = true }
|
||||
nanoid = "0.4.0"
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
|
||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = { version = "0.25.0" }
|
||||
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
|
||||
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ts-rs = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = { version = "0.32" }
|
||||
|
||||
# nanoid pulls getrandom, which needs to be told how to reach the browser's
|
||||
# CSPRNG on wasm32-unknown-unknown. Native targets are unaffected.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
uuid = { version = "1", features = ["js"] }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::pool::SqliteConn;
|
||||
use r2d2::PooledConnection;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{Connection, Statement, ToSql, Transaction};
|
||||
|
||||
pub enum ConnectionOrTx<'a> {
|
||||
Connection(SqliteConn),
|
||||
Connection(PooledConnection<SqliteConnectionManager>),
|
||||
Transaction(&'a Transaction<'a>),
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::error::Error::ModelNotFound;
|
||||
use crate::error::Result;
|
||||
use crate::traits::UpsertModelInfo;
|
||||
use crate::update_source::UpdateSource;
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{
|
||||
Asterisk, Expr, Func, IntoColumnRef, IntoIden, OnConflict, Query, SimpleExpr,
|
||||
SqliteQueryBuilder,
|
||||
|
||||
@@ -7,7 +7,7 @@ pub enum Error {
|
||||
SqlError(#[from] rusqlite::Error),
|
||||
|
||||
#[error("SQL Pool error: {0}")]
|
||||
SqlPoolError(#[from] crate::pool::PoolError),
|
||||
SqlPoolError(#[from] r2d2::Error),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
@@ -2,7 +2,6 @@ pub mod connection_or_tx;
|
||||
pub mod db_context;
|
||||
pub mod error;
|
||||
pub mod migrate;
|
||||
pub mod pool;
|
||||
pub mod traits;
|
||||
pub mod update_source;
|
||||
pub mod util;
|
||||
@@ -12,15 +11,13 @@ pub use connection_or_tx::ConnectionOrTx;
|
||||
pub use db_context::DbContext;
|
||||
pub use error::{Error, Result};
|
||||
pub use migrate::run_migrations;
|
||||
pub use pool::{PoolError, SqliteConn, SqlitePool};
|
||||
pub use traits::{UpsertModelInfo, upsert_date};
|
||||
pub use update_source::{ModelChangeEvent, UpdateSource};
|
||||
pub use util::{generate_id, generate_id_of_length, generate_prefixed_id};
|
||||
|
||||
// Re-export types that consumers will need
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
// Re-export pool types that consumers will need
|
||||
pub use r2d2;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use r2d2_sqlite;
|
||||
pub use rusqlite;
|
||||
pub use sea_query;
|
||||
pub use sea_query_rusqlite;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::error::Result;
|
||||
use crate::pool::SqlitePool;
|
||||
use include_dir::Dir;
|
||||
use log::{debug, info};
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
const TRACKING_TABLE: &str = "_sqlx_migrations";
|
||||
@@ -10,7 +11,7 @@ const TRACKING_TABLE: &str = "_sqlx_migrations";
|
||||
///
|
||||
/// Migrations are sorted by filename (use timestamp prefixes like `00000001_init.sql`).
|
||||
/// Applied migrations are tracked in `_sqlx_migrations`.
|
||||
pub fn run_migrations(pool: &SqlitePool, dir: &Dir<'_>) -> Result<()> {
|
||||
pub fn run_migrations(pool: &Pool<SqliteConnectionManager>, dir: &Dir<'_>) -> Result<()> {
|
||||
info!("Running migrations");
|
||||
|
||||
// Create tracking table
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
//! Where connections come from.
|
||||
//!
|
||||
//! Every query in the model layer asks a pool for a connection, uses it, and
|
||||
//! hands it back. That is the whole contract, and it is the one place the
|
||||
//! desktop and the browser genuinely differ: the desktop has threads and wants
|
||||
//! an r2d2 pool; a browser tab has one thread, no way to spawn another, and one
|
||||
//! connection is exactly enough. Everything above this module is identical on
|
||||
//! both.
|
||||
//!
|
||||
//! On native targets `SqlitePool` *is* `r2d2::Pool` — a type alias, so nothing
|
||||
//! that already builds pools changes. On wasm it is one connection that every
|
||||
//! `get()` hands out a shared handle to.
|
||||
//!
|
||||
//! A `SqliteConn` only ever derefs immutably. The code above this layer opens
|
||||
//! transactions with [`rusqlite::Transaction::new_unchecked`], which takes
|
||||
//! `&Connection`; the `&mut` that `Connection::transaction` demands is a
|
||||
//! compile-time guard against nesting a transaction on one connection, and it
|
||||
//! is what would have forced the wasm pool to lend its connection exclusively.
|
||||
//! The model layer nests connections freely — a helper that already holds one
|
||||
//! calls another that asks for its own — so an exclusive lend would panic on
|
||||
//! the second ask. Sharing the handle instead makes nested *reads* work the way
|
||||
//! they do on the desktop; nested *write transactions* fail on both, only
|
||||
//! differently (here SQLite refuses the inner `BEGIN`; natively the inner
|
||||
//! connection blocks on `busy_timeout` and then fails).
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod imp {
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
|
||||
pub type SqlitePool = r2d2::Pool<SqliteConnectionManager>;
|
||||
pub type SqliteConn = r2d2::PooledConnection<SqliteConnectionManager>;
|
||||
pub type PoolError = r2d2::Error;
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod imp {
|
||||
use rusqlite::Connection;
|
||||
use std::ops::Deref;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// One connection, shared by everyone who asks.
|
||||
///
|
||||
/// `Rc` rather than `Arc` because a `Connection` is `!Sync`, so wrapping
|
||||
/// it in an `Arc` would buy no `Send`/`Sync` anyway — and there is one
|
||||
/// thread here to be honest about.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SqlitePool {
|
||||
conn: Rc<Connection>,
|
||||
}
|
||||
|
||||
impl SqlitePool {
|
||||
pub fn single(conn: Connection) -> Self {
|
||||
Self { conn: Rc::new(conn) }
|
||||
}
|
||||
|
||||
/// Another handle to the connection. Cannot fail; the `Result` keeps
|
||||
/// the signature identical to r2d2's so callers are written once.
|
||||
pub fn get(&self) -> Result<SqliteConn, PoolError> {
|
||||
Ok(SqliteConn(self.conn.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a `get()` would return if it could. It can't, so this has no
|
||||
/// variants; it exists so `Error::SqlPoolError` has the same shape on both
|
||||
/// targets.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PoolError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SqliteConn(Rc<Connection>);
|
||||
|
||||
impl Deref for SqliteConn {
|
||||
type Target = Connection;
|
||||
fn deref(&self) -> &Connection {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use imp::*;
|
||||
+1
-1
@@ -55,7 +55,7 @@ urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
|
||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "yaak-commands"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
yaak = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-rpc-schema = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
@@ -1,157 +0,0 @@
|
||||
//! The actions plugins contribute to the UI, and the calls that run them.
|
||||
//!
|
||||
//! Listing is a plain question for the plugin runtime. Calling is not: the
|
||||
//! frontend sends back the model it was showing, and a plugin must act on what
|
||||
//! that model *actually is* — re-read from the database, with inheritance
|
||||
//! resolved — not on a snapshot the UI has been holding. That re-reading is the
|
||||
//! work these handlers do.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::PluginHost;
|
||||
use crate::resolve::{resolve_grpc_request, resolve_http_request};
|
||||
use yaak_models::models::HttpRequest;
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
|
||||
CallWorkspaceActionRequest, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpRequestActionsResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
// -- Listing --
|
||||
|
||||
pub async fn cmd_http_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdHttpRequestActionsReq,
|
||||
) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
host.http_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_websocket_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdWebsocketRequestActionsReq,
|
||||
) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
host.websocket_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_grpc_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGrpcRequestActionsReq,
|
||||
) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
host.grpc_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_workspace_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdWorkspaceActionsReq,
|
||||
) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
host.workspace_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_folder_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdFolderActionsReq,
|
||||
) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
host.folder_actions().await
|
||||
}
|
||||
|
||||
// -- Calling --
|
||||
|
||||
pub async fn cmd_call_http_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallHttpRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let http_request = resolve_http_request(&host.db(), &inner.args.http_request)?.0;
|
||||
host.call_http_request_action(CallHttpRequestActionRequest {
|
||||
args: CallHttpRequestActionArgs { http_request },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_grpc_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallGrpcRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let grpc_request = resolve_grpc_request(&host.db(), &inner.args.grpc_request)?.0;
|
||||
host.call_grpc_request_action(CallGrpcRequestActionRequest {
|
||||
args: CallGrpcRequestActionArgs { grpc_request, ..inner.args },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_websocket_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallWebsocketRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let websocket_request = host.db().get_websocket_request(&inner.args.websocket_request.id)?;
|
||||
host.call_websocket_request_action(CallWebsocketRequestActionRequest {
|
||||
args: CallWebsocketRequestActionArgs { websocket_request },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_workspace_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallWorkspaceActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let workspace = host.db().get_workspace(&inner.args.workspace.id)?;
|
||||
host.call_workspace_action(CallWorkspaceActionRequest {
|
||||
args: CallWorkspaceActionArgs { workspace },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_folder_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallFolderActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let folder = host.db().get_folder(&inner.args.folder.id)?;
|
||||
host.call_folder_action(CallFolderActionRequest {
|
||||
args: CallFolderActionArgs { folder },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Other things the plugin runtime does --
|
||||
|
||||
/// Turn a `curl` command line into an unsaved request, by handing it to the
|
||||
/// same importer plugins that read files.
|
||||
pub async fn cmd_curl_to_request<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCurlToRequestReq,
|
||||
) -> Result<HttpRequest> {
|
||||
let imported = host.import_data(&req.command).await?;
|
||||
|
||||
let request = imported
|
||||
.resources
|
||||
.http_requests
|
||||
.first()
|
||||
.ok_or_else(|| Error::Generic("No curl command found".to_string()))?;
|
||||
|
||||
// Belongs to the workspace the user is importing into, and is not saved
|
||||
// until they say so — hence the blank id.
|
||||
let mut request = request.clone();
|
||||
request.workspace_id = req.workspace_id;
|
||||
request.id = String::new();
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Restart every plugin, returning whatever failed to come back up.
|
||||
pub async fn cmd_reload_plugins<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdReloadPluginsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let plugins = host.db().list_plugins()?;
|
||||
Ok(host.reload_plugins(plugins).await)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
//! Authentication config forms and their actions.
|
||||
//!
|
||||
//! Both commands here do the same preparation: the frontend sends the model
|
||||
//! whose auth is being edited plus the values currently in the form, and those
|
||||
//! values may contain templates. They have to be rendered against the model's
|
||||
//! own environment chain before a plugin sees them, or an auth plugin receives
|
||||
//! `${[ api_key ]}` where it expected a key.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_json_value;
|
||||
use std::collections::HashMap;
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_plugins::events::{
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::RenderOptions;
|
||||
|
||||
pub async fn cmd_get_http_authentication_summaries<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGetHttpAuthenticationSummariesReq,
|
||||
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
host.http_authentication_summaries().await
|
||||
}
|
||||
|
||||
pub async fn cmd_get_http_authentication_config<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdGetHttpAuthenticationConfigReq,
|
||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
// A config form is being displayed, so a template that cannot resolve
|
||||
// should show as blank rather than refuse to open the form.
|
||||
let values = render_auth_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
req.values,
|
||||
RenderPurpose::Preview,
|
||||
&RenderOptions::return_empty(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
host.http_authentication_config(&req.auth_name, values, req.model.id()).await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallHttpAuthenticationActionReq,
|
||||
) -> Result<()> {
|
||||
// An action actually uses these values, so an unresolvable template is an
|
||||
// error rather than an empty string that would silently authenticate wrong.
|
||||
let values = render_auth_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
req.values,
|
||||
RenderPurpose::Send,
|
||||
&RenderOptions::throw(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Render the form's values against the environment chain the model sits in.
|
||||
///
|
||||
/// The chain depends on where the model lives — a request inherits through its
|
||||
/// folder, a workspace has only its own — so the model is what decides which
|
||||
/// variables are in scope.
|
||||
async fn render_auth_values<H: PluginHost>(
|
||||
host: &H,
|
||||
model: &AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
purpose: RenderPurpose,
|
||||
options: &RenderOptions,
|
||||
) -> Result<HashMap<String, JsonPrimitive>> {
|
||||
let (workspace_id, folder_id) = match model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
other => {
|
||||
return Err(Error::Generic(format!(
|
||||
"Cannot resolve authentication for a {}",
|
||||
other.model()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||
|
||||
let cb = host.template_callback(purpose);
|
||||
let rendered =
|
||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||
|
||||
Ok(serde_json::from_value(rendered)?)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//! Export and formatting.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::Host;
|
||||
use std::path::Path;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::format_json::format_json;
|
||||
|
||||
pub async fn cmd_export_data<H: Host>(host: H, req: CmdExportDataReq) -> Result<()> {
|
||||
let version = host.app_version();
|
||||
Ok(export::export_data(ExportDataParams {
|
||||
query_manager: host.query_manager(),
|
||||
yaak_version: &version,
|
||||
export_path: Path::new(&req.export_path),
|
||||
workspace_ids: req.workspace_ids.iter().map(|s| s.as_str()).collect(),
|
||||
include_private_environments: req.include_private_environments,
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn cmd_format_json<H: Host>(_host: H, req: CmdFormatJsonReq) -> Result<String> {
|
||||
Ok(format_json(&req.text, " "))
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Workspace encryption keys and the `secure()` template function.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::{Host, PluginHost};
|
||||
use yaak_plugins::native_template_functions::decrypt_secure_template_function;
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
pub async fn cmd_enable_encryption<H: Host>(host: H, req: CmdEnableEncryptionReq) -> Result<()> {
|
||||
host.encryption_manager().ensure_workspace_key(&req.workspace_id)?;
|
||||
host.encryption_manager().reveal_workspace_key(&req.workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_reveal_workspace_key<H: Host>(
|
||||
host: H,
|
||||
req: CmdRevealWorkspaceKeyReq,
|
||||
) -> Result<String> {
|
||||
Ok(host.encryption_manager().reveal_workspace_key(&req.workspace_id)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_set_workspace_key<H: Host>(host: H, req: CmdSetWorkspaceKeyReq) -> Result<()> {
|
||||
host.encryption_manager().set_human_key(&req.workspace_id, &req.key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_disable_encryption<H: Host>(host: H, req: CmdDisableEncryptionReq) -> Result<()> {
|
||||
host.encryption_manager().disable_encryption(&req.workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_decrypt_template<H: Host>(host: H, req: CmdDecryptTemplateReq) -> Result<String> {
|
||||
let plugin_context = host.plugin_context();
|
||||
Ok(decrypt_secure_template_function(host.encryption_manager(), &plugin_context, &req.template)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_secure_template<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdSecureTemplateReq,
|
||||
) -> Result<String> {
|
||||
host.encrypt_secure_template(&req.template).await
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Yaak(#[from] yaak::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Model(#[from] yaak_models::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Plugin(#[from] yaak_plugins::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Crypto(#[from] yaak_crypto::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Template(#[from] yaak_templates::error::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("{0}")]
|
||||
Generic(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -1,223 +0,0 @@
|
||||
//! What a command needs from whatever is running it.
|
||||
//!
|
||||
//! A command handler is invoked on behalf of one client (a desktop window today)
|
||||
//! and needs a handful of things from its surroundings: the shared engine
|
||||
//! managers, who the client is, what the client is looking at, and a little
|
||||
//! about the app. `Host` is that handful and nothing more. The desktop
|
||||
//! implements it over a `WebviewWindow`; a server would implement it over a
|
||||
//! connection. Handlers are generic over it, so the same handler body runs
|
||||
//! under either without knowing which.
|
||||
//!
|
||||
//! The surface grows only when a handler being moved here needs something new,
|
||||
//! and stays as narrow as those handlers allow. What is deliberately *not* here
|
||||
//! is anything only a desktop can do — open a native window, run the updater,
|
||||
//! show a native dialog — those handlers stay with the desktop.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::blob_manager::{BlobContext, BlobManager};
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::Plugin;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
|
||||
PluginContext, RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_templates::TemplateCallback;
|
||||
|
||||
/// Only `Clone` is required here. `Send`/`Sync`/`'static` are deliberately
|
||||
/// *not*: a browser host is single-threaded and its connection pool is an
|
||||
/// `Rc<Connection>` — `rusqlite::Connection` is not `Sync` to begin with — so a
|
||||
/// thread-safety bound on the trait would lock that host out of implementing it
|
||||
/// at all. The router needs those bounds and states them itself, which is where
|
||||
/// they belong: they are a property of a particular transport, not of a command.
|
||||
pub trait Host: Clone {
|
||||
/// Stable identity of the client this call is for. On the desktop this is
|
||||
/// the window label. It rides on every model write so the client that made
|
||||
/// a change can tell its own echo from everyone else's.
|
||||
fn client_id(&self) -> &str;
|
||||
|
||||
/// What the client is currently looking at: workspace, environment, cookie
|
||||
/// jar, request. Read at call time, since the client can navigate between
|
||||
/// calls (and during one).
|
||||
fn session(&self) -> WorkspaceContext;
|
||||
|
||||
/// The app version, as reported to the Yaak API and stamped on exports.
|
||||
fn app_version(&self) -> String;
|
||||
|
||||
fn query_manager(&self) -> &QueryManager;
|
||||
fn blob_manager(&self) -> &BlobManager;
|
||||
fn encryption_manager(&self) -> &EncryptionManager;
|
||||
|
||||
// -- Conveniences derived from the above; hosts do not override these --
|
||||
|
||||
fn update_source(&self) -> UpdateSource {
|
||||
UpdateSource::from_window_label(self.client_id())
|
||||
}
|
||||
|
||||
fn plugin_context(&self) -> PluginContext {
|
||||
PluginContext::new(Some(self.client_id().to_string()), self.session().workspace_id)
|
||||
}
|
||||
|
||||
fn db(&self) -> ClientDb<'_> {
|
||||
self.query_manager().connect()
|
||||
}
|
||||
|
||||
fn blobs(&self) -> BlobContext {
|
||||
self.blob_manager().connect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A host that can also reach plugins.
|
||||
///
|
||||
/// Separate from [`Host`] so that a command which only touches the database
|
||||
/// never demands a plugin runtime it does not call: a host with no plugins
|
||||
/// still serves those, and only handlers bounded on `PluginHost` are closed to
|
||||
/// it.
|
||||
///
|
||||
/// These are *operations*, not a handle. Handing back a `&PluginManager` would
|
||||
/// have been shorter, but that type is specifically "spawn a Node sidecar and
|
||||
/// talk to it over a socket", and a browser host runs plugins in a Worker it
|
||||
/// reaches by message — it can answer any of the questions below and can never
|
||||
/// produce that type. Naming the questions instead of the answerer is what lets
|
||||
/// both hosts exist.
|
||||
///
|
||||
/// Same rule as [`Host`]: this grows only when a migrated handler needs
|
||||
/// something new, and stays as narrow as those handlers allow. Today it is the
|
||||
/// four things batch 1 asks for.
|
||||
///
|
||||
/// The types crossing this boundary still come from `yaak-plugins` — fine on
|
||||
/// the desktop, and once its plain data types are split out from its runtime
|
||||
/// that becomes an import-path change here rather than an interface one.
|
||||
pub trait PluginHost: Host {
|
||||
/// What the running plugin runtime knows about the plugin installed in
|
||||
/// `directory`, or `None` if it has not loaded one from there. Callers fall
|
||||
/// back to reading the plugin's manifest off disk.
|
||||
fn loaded_plugin_metadata(
|
||||
&self,
|
||||
directory: &str,
|
||||
) -> impl Future<Output = Option<PluginMetadata>>;
|
||||
|
||||
/// Failures from plugin initialization, drained — reporting them clears
|
||||
/// them, so a caller that drops these has lost them.
|
||||
fn take_plugin_init_errors(&self) -> impl Future<Output = Vec<(String, String)>>;
|
||||
|
||||
/// The plugin rows as the runtime sees them: the database says what is
|
||||
/// installed, the runtime knows which are bundled and what version actually
|
||||
/// loaded. A host without a runtime can return them untouched.
|
||||
fn resolve_plugins(&self, plugins: Vec<Plugin>) -> impl Future<Output = Vec<Plugin>>;
|
||||
|
||||
/// The template functions this host can run, as a callback the renderer
|
||||
/// drives. This is the *only* thing the plugin runtime uniquely provides to
|
||||
/// a render — the variables come from the environment chain, which is an
|
||||
/// ordinary database read — so handing back the callback keeps the rest of
|
||||
/// rendering shared instead of pushing whole commands behind this trait.
|
||||
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback;
|
||||
|
||||
/// Every template function the installed plugins expose, for the
|
||||
/// autocomplete menu.
|
||||
fn template_function_summaries(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetTemplateFunctionSummaryResponse>>>;
|
||||
|
||||
/// The form a template function wants to show for the given values.
|
||||
fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<GetTemplateFunctionConfigResponse>>;
|
||||
|
||||
/// Themes contributed by plugins.
|
||||
fn themes(&self) -> impl Future<Output = crate::Result<Vec<GetThemesResponse>>>;
|
||||
|
||||
// -- Actions plugins contribute to the UI --
|
||||
|
||||
fn http_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetHttpRequestActionsResponse>>>;
|
||||
fn websocket_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetWebsocketRequestActionsResponse>>>;
|
||||
fn grpc_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetGrpcRequestActionsResponse>>>;
|
||||
fn workspace_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetWorkspaceActionsResponse>>>;
|
||||
fn folder_actions(&self) -> impl Future<Output = crate::Result<Vec<GetFolderActionsResponse>>>;
|
||||
|
||||
/// Running an action. The request in each of these has already been
|
||||
/// re-read and had its inheritance resolved by the handler; a host must
|
||||
/// pass it through untouched.
|
||||
fn call_http_request_action(
|
||||
&self,
|
||||
req: CallHttpRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_grpc_request_action(
|
||||
&self,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_websocket_request_action(
|
||||
&self,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_workspace_action(
|
||||
&self,
|
||||
req: CallWorkspaceActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_folder_action(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
|
||||
// -- Authentication --
|
||||
|
||||
fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetHttpAuthenticationSummaryResponse>>>;
|
||||
|
||||
/// The form an auth plugin wants to show. `values` arrive already rendered.
|
||||
fn http_authentication_config(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<GetHttpAuthenticationConfigResponse>>;
|
||||
|
||||
fn call_http_authentication_action(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
|
||||
// -- The importers, and the runtime itself --
|
||||
|
||||
/// Hand arbitrary text to the importer plugins and take what they make of
|
||||
/// it. Used for files, URLs and pasted `curl` commands alike.
|
||||
fn import_data(&self, content: &str) -> impl Future<Output = crate::Result<ImportResponse>>;
|
||||
|
||||
/// Restart every plugin, returning `(plugin, error)` for those that failed.
|
||||
fn reload_plugins(&self, plugins: Vec<Plugin>) -> impl Future<Output = Vec<(String, String)>>;
|
||||
|
||||
/// Re-encrypt the `secure(...)` values in a template.
|
||||
///
|
||||
/// Whole operation rather than its pieces because the encryption is only
|
||||
/// half of it: the value is also run through the plugin template functions,
|
||||
/// so this needs the plugin runtime and not just a key.
|
||||
fn encrypt_secure_template(
|
||||
&self,
|
||||
template: &str,
|
||||
) -> impl Future<Output = crate::Result<String>>;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//! Command handlers for the RPC surface, written against [`Host`] instead of
|
||||
//! any particular host.
|
||||
//!
|
||||
//! `yaak_rpc_schema` declares what each command is called and what it takes
|
||||
//! and returns; this crate is where the bodies live. Every handler has the
|
||||
//! shape the router wants — `async fn(host, Req) -> Result<Res>` — so a host
|
||||
//! registers one with a one-line adapter (or none at all), and never
|
||||
//! redeclares a command.
|
||||
//!
|
||||
//! Not every command is here yet. Handlers move in as they are freed of
|
||||
//! host-specific types; the ones that stay behind are the ones only a desktop
|
||||
//! can serve (native windows, the updater, dialogs) or that still lean on it.
|
||||
|
||||
pub mod actions;
|
||||
pub mod auth;
|
||||
pub mod data;
|
||||
pub mod encryption;
|
||||
pub mod error;
|
||||
pub mod host;
|
||||
pub mod models;
|
||||
pub mod plugins;
|
||||
pub mod render;
|
||||
pub mod resolve;
|
||||
pub mod responses;
|
||||
pub mod templates;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use host::{Host, PluginHost};
|
||||
@@ -1,179 +0,0 @@
|
||||
//! Reads and writes of models, keyed by the client's identity so the frontend
|
||||
//! can suppress its own echoes.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::{Host, PluginHost};
|
||||
use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, Settings, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
pub async fn models_upsert<H: Host>(host: H, req: ModelsUpsertReq) -> Result<String> {
|
||||
let db = host.db();
|
||||
let blobs = host.blob_manager();
|
||||
let source = host.update_source();
|
||||
Ok(yaak_models::models_ops::upsert_model(&db, blobs, req.model, &source)?)
|
||||
}
|
||||
|
||||
/// Deletes cascade — a workspace can hold thousands of requests — and run in a
|
||||
/// transaction, which holds a raw connection for the duration.
|
||||
///
|
||||
/// Whether that wants a blocking thread is the *host's* question, not the
|
||||
/// delete's: a desktop with a multi-threaded runtime should keep this off the
|
||||
/// runtime (see its adapter), while a single-threaded host has nothing to move
|
||||
/// it to and runs it here. So this is the plain version, and a host that wants
|
||||
/// to relocate it calls [`models_delete_blocking`] itself.
|
||||
pub async fn models_delete<H: Host>(host: H, req: ModelsDeleteReq) -> Result<String> {
|
||||
models_delete_blocking(&host, req)
|
||||
}
|
||||
|
||||
/// The body of [`models_delete`], callable from a blocking context.
|
||||
pub fn models_delete_blocking<H: Host>(host: &H, req: ModelsDeleteReq) -> Result<String> {
|
||||
let source = host.update_source();
|
||||
Ok(host.query_manager().with_tx(|tx| {
|
||||
yaak_models::models_ops::delete_model(tx, host.blob_manager(), req.model, &source)
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Duplicates recurse, so this runs in a transaction too.
|
||||
pub async fn models_duplicate<H: Host>(host: H, req: ModelsDuplicateReq) -> Result<String> {
|
||||
let source = host.update_source();
|
||||
Ok(host.query_manager().with_tx(|tx| {
|
||||
yaak_models::models_ops::duplicate_model(tx, &req.model_type, &req.model_id, &source)
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn models_websocket_events<H: Host>(
|
||||
host: H,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(host.db().list_websocket_events(&req.connection_id)?)
|
||||
}
|
||||
|
||||
pub async fn models_grpc_events<H: Host>(
|
||||
host: H,
|
||||
req: ModelsGrpcEventsReq,
|
||||
) -> Result<Vec<GrpcEvent>> {
|
||||
Ok(host.db().list_grpc_events(&req.connection_id)?)
|
||||
}
|
||||
|
||||
pub async fn models_get_settings<H: Host>(host: H, _req: ModelsGetSettingsReq) -> Result<Settings> {
|
||||
Ok(host.db().get_settings())
|
||||
}
|
||||
|
||||
pub async fn models_get_graphql_introspection<H: Host>(
|
||||
host: H,
|
||||
req: ModelsGetGraphqlIntrospectionReq,
|
||||
) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(host.db().get_graphql_introspection(&req.request_id))
|
||||
}
|
||||
|
||||
pub async fn models_upsert_graphql_introspection<H: Host>(
|
||||
host: H,
|
||||
req: ModelsUpsertGraphqlIntrospectionReq,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
let source = host.update_source();
|
||||
Ok(host.db().upsert_graphql_introspection(
|
||||
&req.workspace_id,
|
||||
&req.request_id,
|
||||
req.content,
|
||||
&source,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Everything the frontend's model store needs to boot, as one JSON string.
|
||||
///
|
||||
/// A string rather than a `Vec<AnyModel>` because the desktop has to escape
|
||||
/// this payload before it crosses into the webview (see its adapter), and the
|
||||
/// frontend `JSON.parse`s either form the same way.
|
||||
pub async fn models_workspace_models<H: PluginHost>(
|
||||
host: H,
|
||||
req: ModelsWorkspaceModelsReq,
|
||||
) -> Result<String> {
|
||||
let mut l: Vec<AnyModel> = Vec::new();
|
||||
|
||||
// Add the global models
|
||||
{
|
||||
let db = host.db();
|
||||
l.push(db.get_settings().into());
|
||||
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
let plugins = {
|
||||
let db = host.db();
|
||||
db.list_plugins()?
|
||||
};
|
||||
|
||||
let plugins = host.resolve_plugins(plugins).await;
|
||||
l.append(&mut plugins.into_iter().map(Into::into).collect());
|
||||
|
||||
// Add the workspace children
|
||||
if let Some(wid) = req.workspace_id.as_deref() {
|
||||
let db = host.db();
|
||||
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
Ok(serde_json::to_string(&l)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_get_workspace_meta<H: Host>(
|
||||
host: H,
|
||||
req: CmdGetWorkspaceMetaReq,
|
||||
) -> Result<WorkspaceMeta> {
|
||||
let db = host.db();
|
||||
let workspace = db.get_workspace(&req.workspace_id)?;
|
||||
Ok(db.get_or_create_workspace_meta(&workspace.id)?)
|
||||
}
|
||||
|
||||
pub async fn cmd_delete_all_grpc_connections<H: Host>(
|
||||
host: H,
|
||||
req: CmdDeleteAllGrpcConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(host.db().delete_all_grpc_connections_for_request(&req.request_id, &host.update_source())?)
|
||||
}
|
||||
|
||||
pub async fn cmd_delete_all_http_responses<H: Host>(
|
||||
host: H,
|
||||
req: CmdDeleteAllHttpResponsesReq,
|
||||
) -> Result<()> {
|
||||
host.db().delete_all_http_responses_for_request(&req.request_id, &host.update_source())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_ws_delete_connections<H: Host>(
|
||||
host: H,
|
||||
req: CmdWsDeleteConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(host
|
||||
.db()
|
||||
.delete_all_websocket_connections_for_request(&req.request_id, &host.update_source())?)
|
||||
}
|
||||
|
||||
pub async fn cmd_delete_send_history<H: Host>(host: H, req: CmdDeleteSendHistoryReq) -> Result<()> {
|
||||
Ok(host.query_manager().with_tx(|tx| {
|
||||
let source = &host.update_source();
|
||||
tx.delete_all_http_responses_for_workspace(&req.workspace_id, source)?;
|
||||
tx.delete_all_grpc_connections_for_workspace(&req.workspace_id, source)?;
|
||||
tx.delete_all_websocket_connections_for_workspace(&req.workspace_id, source)?;
|
||||
Ok::<(), yaak_models::error::Error>(())
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn cmd_default_headers<H: Host>(
|
||||
_host: H,
|
||||
_req: CmdDefaultHeadersReq,
|
||||
) -> Result<Vec<HttpRequestHeader>> {
|
||||
Ok(default_headers())
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//! Plugin queries: what the runtime has loaded, and what failed to load.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::PluginHost;
|
||||
use std::path::PathBuf;
|
||||
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
pub async fn cmd_plugin_info<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdPluginInfoReq,
|
||||
) -> Result<PluginMetadata> {
|
||||
let plugin = host.db().get_plugin(&req.id)?;
|
||||
if let Some(metadata) = host.loaded_plugin_metadata(&plugin.directory).await {
|
||||
return Ok(metadata);
|
||||
}
|
||||
|
||||
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
|
||||
return Ok(metadata);
|
||||
}
|
||||
|
||||
Ok(fallback_plugin_metadata(&plugin.directory))
|
||||
}
|
||||
|
||||
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
|
||||
let display_name = PathBuf::from(directory)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(directory)
|
||||
.to_string();
|
||||
|
||||
PluginMetadata {
|
||||
version: "Unavailable".to_string(),
|
||||
name: directory.to_string(),
|
||||
display_name,
|
||||
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
|
||||
homepage_url: None,
|
||||
repository_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cmd_plugin_init_errors<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdPluginInitErrorsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(host.take_plugin_init_errors().await)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//! Rendering a template against an environment chain.
|
||||
//!
|
||||
//! The variables come from the chain, the functions come from the host's
|
||||
//! template callback. Neither of these knows which host it is running under —
|
||||
//! that is the whole point of taking the callback as a parameter.
|
||||
|
||||
use serde_json::Value;
|
||||
use yaak_models::models::Environment;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
pub async fn render_template<T: TemplateCallback>(
|
||||
template: &str,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
parse_and_render(template, vars, cb, opt).await
|
||||
}
|
||||
|
||||
pub async fn render_json_value<T: TemplateCallback>(
|
||||
value: Value,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<Value> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//! Filling in what a request inherits from its folders and workspace.
|
||||
//!
|
||||
//! A request stored in the database records only what is set *on it*;
|
||||
//! authentication and headers can come from any ancestor. Anything that acts on
|
||||
//! a request as the user sees it — sending it, handing it to a plugin — has to
|
||||
//! resolve that chain first, which is why this is shared rather than living
|
||||
//! next to any one caller.
|
||||
|
||||
use crate::error::Result;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::{GrpcRequest, HttpRequest, WebsocketRequest};
|
||||
|
||||
/// The request with inherited auth and headers filled in, plus the id of the
|
||||
/// model the authentication was inherited *from* — plugins key their token
|
||||
/// caches on it, so it must be the ancestor's id and not the request's.
|
||||
pub fn resolve_http_request(db: &ClientDb, request: &HttpRequest) -> Result<(HttpRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_http_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.headers = db.resolve_headers_for_http_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub fn resolve_grpc_request(db: &ClientDb, request: &GrpcRequest) -> Result<(GrpcRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_grpc_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.metadata = db.resolve_metadata_for_grpc_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub fn resolve_websocket_request(
|
||||
db: &ClientDb,
|
||||
request: &WebsocketRequest,
|
||||
) -> Result<(WebsocketRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_websocket_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.headers = db.resolve_headers_for_websocket_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
//! Reading back what a send left behind: response events, request bodies, and
|
||||
//! where a response body lives.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::Host;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::HttpResponseEvent;
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
/// Where a response's body is, and what it is meant to be read as.
|
||||
pub struct ResponseBodyLocation {
|
||||
/// None when the response has no stored body.
|
||||
pub path: Option<PathBuf>,
|
||||
/// The response's declared `Content-Type`, empty when it has none.
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
/// Find a response's body from its id alone.
|
||||
///
|
||||
/// The frontend hands back an id and never a path, so the only bodies reachable
|
||||
/// here are ones the engine wrote and the database still knows about. A
|
||||
/// response that was never saved has no entry, and its body came back from the
|
||||
/// send that made it.
|
||||
pub fn locate_response_body(db: &ClientDb, response_id: &str) -> Result<ResponseBodyLocation> {
|
||||
let response = db.get_http_response(response_id)?;
|
||||
|
||||
Ok(ResponseBodyLocation {
|
||||
path: response.body_path.map(PathBuf::from),
|
||||
content_type: response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
|
||||
.map(|h| h.value.clone())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn cmd_get_http_response_events<H: Host>(
|
||||
host: H,
|
||||
req: CmdGetHttpResponseEventsReq,
|
||||
) -> Result<Vec<HttpResponseEvent>> {
|
||||
let events: Vec<HttpResponseEvent> = host.db().list_http_response_events(&req.response_id)?;
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// The body's path on this machine, for the desktop host to read or hand to the
|
||||
/// webview's asset protocol.
|
||||
///
|
||||
/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
|
||||
/// the path, and only because it is about to open the file itself. Hosts
|
||||
/// without a filesystem serve the same bytes over HTTP instead.
|
||||
pub async fn cmd_http_response_body_path<H: Host>(
|
||||
host: H,
|
||||
req: CmdHttpResponseBodyPathReq,
|
||||
) -> Result<Option<String>> {
|
||||
let location = locate_response_body(&host.db(), &req.response_id)?;
|
||||
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
pub async fn cmd_http_request_body<H: Host>(
|
||||
host: H,
|
||||
req: CmdHttpRequestBodyReq,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let body_id = format!("{}.request", req.response_id);
|
||||
let chunks = host.blobs().get_chunks(&body_id)?;
|
||||
|
||||
if chunks.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Concatenate all chunks
|
||||
let body: Vec<u8> = chunks.into_iter().flat_map(|c| c.data).collect();
|
||||
Ok(Some(body))
|
||||
}
|
||||
|
||||
pub async fn cmd_save_response<H: Host>(host: H, req: CmdSaveResponseReq) -> Result<()> {
|
||||
let response = host.db().get_http_response(&req.response_id)?;
|
||||
|
||||
let body_path =
|
||||
response.body_path.ok_or(Error::Generic("Response does not have a body".to_string()))?;
|
||||
fs::copy(body_path, &req.filepath).map_err(|e| Error::Generic(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//! Templates, the functions plugins put in them, and themes.
|
||||
//!
|
||||
//! Everything here needs the plugin runtime, but only for the one thing it
|
||||
//! uniquely provides: running a template function. Resolving the environment
|
||||
//! chain and deciding what a render should do about errors are ordinary work
|
||||
//! and stay here, where every host gets them the same.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_template;
|
||||
use yaak_plugins::events::{
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions, transform_args};
|
||||
|
||||
pub async fn cmd_render_template<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdRenderTemplateReq,
|
||||
) -> Result<String> {
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
|
||||
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview));
|
||||
let options = RenderOptions {
|
||||
// A preview that throws would show the user an error where they expect
|
||||
// to see the value so far, so callers rendering *into the UI* ask for
|
||||
// empties instead.
|
||||
error_behavior: match req.ignore_error {
|
||||
Some(true) => RenderErrorBehavior::ReturnEmpty,
|
||||
_ => RenderErrorBehavior::Throw,
|
||||
},
|
||||
};
|
||||
Ok(render_template(&req.template, environment_chain, &cb, &options).await?)
|
||||
}
|
||||
|
||||
/// Render only the *arguments* of a template's function calls, leaving the
|
||||
/// calls themselves intact. This is what turns a parsed template back into
|
||||
/// something displayable without evaluating it.
|
||||
pub async fn cmd_template_tokens_to_string<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateTokensToStringReq,
|
||||
) -> Result<String> {
|
||||
let cb = host.template_callback(RenderPurpose::Preview);
|
||||
Ok(transform_args(req.tokens, &cb)?.to_string())
|
||||
}
|
||||
|
||||
pub async fn cmd_template_function_summaries<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdTemplateFunctionSummariesReq,
|
||||
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
host.template_function_summaries().await
|
||||
}
|
||||
|
||||
pub async fn cmd_template_function_config<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateFunctionConfigReq,
|
||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
host.template_function_config(&req.function_name, req.values, req.model.id()).await
|
||||
}
|
||||
|
||||
pub async fn cmd_get_themes<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGetThemesReq,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
host.themes().await
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
//! A host that is nothing but the trait: a temp database, a fixed client id,
|
||||
//! a fixed session. It exists to prove that the handlers really do run without
|
||||
//! a desktop around them, and that the client's identity reaches the writes.
|
||||
//!
|
||||
//! Neither host here has a plugin runtime — no `PluginManager`, no sidecar.
|
||||
//! `TestHost` implements `Host` alone, so a handler that reaches for plugins
|
||||
//! would not compile against it. `SingleThreadedHost` goes further and answers
|
||||
//! `PluginHost` too, without one, which is only possible because that trait
|
||||
//! names operations rather than handing back a manager.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
use yaak_commands::auth::cmd_get_http_authentication_config;
|
||||
use yaak_commands::models::{
|
||||
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
|
||||
models_workspace_models,
|
||||
};
|
||||
use yaak_commands::templates::cmd_render_template;
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{AnyModel, Environment, EnvironmentVariable, Plugin, Workspace};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_rpc_schema::{
|
||||
CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, CmdRenderTemplateReq, ModelsDeleteReq,
|
||||
ModelsUpsertReq, ModelsWorkspaceModelsReq,
|
||||
};
|
||||
use yaak_templates::TemplateCallback;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestHost {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
_dir: TempDir,
|
||||
query_manager: QueryManager,
|
||||
blob_manager: BlobManager,
|
||||
encryption_manager: EncryptionManager,
|
||||
/// Every model write the database reported, so a test can check who it
|
||||
/// says made them.
|
||||
writes: Mutex<Vec<ModelPayload>>,
|
||||
rx: Mutex<std::sync::mpsc::Receiver<ModelPayload>>,
|
||||
}
|
||||
|
||||
impl TestHost {
|
||||
fn new() -> Self {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let (query_manager, blob_manager, rx) = yaak_models::init_standalone(
|
||||
dir.path().join("db.sqlite"),
|
||||
dir.path().join("blobs.sqlite"),
|
||||
)
|
||||
.expect("init db");
|
||||
let encryption_manager = EncryptionManager::new(query_manager.clone(), "app.yaak.test");
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
_dir: dir,
|
||||
query_manager,
|
||||
blob_manager,
|
||||
encryption_manager,
|
||||
writes: Mutex::new(Vec::new()),
|
||||
rx: Mutex::new(rx),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_writes(&self) -> Vec<ModelPayload> {
|
||||
let rx = self.inner.rx.lock().unwrap();
|
||||
let mut writes = self.inner.writes.lock().unwrap();
|
||||
while let Ok(payload) = rx.try_recv() {
|
||||
writes.push(payload);
|
||||
}
|
||||
writes.drain(..).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Host for TestHost {
|
||||
fn client_id(&self) -> &str {
|
||||
"test-client"
|
||||
}
|
||||
|
||||
fn session(&self) -> WorkspaceContext {
|
||||
WorkspaceContext::new().with_workspace("wk_test")
|
||||
}
|
||||
|
||||
fn app_version(&self) -> String {
|
||||
"0.0.0-test".to_string()
|
||||
}
|
||||
|
||||
fn query_manager(&self) -> &QueryManager {
|
||||
&self.inner.query_manager
|
||||
}
|
||||
|
||||
fn blob_manager(&self) -> &BlobManager {
|
||||
&self.inner.blob_manager
|
||||
}
|
||||
|
||||
fn encryption_manager(&self) -> &EncryptionManager {
|
||||
&self.inner.encryption_manager
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn writes_carry_the_client_id() {
|
||||
let host = TestHost::new();
|
||||
|
||||
let workspace = Workspace { name: "From a test".to_string(), ..Default::default() };
|
||||
let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) })
|
||||
.await
|
||||
.expect("upsert");
|
||||
assert!(id.starts_with("wk_"), "unexpected id {id}");
|
||||
|
||||
let writes = host.drain_writes();
|
||||
assert_eq!(writes.len(), 1);
|
||||
assert!(
|
||||
matches!(&writes[0].update_source, UpdateSource::Window { label } if label == "test-client"),
|
||||
"the write should be attributed to the calling client, got {:?}",
|
||||
writes[0].update_source,
|
||||
);
|
||||
|
||||
let meta =
|
||||
cmd_get_workspace_meta(host.clone(), CmdGetWorkspaceMetaReq { workspace_id: id.clone() })
|
||||
.await
|
||||
.expect("workspace meta");
|
||||
assert_eq!(meta.workspace_id, id);
|
||||
|
||||
// Deletes cascade inside a transaction; make sure that path works with no
|
||||
// host doing anything special around it.
|
||||
let workspace = host.db().get_workspace(&id).expect("get workspace");
|
||||
let deleted =
|
||||
models_delete(host.clone(), ModelsDeleteReq { model: AnyModel::Workspace(workspace) })
|
||||
.await
|
||||
.expect("delete");
|
||||
assert_eq!(deleted, id);
|
||||
assert!(host.db().get_workspace(&id).is_err(), "workspace should be gone");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_free_handlers_need_no_state() {
|
||||
let host = TestHost::new();
|
||||
let headers = cmd_default_headers(host, CmdDefaultHeadersReq {}).await.expect("headers");
|
||||
assert!(!headers.is_empty());
|
||||
}
|
||||
|
||||
/// A host that is deliberately **not** `Send` or `Sync`: it keeps its state in
|
||||
/// an `Rc`, the way a single-threaded browser host has to, since
|
||||
/// `rusqlite::Connection` is not `Sync` to begin with. It also has no plugin
|
||||
/// runtime of any kind — no `PluginManager`, no sidecar, nothing to spawn.
|
||||
///
|
||||
/// Nothing here asserts much at runtime; the test is largely that it compiles.
|
||||
/// A `Host` demanding thread-safety, or a `PluginHost` handing back a
|
||||
/// `&PluginManager`, would shut such a host out of the traits entirely and this
|
||||
/// file would stop building.
|
||||
#[derive(Clone)]
|
||||
struct SingleThreadedHost {
|
||||
inner: Rc<Inner>,
|
||||
/// The values the last auth-config call arrived with, so a test can check
|
||||
/// they were rendered before the host ever saw them.
|
||||
auth_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||
}
|
||||
|
||||
impl Host for SingleThreadedHost {
|
||||
fn client_id(&self) -> &str {
|
||||
"tab-1"
|
||||
}
|
||||
|
||||
fn session(&self) -> WorkspaceContext {
|
||||
WorkspaceContext::new()
|
||||
}
|
||||
|
||||
fn app_version(&self) -> String {
|
||||
"0.0.0-web".to_string()
|
||||
}
|
||||
|
||||
fn query_manager(&self) -> &QueryManager {
|
||||
&self.inner.query_manager
|
||||
}
|
||||
|
||||
fn blob_manager(&self) -> &BlobManager {
|
||||
&self.inner.blob_manager
|
||||
}
|
||||
|
||||
fn encryption_manager(&self) -> &EncryptionManager {
|
||||
&self.inner.encryption_manager
|
||||
}
|
||||
}
|
||||
|
||||
/// A template callback with no plugins behind it: variables still resolve,
|
||||
/// function calls have nothing to run them. A browser host would put a Worker
|
||||
/// round-trip where this returns an error.
|
||||
struct NoTemplateFunctions;
|
||||
|
||||
impl TemplateCallback for NoTemplateFunctions {
|
||||
async fn run(
|
||||
&self,
|
||||
fn_name: &str,
|
||||
_args: HashMap<String, serde_json::Value>,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
Err(yaak_templates::error::Error::RenderError(format!(
|
||||
"no plugin runtime to run {fn_name}()"
|
||||
)))
|
||||
}
|
||||
|
||||
fn transform_arg(
|
||||
&self,
|
||||
_fn_name: &str,
|
||||
_arg_name: &str,
|
||||
arg_value: &str,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
Ok(arg_value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Answering plugin questions with no plugin runtime behind them. A browser
|
||||
/// host would put a `postMessage` round-trip to its Worker where these return
|
||||
/// constants; the shape of the trait is what makes either possible.
|
||||
impl PluginHost for SingleThreadedHost {
|
||||
async fn loaded_plugin_metadata(&self, _directory: &str) -> Option<PluginMetadata> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
|
||||
// No runtime to enrich them with; the database rows are still the truth
|
||||
// about what is installed.
|
||||
plugins
|
||||
}
|
||||
|
||||
async fn encrypt_secure_template(&self, _template: &str) -> yaak_commands::Result<String> {
|
||||
Err(yaak_commands::Error::Generic("no plugin runtime on this host".into()))
|
||||
}
|
||||
|
||||
fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback {
|
||||
NoTemplateFunctions
|
||||
}
|
||||
|
||||
async fn template_function_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
_values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||
Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()")))
|
||||
}
|
||||
|
||||
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
// No plugins, so nothing contributes actions and nothing can run one.
|
||||
|
||||
async fn http_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn websocket_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn grpc_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn call_http_request_action(
|
||||
&self,
|
||||
_req: CallHttpRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_grpc_request_action(
|
||||
&self,
|
||||
_req: CallGrpcRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_websocket_request_action(
|
||||
&self,
|
||||
_req: CallWebsocketRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_workspace_action(
|
||||
&self,
|
||||
_req: CallWorkspaceActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_folder_action(&self, _req: CallFolderActionRequest) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn http_authentication_config(
|
||||
&self,
|
||||
_auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
|
||||
*self.auth_values.borrow_mut() = Some(values);
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_http_authentication_action(
|
||||
&self,
|
||||
_auth_name: &str,
|
||||
_action_index: i32,
|
||||
_values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn import_data(&self, _content: &str) -> yaak_commands::Result<ImportResponse> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn reload_plugins(&self, _plugins: Vec<Plugin>) -> Vec<(String, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn no_plugins() -> yaak_commands::Error {
|
||||
yaak_commands::Error::Generic("no plugin runtime on this host".into())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_single_threaded_host_can_implement_the_trait() {
|
||||
let TestHost { inner } = TestHost::new();
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
|
||||
let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) })
|
||||
.await
|
||||
.expect("upsert");
|
||||
|
||||
// A `PluginHost` command, on a host with no plugin runtime at all. This is
|
||||
// the one that could not be written when the trait handed back a
|
||||
// `&PluginManager`.
|
||||
let json = models_workspace_models(
|
||||
host.clone(),
|
||||
ModelsWorkspaceModelsReq { workspace_id: Some(id.clone()) },
|
||||
)
|
||||
.await
|
||||
.expect("workspace models");
|
||||
assert!(json.contains(&id), "the workspace should be in its own bootstrap payload");
|
||||
|
||||
// Rendering, on a host whose template callback has no plugins behind it.
|
||||
// Resolving the environment chain is a database read and the render is
|
||||
// shared code; only the callback came from the host. Rendering a real
|
||||
// variable is what proves the chain was resolved rather than skipped.
|
||||
let environment = host
|
||||
.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: id.clone(),
|
||||
name: "Test env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "greeting".to_string(),
|
||||
value: "hello".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("seed environment");
|
||||
|
||||
let rendered = cmd_render_template(
|
||||
host.clone(),
|
||||
CmdRenderTemplateReq {
|
||||
template: "${[ greeting ]} world".to_string(),
|
||||
workspace_id: id.clone(),
|
||||
environment_id: Some(environment.id.clone()),
|
||||
purpose: None,
|
||||
ignore_error: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("render");
|
||||
assert_eq!(rendered, "hello world", "the environment chain should have been resolved");
|
||||
|
||||
// The delete path too, since it is the one that used to reach for a
|
||||
// blocking thread this host does not have.
|
||||
let workspace = host.db().get_workspace(&id).expect("get workspace");
|
||||
let deleted = models_delete(host, ModelsDeleteReq { model: AnyModel::Workspace(workspace) })
|
||||
.await
|
||||
.expect("delete");
|
||||
assert_eq!(deleted, id);
|
||||
}
|
||||
|
||||
/// Auth form values may contain templates, and a plugin must never see one
|
||||
/// unrendered. The rendering happens in the shared handler, so this checks the
|
||||
/// host received a resolved value rather than `${[ ... ]}`.
|
||||
#[tokio::test]
|
||||
async fn auth_values_are_rendered_before_the_host_sees_them() {
|
||||
let TestHost { inner } = TestHost::new();
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = host
|
||||
.db()
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Auth".to_string(), ..Default::default() },
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("workspace");
|
||||
host.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "token".to_string(),
|
||||
value: "s3cret".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("environment");
|
||||
let environment =
|
||||
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
|
||||
|
||||
let mut values = HashMap::new();
|
||||
values.insert("password".to_string(), JsonPrimitive::String("${[ token ]}".to_string()));
|
||||
|
||||
// The host refuses the call itself — it has no plugins — but only after the
|
||||
// handler has rendered and handed over the values, which is what matters.
|
||||
let _ = cmd_get_http_authentication_config(
|
||||
host.clone(),
|
||||
yaak_rpc_schema::CmdGetHttpAuthenticationConfigReq {
|
||||
auth_name: "basic".to_string(),
|
||||
values,
|
||||
model: AnyModel::Workspace(workspace),
|
||||
environment_id: Some(environment.id),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let seen = host.auth_values.borrow().clone().expect("the host should have been called");
|
||||
assert!(
|
||||
matches!(seen.get("password"), Some(JsonPrimitive::String(v)) if v == "s3cret"),
|
||||
"the template should have been rendered before reaching the host, got {:?}",
|
||||
seen.get("password"),
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Context for a workspace operation.
|
||||
///
|
||||
/// In Tauri, this is extracted from the WebviewWindow URL.
|
||||
@@ -35,3 +37,20 @@ impl WorkspaceContext {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Application context trait for accessing app-level resources.
|
||||
///
|
||||
/// This abstracts over Tauri's `AppHandle` for path resolution and app identity.
|
||||
/// Implemented by Tauri's AppHandle and by CLI's own context struct.
|
||||
pub trait AppContext: Send + Sync + Clone {
|
||||
/// Returns the path to the application data directory.
|
||||
/// This is where the database and other persistent data are stored.
|
||||
fn app_data_dir(&self) -> PathBuf;
|
||||
|
||||
/// Returns the application identifier (e.g., "app.yaak.desktop").
|
||||
/// Used for keyring access and other platform-specific features.
|
||||
fn app_identifier(&self) -> &str;
|
||||
|
||||
/// Returns true if running in development mode.
|
||||
fn is_dev(&self) -> bool;
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
mod context;
|
||||
mod error;
|
||||
|
||||
pub use context::WorkspaceContext;
|
||||
pub use context::{AppContext, WorkspaceContext};
|
||||
pub use error::{Error, Result};
|
||||
|
||||
@@ -4,9 +4,7 @@ use log::{debug, info, warn};
|
||||
use reqwest::{Client, ClientBuilder, Proxy, redirect};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use yaak_models::models::DnsOverride;
|
||||
use yaak_tls::{
|
||||
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
|
||||
};
|
||||
use yaak_tls::{ClientCertificateConfig, get_tls_config, load_client_identity_pkcs12};
|
||||
|
||||
pub const HTTP2_MAX_RESPONSE_HEADER_LIST_SIZE: u32 = 1024 * 1024;
|
||||
|
||||
@@ -63,19 +61,12 @@ static IDENTITY_IMPORT: Mutex<()> = Mutex::new(());
|
||||
fn build_native_tls_identity(
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
) -> Result<Option<native_tls::Identity>> {
|
||||
let Some(material) = load_native_client_identity(client_cert)? else {
|
||||
let Some((pkcs12, password)) = load_client_identity_pkcs12(client_cert)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let _guard = IDENTITY_IMPORT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
Ok(Some(match material {
|
||||
NativeClientIdentity::Pkcs12 { data, password } => {
|
||||
native_tls::Identity::from_pkcs12(&data, &password)?
|
||||
}
|
||||
NativeClientIdentity::Pkcs8 { chain_pem, key_pem } => {
|
||||
native_tls::Identity::from_pkcs8(&chain_pem, &key_pem)?
|
||||
}
|
||||
}))
|
||||
Ok(Some(native_tls::Identity::from_pkcs12(&pkcs12, &password)?))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -11,9 +11,11 @@ hex = { workspace = true }
|
||||
include_dir = "0.7"
|
||||
log = { workspace = true }
|
||||
nanoid = "0.4.0"
|
||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
||||
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = { version = "0.25.0" }
|
||||
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
|
||||
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
|
||||
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
@@ -21,7 +23,3 @@ sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
|
||||
yaak-core = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
r2d2 = "0.8.10"
|
||||
r2d2_sqlite = { version = "0.32" }
|
||||
|
||||
+1
@@ -225,6 +225,7 @@ export type HttpResponse = {
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
bodyPath: string | null;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
|
||||
@@ -2,8 +2,9 @@ use crate::error::Result;
|
||||
use crate::util::generate_prefixed_id;
|
||||
use include_dir::{Dir, include_dir};
|
||||
use log::{debug, info};
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use yaak_database::{SqliteConn, SqlitePool};
|
||||
|
||||
static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations");
|
||||
|
||||
@@ -28,11 +29,11 @@ impl BodyChunk {
|
||||
// whole app whenever the pool is exhausted.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlobManager {
|
||||
pool: SqlitePool,
|
||||
pool: Pool<SqliteConnectionManager>,
|
||||
}
|
||||
|
||||
impl BlobManager {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ impl BlobManager {
|
||||
|
||||
/// Context for blob database operations.
|
||||
pub struct BlobContext {
|
||||
conn: SqliteConn,
|
||||
conn: r2d2::PooledConnection<SqliteConnectionManager>,
|
||||
}
|
||||
|
||||
impl BlobContext {
|
||||
@@ -130,7 +131,7 @@ impl BlobContext {
|
||||
}
|
||||
|
||||
/// Run migrations for the blob database.
|
||||
pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
|
||||
pub fn migrate_blob_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
|
||||
info!("Running blob database migrations");
|
||||
|
||||
// Create migrations tracking table
|
||||
@@ -197,9 +198,9 @@ pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_pool() -> SqlitePool {
|
||||
let manager = r2d2_sqlite::SqliteConnectionManager::memory();
|
||||
let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap();
|
||||
fn create_test_pool() -> Pool<SqliteConnectionManager> {
|
||||
let manager = SqliteConnectionManager::memory();
|
||||
let pool = Pool::builder().max_size(1).build(manager).unwrap();
|
||||
migrate_blob_db(&pool).unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ pub enum Error {
|
||||
SqlError(#[from] rusqlite::Error),
|
||||
|
||||
#[error("SQL Pool error: {0}")]
|
||||
SqlPoolError(#[from] yaak_database::PoolError),
|
||||
SqlPoolError(#[from] r2d2::Error),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(String),
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use crate::blob_manager::{BlobManager, migrate_blob_db};
|
||||
use crate::error::Result;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::migrate::migrate_db;
|
||||
use crate::query_manager::QueryManager;
|
||||
use crate::util::ModelPayload;
|
||||
use log::info;
|
||||
use std::path::Path;
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use std::fs::create_dir_all;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use yaak_database::SqlitePool;
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod blob_manager;
|
||||
pub mod client_db;
|
||||
@@ -14,85 +17,22 @@ mod connection_or_tx;
|
||||
pub mod error;
|
||||
pub mod migrate;
|
||||
pub mod models;
|
||||
pub mod models_ops;
|
||||
pub mod queries;
|
||||
pub mod query_manager;
|
||||
pub mod render;
|
||||
pub mod util;
|
||||
|
||||
/// Per-connection setup, applied by every pool on every connection it opens.
|
||||
fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
conn.busy_timeout(std::time::Duration::from_millis(5000))
|
||||
fn sqlite_file_manager(path: impl Into<PathBuf>) -> SqliteConnectionManager {
|
||||
SqliteConnectionManager::file(path.into()).with_init(|conn| {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.busy_timeout(Duration::from_millis(5000))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn init_file_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
init_connection(conn)
|
||||
}
|
||||
|
||||
/// The two ways a pool comes to exist, one per target.
|
||||
///
|
||||
/// On the desktop and CLI, an r2d2 pool over a file. In a browser, a single
|
||||
/// connection over whatever VFS the host registered before calling in — the
|
||||
/// path is a name inside that VFS, not a place on disk. Everything downstream
|
||||
/// of `SqlitePool` is target-agnostic; this is the only fork.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod open {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn file_pool(path: impl Into<PathBuf>, max_size: u32, min_idle: u32) -> Result<SqlitePool> {
|
||||
let path: PathBuf = path.into();
|
||||
// Create parent directories if needed
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let manager = SqliteConnectionManager::file(path).with_init(|c| init_file_connection(c));
|
||||
Pool::builder()
|
||||
.max_size(max_size)
|
||||
.min_idle(Some(min_idle))
|
||||
.connection_timeout(Duration::from_secs(10))
|
||||
.build(manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn memory_pool() -> Result<SqlitePool> {
|
||||
let manager = SqliteConnectionManager::memory().with_init(|c| init_connection(c));
|
||||
// In-memory DB doesn't support multiple connections
|
||||
Pool::builder().max_size(1).build(manager).map_err(|e| Error::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod open {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn file_pool(
|
||||
path: impl Into<PathBuf>,
|
||||
_max_size: u32,
|
||||
_min_idle: u32,
|
||||
) -> Result<SqlitePool> {
|
||||
// No WAL: the browser VFSs are single-connection and journal their own
|
||||
// way; the pragma is accepted and ignored on some and rejected on
|
||||
// others, so it is not applied at all here.
|
||||
let conn = Connection::open(path.into())?;
|
||||
init_connection(&conn)?;
|
||||
Ok(SqlitePool::single(conn))
|
||||
}
|
||||
|
||||
pub fn memory_pool() -> Result<SqlitePool> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
init_connection(&conn)?;
|
||||
Ok(SqlitePool::single(conn))
|
||||
}
|
||||
fn sqlite_memory_manager() -> SqliteConnectionManager {
|
||||
SqliteConnectionManager::memory()
|
||||
.with_init(|conn| conn.busy_timeout(Duration::from_millis(5000)))
|
||||
}
|
||||
|
||||
/// Initialize the database managers for standalone (non-Tauri) usage.
|
||||
@@ -106,16 +46,40 @@ pub fn init_standalone(
|
||||
let db_path = db_path.as_ref();
|
||||
let blob_path = blob_path.as_ref();
|
||||
|
||||
// Create parent directories if needed
|
||||
if let Some(parent) = db_path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
if let Some(parent) = blob_path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Main database pool. Sized for concurrent in-flight queries, not concurrent app
|
||||
// features — connections are held per-statement, so even heavy fan-out (e.g. many
|
||||
// gRPC streams) only needs a handful at once. Keep max_size modest: WAL connections
|
||||
// hold ~3 file descriptors each, and macOS GUI apps get a 256 fd soft limit.
|
||||
info!("Initializing app database {db_path:?}");
|
||||
let pool = open::file_pool(db_path, 20, 2)?;
|
||||
let manager = sqlite_file_manager(db_path);
|
||||
let pool = Pool::builder()
|
||||
.max_size(20)
|
||||
.min_idle(Some(2))
|
||||
.connection_timeout(Duration::from_secs(10))
|
||||
.build(manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
migrate_db(&pool)?;
|
||||
|
||||
info!("Initializing blobs database {blob_path:?}");
|
||||
let blob_pool = open::file_pool(blob_path, 10, 1)?;
|
||||
|
||||
// Blob database pool
|
||||
let blob_manager = sqlite_file_manager(blob_path);
|
||||
let blob_pool = Pool::builder()
|
||||
.max_size(10)
|
||||
.min_idle(Some(1))
|
||||
.connection_timeout(Duration::from_secs(10))
|
||||
.build(blob_manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
migrate_blob_db(&blob_pool)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
@@ -128,10 +92,22 @@ pub fn init_standalone(
|
||||
/// Initialize the database managers with in-memory SQLite databases.
|
||||
/// Useful for testing and CI environments.
|
||||
pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
|
||||
let pool = open::memory_pool()?;
|
||||
// Main database pool
|
||||
let manager = sqlite_memory_manager();
|
||||
let pool = Pool::builder()
|
||||
.max_size(1) // In-memory DB doesn't support multiple connections
|
||||
.build(manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
migrate_db(&pool)?;
|
||||
|
||||
let blob_pool = open::memory_pool()?;
|
||||
// Blob database pool
|
||||
let blob_manager = sqlite_memory_manager();
|
||||
let blob_pool = Pool::builder()
|
||||
.max_size(1)
|
||||
.build(blob_manager)
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
migrate_blob_db(&blob_pool)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
@@ -2,13 +2,14 @@ use crate::error::Error::MigrationError;
|
||||
use crate::error::Result;
|
||||
use include_dir::{Dir, DirEntry, include_dir};
|
||||
use log::{debug, info};
|
||||
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
||||
use sha2::{Digest, Sha384};
|
||||
use yaak_database::SqlitePool;
|
||||
|
||||
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
|
||||
|
||||
pub fn migrate_db(pool: &SqlitePool) -> Result<()> {
|
||||
pub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {
|
||||
info!("Running database migrations");
|
||||
|
||||
// Ensure the table exists
|
||||
@@ -42,10 +43,8 @@ pub fn migrate_db(pool: &SqlitePool) -> Result<()> {
|
||||
let mut ran_migrations = 0;
|
||||
for entry in entries {
|
||||
num_migrations += 1;
|
||||
let conn = pool.get()?;
|
||||
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
|
||||
// the pool never hands out `&mut`.
|
||||
let mut tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)?;
|
||||
let mut conn = pool.get()?;
|
||||
let mut tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
match run_migration(entry, &mut tx) {
|
||||
Ok(ran) => {
|
||||
if ran {
|
||||
@@ -75,7 +74,7 @@ pub fn migrate_db(pool: &SqlitePool) -> Result<()> {
|
||||
}
|
||||
|
||||
fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> Result<bool> {
|
||||
let start = elapsed_timer();
|
||||
let start = std::time::Instant::now();
|
||||
let (version, description) = split_migration_filename(migration_path.path().to_str().unwrap())
|
||||
.expect("Failed to parse migration filename");
|
||||
|
||||
@@ -98,7 +97,7 @@ fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> R
|
||||
// Split on `;`? → optional depending on how your SQL is structured
|
||||
tx.execute_batch(&sql)?;
|
||||
|
||||
let execution_time = start();
|
||||
let execution_time = start.elapsed().as_nanos() as i64;
|
||||
let checksum = sha384_hex_prefixed(sql.as_bytes());
|
||||
|
||||
// NOTE: The success column is never used. It's just there for sqlx compatibility.
|
||||
@@ -110,21 +109,6 @@ fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> R
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Nanoseconds since the timer was started, for the sqlx-compatible
|
||||
/// `execution_time` column. `Instant` does not exist on `wasm32-unknown-unknown`
|
||||
/// (there is no monotonic clock to ask), and the column is bookkeeping, so
|
||||
/// there it reads as zero rather than taking the migrator down with it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn elapsed_timer() -> impl Fn() -> i64 {
|
||||
let start = std::time::Instant::now();
|
||||
move || start.elapsed().as_nanos() as i64
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn elapsed_timer() -> impl Fn() -> i64 {
|
||||
|| 0
|
||||
}
|
||||
|
||||
fn split_migration_filename(filename: &str) -> Option<(String, String)> {
|
||||
// Remove the .sql extension
|
||||
let trimmed = filename.strip_suffix(".sql")?;
|
||||
|
||||
@@ -1677,13 +1677,6 @@ pub struct HttpResponse {
|
||||
pub workspace_id: String,
|
||||
pub request_id: String,
|
||||
|
||||
/// Where the engine put the body, when it puts it in a file.
|
||||
///
|
||||
/// Not exported to TypeScript: a path is only meaningful to a host that
|
||||
/// has the filesystem it names, and bodies are moving off it. Read a body
|
||||
/// by response id instead — the frontend through
|
||||
/// `cmd_http_response_body_path`, plugins through `ctx.httpResponse.body`.
|
||||
#[ts(skip)]
|
||||
pub body_path: Option<String>,
|
||||
pub content_length: Option<i32>,
|
||||
pub content_length_compressed: Option<i32>,
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::error::Result;
|
||||
use crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden};
|
||||
use crate::util::UpdateSource;
|
||||
use chrono::{Duration, Utc};
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::models::{GrpcConnection, GrpcConnectionIden, GrpcConnectionState};
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
use crate::util::UpdateSource;
|
||||
use log::debug;
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
use crate::util::UpdateSource;
|
||||
use log::{debug, error};
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
use std::fs;
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::models::{KeyValue, KeyValueIden, UpsertModelInfo};
|
||||
use crate::util::UpdateSource;
|
||||
use chrono::NaiveDateTime;
|
||||
use log::error;
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{PluginKeyValue, PluginKeyValueIden};
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::Keyword::CurrentTimestamp;
|
||||
use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{SyncState, SyncStateIden, UpsertModelInfo};
|
||||
use crate::util::UpdateSource;
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::models::{WebsocketConnection, WebsocketConnectionIden, WebsocketConne
|
||||
use crate::queries::MAX_HISTORY_ITEMS;
|
||||
use crate::util::UpdateSource;
|
||||
use log::debug;
|
||||
use sea_query::ExprTrait;
|
||||
use sea_query::{Expr, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::util::ModelPayload;
|
||||
use rusqlite::{Transaction, TransactionBehavior};
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::TransactionBehavior;
|
||||
use std::sync::mpsc;
|
||||
use yaak_database::{ConnectionOrTx, DbContext, SqlitePool};
|
||||
use yaak_database::{ConnectionOrTx, DbContext};
|
||||
|
||||
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
|
||||
// blocking `get()` serializes every DB access behind the slowest waiter, freezing the
|
||||
// whole app whenever the pool is exhausted.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QueryManager {
|
||||
pool: SqlitePool,
|
||||
pool: Pool<SqliteConnectionManager>,
|
||||
events_tx: mpsc::Sender<ModelPayload>,
|
||||
}
|
||||
|
||||
impl QueryManager {
|
||||
pub fn new(pool: SqlitePool, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
||||
pub fn new(pool: Pool<SqliteConnectionManager>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
||||
QueryManager { pool, events_tx }
|
||||
}
|
||||
|
||||
@@ -44,10 +46,9 @@ impl QueryManager {
|
||||
where
|
||||
E: From<crate::error::Error>,
|
||||
{
|
||||
let conn = self.pool.get().expect("Failed to get new DB connection from the pool");
|
||||
// `new_unchecked` takes `&Connection`; see yaak_database::pool for why
|
||||
// the pool never hands out `&mut`.
|
||||
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
|
||||
let mut conn = self.pool.get().expect("Failed to get new DB connection from the pool");
|
||||
let tx = conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.expect("Failed to start DB transaction");
|
||||
|
||||
let ctx = DbContext::new(ConnectionOrTx::Transaction(&tx));
|
||||
|
||||
+2
-58
File diff suppressed because one or more lines are too long
+1
@@ -224,6 +224,7 @@ export type HttpResponse = {
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
bodyPath: string | null;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
|
||||
@@ -171,12 +171,6 @@ pub enum InternalEventPayload {
|
||||
|
||||
FindHttpResponsesRequest(FindHttpResponsesRequest),
|
||||
FindHttpResponsesResponse(FindHttpResponsesResponse),
|
||||
|
||||
GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest),
|
||||
GetHttpResponseBodyInfoResponse(GetHttpResponseBodyInfoResponse),
|
||||
ReadHttpResponseBodyChunkRequest(ReadHttpResponseBodyChunkRequest),
|
||||
ReadHttpResponseBodyChunkResponse(ReadHttpResponseBodyChunkResponse),
|
||||
|
||||
ListHttpRequestsRequest(ListHttpRequestsRequest),
|
||||
ListHttpRequestsResponse(ListHttpRequestsResponse),
|
||||
ListFoldersRequest(ListFoldersRequest),
|
||||
@@ -294,15 +288,6 @@ pub struct SendHttpRequestRequest {
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct SendHttpRequestResponse {
|
||||
pub http_response: HttpResponse,
|
||||
|
||||
/// The body, base64, when the send saved nothing.
|
||||
///
|
||||
/// A request with no id behind it produces a response the model store never
|
||||
/// sees, so it cannot be read back by id later the way a saved one can.
|
||||
/// This is the only copy of it. `None` means the body was stored and should
|
||||
/// be read with `read_http_response_body_chunk_request`.
|
||||
#[ts(optional = nullable)]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
@@ -1428,67 +1413,6 @@ pub struct FindHttpResponsesResponse {
|
||||
pub http_responses: Vec<HttpResponse>,
|
||||
}
|
||||
|
||||
/// Ask what a response's body is, before deciding whether to pull it.
|
||||
///
|
||||
/// Bodies are addressed by response id and never by path, so where the host
|
||||
/// keeps the bytes is its own business.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct GetHttpResponseBodyInfoRequest {
|
||||
pub response_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct GetHttpResponseBodyInfoResponse {
|
||||
/// How many bytes are stored right now, which is not necessarily what the
|
||||
/// `Content-Length` header claimed. Zero when the response has no body.
|
||||
#[ts(type = "number")]
|
||||
pub content_length: u64,
|
||||
|
||||
/// Whether the response has finished arriving. While it has not, the body
|
||||
/// keeps growing past `content_length`, and a reader that wants all of it
|
||||
/// asks again.
|
||||
pub complete: bool,
|
||||
|
||||
/// The response's `Content-Type` header, verbatim, so the reader can pick a
|
||||
/// charset.
|
||||
#[ts(optional = nullable)]
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Pull one window of a response body.
|
||||
///
|
||||
/// Reads are idempotent: the bytes live in durable storage, so the same window
|
||||
/// can be asked for as many times as the plugin likes.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct ReadHttpResponseBodyChunkRequest {
|
||||
pub response_id: String,
|
||||
#[ts(type = "number")]
|
||||
pub offset: u64,
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct ReadHttpResponseBodyChunkResponse {
|
||||
/// Base64, because the desktop transport is a WebSocket that only sends
|
||||
/// text frames today. A host that can carry binary sends the bytes as they
|
||||
/// are and fills this in from them.
|
||||
pub data: String,
|
||||
|
||||
/// Bytes decoded from `data`. Short of the requested length means the body
|
||||
/// ended here.
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
|
||||
@@ -7,7 +7,6 @@ publish = false
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
p12 = "0.6.3"
|
||||
pem = "3"
|
||||
rustls = { workspace = true, default-features = false, features = ["ring"] }
|
||||
rustls-pemfile = "2"
|
||||
rustls-platform-verifier = { workspace = true }
|
||||
|
||||
+11
-129
@@ -18,7 +18,7 @@ pub mod error;
|
||||
const OID_RSA_ENCRYPTION: &[u64] = &[1, 2, 840, 113549, 1, 1, 1];
|
||||
const OID_EC_PUBLIC_KEY: &[u64] = &[1, 2, 840, 10045, 2, 1];
|
||||
|
||||
/// Password for the PKCS#12 blob [`load_native_client_identity`] builds from PEM
|
||||
/// Password for the PKCS#12 blob [`load_client_identity_pkcs12`] builds from PEM
|
||||
/// files. The blob never leaves the process, so the value only has to agree with
|
||||
/// the caller that immediately re-parses it.
|
||||
const IN_MEMORY_PKCS12_PASSWORD: &str = "yaak";
|
||||
@@ -107,33 +107,16 @@ fn load_client_cert(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// A client identity in one of the encodings a native TLS stack accepts.
|
||||
pub enum NativeClientIdentity {
|
||||
/// A PKCS#12 archive, with the password needed to open it.
|
||||
Pkcs12 { data: Vec<u8>, password: String },
|
||||
/// A PEM certificate chain, leaf first, with a PKCS#8 PEM private key.
|
||||
Pkcs8 {
|
||||
chain_pem: Vec<u8>,
|
||||
key_pem: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Whether the platform's native TLS stack should be handed PEM material as
|
||||
/// PKCS#12 rather than PKCS#8.
|
||||
/// Load the configured client certificate as PKCS#12 DER, along with the
|
||||
/// password needed to open it.
|
||||
///
|
||||
/// Both encodings lose something. PKCS#8 is rejected for EC keys by Security
|
||||
/// Framework on macOS and by SChannel on Windows, which imports keys through an
|
||||
/// RSA-only provider. PKCS#12 as the `p12` crate emits it is encrypted with
|
||||
/// SHA1/40-bit-RC2 (certificates) and SHA1/3DES (key), and OpenSSL 3 moved RC2
|
||||
/// into the legacy provider, so on Linux it fails to decrypt what we just
|
||||
/// wrote. Each platform therefore gets the encoding its own stack can read.
|
||||
const NATIVE_TLS_WANTS_PKCS12: bool = cfg!(any(target_vendor = "apple", target_os = "windows"));
|
||||
|
||||
/// Load the configured client certificate in whichever encoding this platform's
|
||||
/// native TLS stack accepts.
|
||||
pub fn load_native_client_identity(
|
||||
/// Native TLS stacks accept a client identity as either PKCS#12 or a PKCS#8
|
||||
/// PEM, and the PKCS#8 route rejects EC keys on macOS outright. Going through
|
||||
/// PKCS#12 keeps the key formats we accept identical to the rustls path, which
|
||||
/// reads PKCS#1 and SEC1 keys directly.
|
||||
pub fn load_client_identity_pkcs12(
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
) -> Result<Option<NativeClientIdentity>> {
|
||||
) -> Result<Option<(Vec<u8>, String)>> {
|
||||
let config = match client_cert {
|
||||
None => return Ok(None),
|
||||
Some(c) => c,
|
||||
@@ -144,10 +127,7 @@ pub fn load_native_client_identity(
|
||||
if let Some(pfx_path) = &config.pfx_file {
|
||||
if !pfx_path.is_empty() {
|
||||
let data = fs::read(Path::new(pfx_path))?;
|
||||
return Ok(Some(NativeClientIdentity::Pkcs12 {
|
||||
data,
|
||||
password: config.passphrase.clone().unwrap_or_default(),
|
||||
}));
|
||||
return Ok(Some((data, config.passphrase.clone().unwrap_or_default())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,35 +136,13 @@ pub fn load_native_client_identity(
|
||||
};
|
||||
|
||||
let key_der = to_pkcs8_der(&key)?;
|
||||
|
||||
if !NATIVE_TLS_WANTS_PKCS12 {
|
||||
return Ok(Some(to_pkcs8_identity(&certs, &key_der)));
|
||||
}
|
||||
|
||||
let (leaf, cas) = certs.split_first().ok_or(GenericError("No certificates found".into()))?;
|
||||
let cas: Vec<&[u8]> = cas.iter().map(|c| c.as_ref()).collect();
|
||||
|
||||
let pfx = p12::PFX::new_with_cas(leaf, &key_der, &cas, IN_MEMORY_PKCS12_PASSWORD, "yaak")
|
||||
.ok_or(GenericError("Failed to build PKCS#12 from client certificate".into()))?;
|
||||
|
||||
Ok(Some(NativeClientIdentity::Pkcs12 {
|
||||
data: pfx.to_der(),
|
||||
password: IN_MEMORY_PKCS12_PASSWORD.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Re-encode a certificate chain and PKCS#8 key as the PEM pair native-tls
|
||||
/// expects. It only recognises a key whose first line is the PKCS#8 header, so
|
||||
/// the key has to arrive already converted by [`to_pkcs8_der`].
|
||||
fn to_pkcs8_identity(certs: &[CertificateDer<'static>], key_der: &[u8]) -> NativeClientIdentity {
|
||||
let config = pem::EncodeConfig::new().set_line_ending(pem::LineEnding::LF);
|
||||
let chain: Vec<pem::Pem> =
|
||||
certs.iter().map(|c| pem::Pem::new("CERTIFICATE", c.as_ref())).collect();
|
||||
|
||||
NativeClientIdentity::Pkcs8 {
|
||||
chain_pem: pem::encode_many_config(&chain, config).into_bytes(),
|
||||
key_pem: pem::encode_config(&pem::Pem::new("PRIVATE KEY", key_der), config).into_bytes(),
|
||||
}
|
||||
Ok(Some((pfx.to_der(), IN_MEMORY_PKCS12_PASSWORD.to_string())))
|
||||
}
|
||||
|
||||
/// Re-encode a private key as PKCS#8 DER, wrapping PKCS#1 and SEC1 keys.
|
||||
@@ -421,79 +379,3 @@ pub fn find_client_certificate(
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pkcs8_identity_tests {
|
||||
use super::*;
|
||||
|
||||
const EC_CRT: &str = r#"-----BEGIN CERTIFICATE-----
|
||||
MIIBhTCCASugAwIBAgIUB8703dqXCUOJQbhbyaMUMbVFOjwwCgYIKoZIzj0EAwIw
|
||||
FzEVMBMGA1UEAwwMeWFhay10ZXN0LWVjMCAXDTI2MDgxNDIwNDYyNFoYDzIxMjYw
|
||||
NzIxMjA0NjI0WjAXMRUwEwYDVQQDDAx5YWFrLXRlc3QtZWMwWTATBgcqhkjOPQIB
|
||||
BggqhkjOPQMBBwNCAATCYYKhzgHEaRaGsYVjJSoXvoroL8qe1yeEA0VtfxFzMBg+
|
||||
+bkPQ0nCtMyFfvQQtXWYIakxzsWJyhI8wPjUj6QSo1MwUTAdBgNVHQ4EFgQUKq40
|
||||
Hl+2DziVkBVR/tGsPj9FRo0wHwYDVR0jBBgwFoAUKq40Hl+2DziVkBVR/tGsPj9F
|
||||
Ro0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAj1dx5XLl9iCZ
|
||||
rD0CW+a3RTluxQ5icXno9WJ9qaS6L08CIFx2t0y9znQr7n5x+SmfXbfZtkDola8e
|
||||
8nEZga/HXSeu
|
||||
-----END CERTIFICATE-----"#;
|
||||
|
||||
const EC_SEC1_KEY: &str = r#"-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIIoiiZ/hb4h6eHkZUVBTQFz7KLrVKJqQtWee2ygOjijNoAoGCCqGSM49
|
||||
AwEHoUQDQgAEwmGCoc4BxGkWhrGFYyUqF76K6C/KntcnhANFbX8RczAYPvm5D0NJ
|
||||
wrTMhX70ELV1mCGpMc7FicoSPMD41I+kEg==
|
||||
-----END EC PRIVATE KEY-----"#;
|
||||
|
||||
const EC_PKCS8_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgiiKJn+FviHp4eRlR
|
||||
UFNAXPsoutUompC1Z57bKA6OKM2hRANCAATCYYKhzgHEaRaGsYVjJSoXvoroL8qe
|
||||
1yeEA0VtfxFzMBg++bkPQ0nCtMyFfvQQtXWYIakxzsWJyhI8wPjUj6QS
|
||||
-----END PRIVATE KEY-----"#;
|
||||
|
||||
fn pkcs8_identity(crt: &str, key: &str) -> (Vec<u8>, Vec<u8>) {
|
||||
let certs: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut BufReader::new(crt.as_bytes()))
|
||||
.map(|c| c.unwrap())
|
||||
.collect();
|
||||
let key_der = to_pkcs8_der(&load_private_key(key.as_bytes()).unwrap()).unwrap();
|
||||
|
||||
match to_pkcs8_identity(&certs, &key_der) {
|
||||
NativeClientIdentity::Pkcs8 { chain_pem, key_pem } => (chain_pem, key_pem),
|
||||
NativeClientIdentity::Pkcs12 { .. } => unreachable!("asked for PKCS#8"),
|
||||
}
|
||||
}
|
||||
|
||||
/// native-tls matches the PKCS#8 header as a literal prefix and rejects the
|
||||
/// key outright when it does not line up, so pin it on every platform even
|
||||
/// though only the OpenSSL backend is handed this encoding.
|
||||
#[test]
|
||||
fn every_key_format_re_encodes_to_a_pkcs8_pem() {
|
||||
for (name, key) in [("SEC1", EC_SEC1_KEY), ("PKCS#8", EC_PKCS8_KEY)] {
|
||||
let (chain_pem, key_pem) = pkcs8_identity(EC_CRT, key);
|
||||
|
||||
assert!(
|
||||
key_pem.starts_with(b"-----BEGIN PRIVATE KEY-----\n"),
|
||||
"{name} key did not re-encode to a PKCS#8 PEM"
|
||||
);
|
||||
|
||||
let round_tripped: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut BufReader::new(chain_pem.as_slice()))
|
||||
.map(|c| c.unwrap())
|
||||
.collect();
|
||||
let original: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut BufReader::new(EC_CRT.as_bytes()))
|
||||
.map(|c| c.unwrap())
|
||||
.collect();
|
||||
assert_eq!(round_tripped, original, "{name} chain did not round-trip");
|
||||
}
|
||||
}
|
||||
|
||||
/// The two on-disk spellings of one EC key have to converge, because only
|
||||
/// the PKCS#8 one survives the re-encode.
|
||||
#[test]
|
||||
fn sec1_and_pkcs8_spellings_of_one_key_agree() {
|
||||
let (_, from_sec1) = pkcs8_identity(EC_CRT, EC_SEC1_KEY);
|
||||
let (_, from_pkcs8) = pkcs8_identity(EC_CRT, EC_PKCS8_KEY);
|
||||
assert_eq!(from_sec1, from_pkcs8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
[package]
|
||||
name = "yaak-web"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# The desktop's model layer, compiled for a browser tab. See src/lib.rs.
|
||||
#
|
||||
# Building needs a clang with a WebAssembly backend: sqlite-wasm-rs compiles
|
||||
# sqlite3.c to wasm at build time, and Apple's clang cannot target it. See
|
||||
# build-wasm.cjs, which points cc at Homebrew LLVM when it is present.
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
wasm-opt = false # Matches yaak-templates; wasm-opt has caused errors in CI
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# The whole crate is `#![cfg(target_arch = "wasm32")]`: on a native target it
|
||||
# is empty, so a workspace-wide `cargo test` neither builds SQLite's wasm shim
|
||||
# for the host (which fails) nor links a browser-only runtime. Everything that
|
||||
# only exists for wasm is a target-scoped dependency for the same reason.
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
console_error_panic_hook = "0.1"
|
||||
js-sys = "0.3"
|
||||
serde-wasm-bindgen = "0.6.5"
|
||||
sqlite-wasm-rs = "0.5"
|
||||
sqlite-wasm-vfs = "0.2"
|
||||
wasm-bindgen = "0.2.100"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
@@ -1,79 +0,0 @@
|
||||
const { execSync, spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
// Same shape as crates/yaak-templates/build-wasm.cjs, plus one wrinkle: this
|
||||
// crate links SQLite, and sqlite-wasm-rs compiles sqlite3.c to wasm at build
|
||||
// time. That needs a C compiler with a WebAssembly backend, which Apple's
|
||||
// clang is not. So the build looks for one, and when it finds none it keeps
|
||||
// the committed pkg/ and says so — desktop developers never need this crate
|
||||
// rebuilt, and failing their `npm run bootstrap` over it would be wrong.
|
||||
|
||||
if (process.env.SKIP_WASM_BUILD === "1") {
|
||||
console.log("Skipping wasm-pack build (SKIP_WASM_BUILD=1)");
|
||||
return;
|
||||
}
|
||||
|
||||
/** A clang that can emit wasm32, or null. */
|
||||
function findWasmClang() {
|
||||
const candidates = [
|
||||
process.env.CC_wasm32_unknown_unknown,
|
||||
"/opt/homebrew/opt/llvm/bin/clang", // Homebrew LLVM, Apple Silicon
|
||||
"/usr/local/opt/llvm/bin/clang", // Homebrew LLVM, Intel
|
||||
"clang", // Linux distros' clang usually has the backend built in
|
||||
].filter(Boolean);
|
||||
|
||||
for (const clang of candidates) {
|
||||
const probe = spawnSync(clang, ["--print-targets"], { encoding: "utf8" });
|
||||
if (probe.status === 0 && /\bwasm32\b/.test(probe.stdout)) return clang;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const clang = findWasmClang();
|
||||
if (clang == null) {
|
||||
console.log(
|
||||
[
|
||||
"yaak-web: no C compiler with a WebAssembly backend found; keeping the committed pkg/.",
|
||||
" To rebuild: install LLVM (macOS: `brew install llvm`) or point CC_wasm32_unknown_unknown at one.",
|
||||
].join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// llvm-ar lives next to clang in every LLVM distribution
|
||||
const ar = path.join(path.dirname(clang), "llvm-ar");
|
||||
|
||||
// Remap machine-specific paths that rustc embeds into the binary (panic
|
||||
// location strings), so builds are reproducible across machines
|
||||
const sysroot = execSync("rustc --print sysroot").toString().trim();
|
||||
const cargoHome = process.env.CARGO_HOME ?? path.join(os.homedir(), ".cargo");
|
||||
|
||||
execSync("wasm-pack build --target bundler", {
|
||||
stdio: "inherit",
|
||||
cwd: __dirname,
|
||||
env: {
|
||||
...process.env,
|
||||
CC_wasm32_unknown_unknown: clang,
|
||||
AR_wasm32_unknown_unknown: fs.existsSync(ar) ? ar : (process.env.AR_wasm32_unknown_unknown ?? ""),
|
||||
RUSTFLAGS: `--remap-path-prefix=${cargoHome}=/cargo --remap-path-prefix=${sysroot}=/rustc`,
|
||||
},
|
||||
});
|
||||
|
||||
// Rewrite the generated entry to use Vite's ?init import style instead of
|
||||
// the ES Module Integration style that wasm-pack generates, which Vite/rolldown
|
||||
// does not support in production builds.
|
||||
const entry = path.join(__dirname, "pkg", "yaak_web.js");
|
||||
fs.writeFileSync(
|
||||
entry,
|
||||
[
|
||||
'import init from "./yaak_web_bg.wasm?init";',
|
||||
'export * from "./yaak_web_bg.js";',
|
||||
'import * as bg from "./yaak_web_bg.js";',
|
||||
'const instance = await init({ "./yaak_web_bg.js": bg });',
|
||||
"bg.__wbg_set_wasm(instance.exports);",
|
||||
"instance.exports.__wbindgen_start();",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
// The desktop's model layer, compiled to wasm for the browser. See src/lib.rs.
|
||||
//
|
||||
// This is loaded by the SharedWorker in packages/platform/src/web/worker.ts and
|
||||
// nowhere else: it owns a SQLite database, and there must be exactly one of it
|
||||
// per origin.
|
||||
export { blob_delete, blob_get, blob_put, boot, rpc } from "./pkg";
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"bootstrap": "npm run build",
|
||||
"build": "run-s build:*",
|
||||
"build:pack": "node build-wasm.cjs",
|
||||
"build:clean": "rimraf ./pkg/.gitignore"
|
||||
},
|
||||
"devDependencies": {
|
||||
"rimraf": "^6.1.2"
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "yaak-web",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"yaak_web_bg.wasm",
|
||||
"yaak_web.js",
|
||||
"yaak_web_bg.js",
|
||||
"yaak_web.d.ts"
|
||||
],
|
||||
"main": "yaak_web.js",
|
||||
"types": "yaak_web.d.ts",
|
||||
"sideEffects": [
|
||||
"./yaak_web.js",
|
||||
"./snippets/*"
|
||||
]
|
||||
}
|
||||
Vendored
-51
@@ -1,51 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export function blob_delete(id: string): void;
|
||||
|
||||
/**
|
||||
* The bytes stored under an id, or none. Ids are the desktop's: a response's
|
||||
* own id for its body, `{responseId}.request` for the request that produced
|
||||
* it. Bytes cross to JS as a `Uint8Array` rather than through JSON.
|
||||
*/
|
||||
export function blob_get(id: string): Uint8Array | undefined;
|
||||
|
||||
/**
|
||||
* Store bytes under an id, replacing anything already there. Chunked the way
|
||||
* the desktop chunks, so a body written here reads back on a desktop that
|
||||
* imports the database, and vice versa.
|
||||
*/
|
||||
export function blob_put(id: string, bytes: Uint8Array): void;
|
||||
|
||||
/**
|
||||
* Register the IndexedDB-backed VFS and open the database.
|
||||
*
|
||||
* Migrations run inside `init_standalone`, exactly as they do for the CLI.
|
||||
* Safe to call more than once; later calls are no-ops.
|
||||
*/
|
||||
export function boot(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Run one command as `label` (the calling tab's identity, which stands in for
|
||||
* the desktop's window label on every write it makes).
|
||||
*
|
||||
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are
|
||||
* declared locally and dispatched by name, which is the one place this host
|
||||
* does not share the desktop's guarantees: the desktop builds its router from
|
||||
* the schema, so every command has a handler by construction. Here a renamed
|
||||
* command would surface as a runtime "not a command this host answers".
|
||||
*
|
||||
* The fix is `yaak-commands` (the `Host` trait), not more machinery here —
|
||||
* its `models::*` handlers are already this file, typed. Three things have to
|
||||
* give before a wasm host can register them:
|
||||
*
|
||||
* 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread
|
||||
* and the connection pool is an `Rc`.
|
||||
* 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn
|
||||
* onto here.
|
||||
* 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP
|
||||
* stack and the Node sidecar and do not build for wasm32.
|
||||
*
|
||||
* None of those are hard; they are just not this PR.
|
||||
*/
|
||||
export function rpc(cmd: string, payload: any, label: string): any;
|
||||
@@ -1,6 +0,0 @@
|
||||
import init from "./yaak_web_bg.wasm?init";
|
||||
export * from "./yaak_web_bg.js";
|
||||
import * as bg from "./yaak_web_bg.js";
|
||||
const instance = await init({ "./yaak_web_bg.js": bg });
|
||||
bg.__wbg_set_wasm(instance.exports);
|
||||
instance.exports.__wbindgen_start();
|
||||
@@ -1,990 +0,0 @@
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export function blob_delete(id) {
|
||||
const ptr0 = passStringToWasm0(id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.blob_delete(ptr0, len0);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes stored under an id, or none. Ids are the desktop's: a response's
|
||||
* own id for its body, `{responseId}.request` for the request that produced
|
||||
* it. Bytes cross to JS as a `Uint8Array` rather than through JSON.
|
||||
* @param {string} id
|
||||
* @returns {Uint8Array | undefined}
|
||||
*/
|
||||
export function blob_get(id) {
|
||||
const ptr0 = passStringToWasm0(id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.blob_get(ptr0, len0);
|
||||
if (ret[3]) {
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
let v2;
|
||||
if (ret[0] !== 0) {
|
||||
v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
|
||||
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||
}
|
||||
return v2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store bytes under an id, replacing anything already there. Chunked the way
|
||||
* the desktop chunks, so a body written here reads back on a desktop that
|
||||
* imports the database, and vice versa.
|
||||
* @param {string} id
|
||||
* @param {Uint8Array} bytes
|
||||
*/
|
||||
export function blob_put(id, bytes) {
|
||||
const ptr0 = passStringToWasm0(id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.blob_put(ptr0, len0, ptr1, len1);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the IndexedDB-backed VFS and open the database.
|
||||
*
|
||||
* Migrations run inside `init_standalone`, exactly as they do for the CLI.
|
||||
* Safe to call more than once; later calls are no-ops.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function boot() {
|
||||
const ret = wasm.boot();
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one command as `label` (the calling tab's identity, which stands in for
|
||||
* the desktop's window label on every write it makes).
|
||||
*
|
||||
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are
|
||||
* declared locally and dispatched by name, which is the one place this host
|
||||
* does not share the desktop's guarantees: the desktop builds its router from
|
||||
* the schema, so every command has a handler by construction. Here a renamed
|
||||
* command would surface as a runtime "not a command this host answers".
|
||||
*
|
||||
* The fix is `yaak-commands` (the `Host` trait), not more machinery here —
|
||||
* its `models::*` handlers are already this file, typed. Three things have to
|
||||
* give before a wasm host can register them:
|
||||
*
|
||||
* 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread
|
||||
* and the connection pool is an `Rc`.
|
||||
* 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn
|
||||
* onto here.
|
||||
* 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP
|
||||
* stack and the Node sidecar and do not build for wasm32.
|
||||
*
|
||||
* None of those are hard; they are just not this PR.
|
||||
* @param {string} cmd
|
||||
* @param {any} payload
|
||||
* @param {string} label
|
||||
* @returns {any}
|
||||
*/
|
||||
export function rpc(cmd, payload, label) {
|
||||
const ptr0 = passStringToWasm0(cmd, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(label, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.rpc(ptr0, len0, payload, ptr1, len1);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
export function __wbg_Error_bce6d499ff0a4aff(arg0, arg1) {
|
||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_String_8564e559799eccda(arg0, arg1) {
|
||||
const ret = String(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
}
|
||||
export function __wbg_Window_70131fc0c91e4b3c(arg0) {
|
||||
const ret = arg0.Window;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_WorkerGlobalScope_601c48015b8cc78e(arg0) {
|
||||
const ret = arg0.WorkerGlobalScope;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg___wbindgen_bigint_get_as_i64_410e28c7b761ad83(arg0, arg1) {
|
||||
const v = arg1;
|
||||
const ret = typeof(v) === 'bigint' ? v : undefined;
|
||||
getDataViewMemory0().setBigInt64(arg0 + 8 * 1, isLikeNone(ret) ? BigInt(0) : ret, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
|
||||
}
|
||||
export function __wbg___wbindgen_boolean_get_2304fb8c853028c8(arg0) {
|
||||
const v = arg0;
|
||||
const ret = typeof(v) === 'boolean' ? v : undefined;
|
||||
return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0;
|
||||
}
|
||||
export function __wbg___wbindgen_debug_string_edece8177ad01481(arg0, arg1) {
|
||||
const ret = debugString(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
}
|
||||
export function __wbg___wbindgen_in_07056af4f902c445(arg0, arg1) {
|
||||
const ret = arg0 in arg1;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg___wbindgen_is_bigint_aeae3893f30ed54e(arg0) {
|
||||
const ret = typeof(arg0) === 'bigint';
|
||||
return ret;
|
||||
}
|
||||
export function __wbg___wbindgen_is_function_5cd60d5cf78b4eef(arg0) {
|
||||
const ret = typeof(arg0) === 'function';
|
||||
return ret;
|
||||
}
|
||||
export function __wbg___wbindgen_is_null_2042690d351e14f0(arg0) {
|
||||
const ret = arg0 === null;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg___wbindgen_is_object_b4593df85baada48(arg0) {
|
||||
const val = arg0;
|
||||
const ret = typeof(val) === 'object' && val !== null;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg___wbindgen_is_string_dde0fd9020db4434(arg0) {
|
||||
const ret = typeof(arg0) === 'string';
|
||||
return ret;
|
||||
}
|
||||
export function __wbg___wbindgen_is_undefined_35bb9f4c7fd651d5(arg0) {
|
||||
const ret = arg0 === undefined;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg___wbindgen_jsval_eq_c0ed08b3e0f393b9(arg0, arg1) {
|
||||
const ret = arg0 === arg1;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg___wbindgen_jsval_loose_eq_0ad77b7717db155c(arg0, arg1) {
|
||||
const ret = arg0 == arg1;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg___wbindgen_number_get_f73a1244370fcc2c(arg0, arg1) {
|
||||
const obj = arg1;
|
||||
const ret = typeof(obj) === 'number' ? obj : undefined;
|
||||
getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
|
||||
}
|
||||
export function __wbg___wbindgen_string_get_d109740c0d18f4d7(arg0, arg1) {
|
||||
const obj = arg1;
|
||||
const ret = typeof(obj) === 'string' ? obj : undefined;
|
||||
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
var len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
}
|
||||
export function __wbg___wbindgen_throw_9c31b086c2b26051(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
}
|
||||
export function __wbg__wbg_cb_unref_3fa391f3fcdb55f8(arg0) {
|
||||
arg0._wbg_cb_unref();
|
||||
}
|
||||
export function __wbg_abort_70a701fced9ad53a() { return handleError(function (arg0) {
|
||||
arg0.abort();
|
||||
}, arguments); }
|
||||
export function __wbg_bound_8d5dfa042d13a74b() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||
const ret = IDBKeyRange.bound(arg0, arg1, arg2 !== 0, arg3 !== 0);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_call_13665d9f14390edc() { return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.call(arg1);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_call_dfde26266607c996() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.call(arg1, arg2);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_clear_bb1b3ff877b62598() { return handleError(function (arg0) {
|
||||
const ret = arg0.clear();
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_commit_e9c1332714c53826() { return handleError(function (arg0) {
|
||||
arg0.commit();
|
||||
}, arguments); }
|
||||
export function __wbg_createObjectStore_7aa4cf3fcb65c75a() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||
const ret = arg0.createObjectStore(getStringFromWasm0(arg1, arg2), arg3);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_crypto_48300657fced39f9(arg0) {
|
||||
const ret = arg0.crypto;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_delete_bc03f88e7f14db56() { return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.delete(arg1);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_done_54b8da57023b7ed2(arg0) {
|
||||
const ret = arg0.done;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_entries_564a7e8b1e54ede5(arg0) {
|
||||
const ret = Object.entries(arg0);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_error_a6fa202b58aa1cd3(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.error(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
}
|
||||
export function __wbg_error_ef9cbaece146d1d5() { return handleError(function (arg0) {
|
||||
const ret = arg0.error;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}, arguments); }
|
||||
export function __wbg_getAll_a0a54eef6ac20915() { return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.getAll(arg1);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_getAll_bc4f4ec6a1504163() { return handleError(function (arg0) {
|
||||
const ret = arg0.getAll();
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_getDate_a52123c8affc9072(arg0) {
|
||||
const ret = arg0.getDate();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_getDay_50a9ee1e4d17dc24(arg0) {
|
||||
const ret = arg0.getDay();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_getFullYear_d5d1f7de344fdc5b(arg0) {
|
||||
const ret = arg0.getFullYear();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_getHours_c974d920209733e8(arg0) {
|
||||
const ret = arg0.getHours();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_getMinutes_e2e8ae846b37b328(arg0) {
|
||||
const ret = arg0.getMinutes();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_getMonth_de70091920053153(arg0) {
|
||||
const ret = arg0.getMonth();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_getRandomValues_15134f5c0ae6b0d0() { return handleError(function (arg0, arg1) {
|
||||
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
|
||||
}, arguments); }
|
||||
export function __wbg_getRandomValues_263d0aa5464054ee() { return handleError(function (arg0, arg1) {
|
||||
arg0.getRandomValues(arg1);
|
||||
}, arguments); }
|
||||
export function __wbg_getSeconds_2782a558f414ec05(arg0) {
|
||||
const ret = arg0.getSeconds();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_getTime_09f1dd40a44edb30(arg0) {
|
||||
const ret = arg0.getTime();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_getTimezoneOffset_96cfb6ddebc9e5ca(arg0) {
|
||||
const ret = arg0.getTimezoneOffset();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_get_3e9a707ab7d352eb() { return handleError(function (arg0, arg1) {
|
||||
const ret = Reflect.get(arg0, arg1);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_get_98fdf51d029a75eb(arg0, arg1) {
|
||||
const ret = arg0[arg1 >>> 0];
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_get_dcf82ab8aad1a593() { return handleError(function (arg0, arg1) {
|
||||
const ret = Reflect.get(arg0, arg1);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_get_unchecked_1dfe6d05ad91d9b7(arg0, arg1) {
|
||||
const ret = arg0[arg1 >>> 0];
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_get_with_ref_key_6412cf3094599694(arg0, arg1) {
|
||||
const ret = arg0[arg1];
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_global_e30ac0b7684506d0(arg0) {
|
||||
const ret = arg0.global;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_indexedDB_2e82cb845ce6b3ad() { return handleError(function (arg0) {
|
||||
const ret = arg0.indexedDB;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}, arguments); }
|
||||
export function __wbg_indexedDB_a2139150e2ea2a08() { return handleError(function (arg0) {
|
||||
const ret = arg0.indexedDB;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}, arguments); }
|
||||
export function __wbg_indexedDB_cbfeacc981615a77() { return handleError(function (arg0) {
|
||||
const ret = arg0.indexedDB;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}, arguments); }
|
||||
export function __wbg_instanceof_ArrayBuffer_53db37b06f6b9afe(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof ArrayBuffer;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_instanceof_DomException_bc16ce893e8c7439(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof DOMException;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_instanceof_Error_b3f7e146d654031a(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof Error;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_instanceof_IdbDatabase_102b0fe5255eee9c(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof IDBDatabase;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_instanceof_IdbRequest_eef501cff5d0b7c1(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof IDBRequest;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_instanceof_Map_16f217b9a2a08d8c(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof Map;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_instanceof_Uint8Array_abd07d4bd221d50b(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof Uint8Array;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_isArray_94898ed3aad6947b(arg0) {
|
||||
const ret = Array.isArray(arg0);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_isSafeInteger_01e964d144ad3a55(arg0) {
|
||||
const ret = Number.isSafeInteger(arg0);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_iterator_1441b47f341dc34f() {
|
||||
const ret = Symbol.iterator;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_length_2591a0f4f659a55c(arg0) {
|
||||
const ret = arg0.length;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_length_56fcd3e2b7e0299d(arg0) {
|
||||
const ret = arg0.length;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_lowerBound_a64226f683db77bb() { return handleError(function (arg0, arg1) {
|
||||
const ret = IDBKeyRange.lowerBound(arg0, arg1 !== 0);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_message_324ac511aeaf710e(arg0) {
|
||||
const ret = arg0.message;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_message_e88a8d3ba2b91c2a(arg0, arg1) {
|
||||
const ret = arg1.message;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
}
|
||||
export function __wbg_msCrypto_8c6d45a75ef1d3da(arg0) {
|
||||
const ret = arg0.msCrypto;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_name_fe88cfc178ec40b8(arg0, arg1) {
|
||||
const ret = arg1.name;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
}
|
||||
export function __wbg_new_02d162bc6cf02f60() {
|
||||
const ret = new Object();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_new_070df68d66325372() {
|
||||
const ret = new Map();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_new_0_2722fcdb71a888a6() {
|
||||
const ret = new Date();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_new_1f236d63ba0c4784(arg0, arg1) {
|
||||
const ret = new Error(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_new_227d7c05414eb861() {
|
||||
const ret = new Error();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_new_310879b66b6e95e1() {
|
||||
const ret = new Array();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_new_7ddec6de44ff8f5d(arg0) {
|
||||
const ret = new Uint8Array(arg0);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_new_859b9002e2668e82(arg0) {
|
||||
const ret = new Date(arg0);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_new_from_slice_269e35316ed2d061(arg0, arg1) {
|
||||
const ret = new Uint8Array(getArrayU8FromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_new_typed_c072c4ce9a2a0cdf(arg0, arg1) {
|
||||
try {
|
||||
var state0 = {a: arg0, b: arg1};
|
||||
var cb0 = (arg0, arg1) => {
|
||||
const a = state0.a;
|
||||
state0.a = 0;
|
||||
try {
|
||||
return wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(a, state0.b, arg0, arg1);
|
||||
} finally {
|
||||
state0.a = a;
|
||||
}
|
||||
};
|
||||
const ret = new Promise(cb0);
|
||||
return ret;
|
||||
} finally {
|
||||
state0.a = 0;
|
||||
}
|
||||
}
|
||||
export function __wbg_new_with_length_99887c91eae4abab(arg0) {
|
||||
const ret = new Uint8Array(arg0 >>> 0);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_new_with_year_month_day_0ccdc1cc3a42b726(arg0, arg1, arg2) {
|
||||
const ret = new Date(arg0 >>> 0, arg1, arg2);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_next_2a4e19f4f5083b0f(arg0) {
|
||||
const ret = arg0.next;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_next_6429a146bf756f93() { return handleError(function (arg0) {
|
||||
const ret = arg0.next();
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_node_95beb7570492fd97(arg0) {
|
||||
const ret = arg0.node;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_objectStore_b28adb984a77902e() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.objectStore(getStringFromWasm0(arg1, arg2));
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_open_40ab11cdd8f5ac5a() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||
const ret = arg0.open(getStringFromWasm0(arg1, arg2), arg3 >>> 0);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_process_b2fea42461d03994(arg0) {
|
||||
const ret = arg0.process;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_prototypesetcall_5f9bdc8d75e07276(arg0, arg1, arg2) {
|
||||
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
|
||||
}
|
||||
export function __wbg_push_b77c476b01548d0a(arg0, arg1) {
|
||||
const ret = arg0.push(arg1);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_put_848906967513a84d() { return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.put(arg1);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_queueMicrotask_78d584b53af520f5(arg0) {
|
||||
const ret = arg0.queueMicrotask;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_queueMicrotask_b39ea83c7f01971a(arg0) {
|
||||
queueMicrotask(arg0);
|
||||
}
|
||||
export function __wbg_randomFillSync_ca9f178fb14c88cb() { return handleError(function (arg0, arg1) {
|
||||
arg0.randomFillSync(arg1);
|
||||
}, arguments); }
|
||||
export function __wbg_random_a8dfe52b70cb65a5() {
|
||||
const ret = Math.random();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_readyState_b7c530197b76b93b(arg0) {
|
||||
const ret = arg0.readyState;
|
||||
return (__wbindgen_enum_IdbRequestReadyState.indexOf(ret) + 1 || 3) - 1;
|
||||
}
|
||||
export function __wbg_require_7a9419e39d796c95() { return handleError(function () {
|
||||
const ret = module.require;
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_resolve_d17db9352f5a220e(arg0) {
|
||||
const ret = Promise.resolve(arg0);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_result_c4cb33cd39c97cac() { return handleError(function (arg0) {
|
||||
const ret = arg0.result;
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_set_24d0fa9e104112f9(arg0, arg1, arg2) {
|
||||
arg0.set(getArrayU8FromWasm0(arg1, arg2));
|
||||
}
|
||||
export function __wbg_set_6be42768c690e380(arg0, arg1, arg2) {
|
||||
arg0[arg1] = arg2;
|
||||
}
|
||||
export function __wbg_set_78ea6a19f4818587(arg0, arg1, arg2) {
|
||||
arg0[arg1 >>> 0] = arg2;
|
||||
}
|
||||
export function __wbg_set_a0e911be3da02782() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = Reflect.set(arg0, arg1, arg2);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_set_facb7a5914e0fa39(arg0, arg1, arg2) {
|
||||
const ret = arg0.set(arg1, arg2);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_set_key_path_8f8e19a098d0851c(arg0, arg1) {
|
||||
arg0.keyPath = arg1;
|
||||
}
|
||||
export function __wbg_set_onabort_ed56d2172d920901(arg0, arg1) {
|
||||
arg0.onabort = arg1;
|
||||
}
|
||||
export function __wbg_set_oncomplete_3f428ec13b20d7cc(arg0, arg1) {
|
||||
arg0.oncomplete = arg1;
|
||||
}
|
||||
export function __wbg_set_onerror_38740b892815eedc(arg0, arg1) {
|
||||
arg0.onerror = arg1;
|
||||
}
|
||||
export function __wbg_set_onerror_457b093a5063c7ec(arg0, arg1) {
|
||||
arg0.onerror = arg1;
|
||||
}
|
||||
export function __wbg_set_onsuccess_b556141053d02ea7(arg0, arg1) {
|
||||
arg0.onsuccess = arg1;
|
||||
}
|
||||
export function __wbg_set_onupgradeneeded_f885fa17614acd2b(arg0, arg1) {
|
||||
arg0.onupgradeneeded = arg1;
|
||||
}
|
||||
export function __wbg_stack_3b0d974bbf31e44f(arg0, arg1) {
|
||||
const ret = arg1.stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
}
|
||||
export function __wbg_static_accessor_GLOBAL_THIS_02344c9b09eb08a9() {
|
||||
const ret = typeof globalThis === 'undefined' ? null : globalThis;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
export function __wbg_static_accessor_GLOBAL_ac6d4ac874d5cd54() {
|
||||
const ret = typeof global === 'undefined' ? null : global;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
export function __wbg_static_accessor_SELF_9b2406c23aeb2023() {
|
||||
const ret = typeof self === 'undefined' ? null : self;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
export function __wbg_static_accessor_WINDOW_b34d2126934e16ba() {
|
||||
const ret = typeof window === 'undefined' ? null : window;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
export function __wbg_subarray_7c6a0da8f3b4a1ba(arg0, arg1, arg2) {
|
||||
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_target_84e05e84ffc12989(arg0) {
|
||||
const ret = arg0.target;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}
|
||||
export function __wbg_then_837494e384b37459(arg0, arg1) {
|
||||
const ret = arg0.then(arg1);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_toString_1dda136fd8f30a5f(arg0) {
|
||||
const ret = arg0.toString();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_transaction_213e4f585d3d1b40(arg0) {
|
||||
const ret = arg0.transaction;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_transaction_b7261fed68fa4264() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||
const ret = arg0.transaction(getStringFromWasm0(arg1, arg2), __wbindgen_enum_IdbTransactionMode[arg3]);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_upperBound_f7daa7529e579cfc() { return handleError(function (arg0, arg1) {
|
||||
const ret = IDBKeyRange.upperBound(arg0, arg1 !== 0);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_value_9cc0518af87a489c(arg0) {
|
||||
const ret = arg0.value;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_versions_215a3ab1c9d5745a(arg0) {
|
||||
const ret = arg0.versions;
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1104, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 180, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 200, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000005(arg0) {
|
||||
// Cast intrinsic for `F64 -> Externref`.
|
||||
const ret = arg0;
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000006(arg0) {
|
||||
// Cast intrinsic for `I64 -> Externref`.
|
||||
const ret = arg0;
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000007(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
|
||||
const ret = getArrayU8FromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000008(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000009(arg0) {
|
||||
// Cast intrinsic for `U64 -> Externref`.
|
||||
const ret = BigInt.asUintN(64, arg0);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_init_externref_table() {
|
||||
const table = wasm.__wbindgen_externrefs;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
}
|
||||
function wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
|
||||
const __wbindgen_enum_IdbRequestReadyState = ["pending", "done"];
|
||||
|
||||
|
||||
const __wbindgen_enum_IdbTransactionMode = ["readonly", "readwrite", "versionchange", "readwriteflush", "cleanup"];
|
||||
|
||||
function addToExternrefTable0(obj) {
|
||||
const idx = wasm.__externref_table_alloc();
|
||||
wasm.__wbindgen_externrefs.set(idx, obj);
|
||||
return idx;
|
||||
}
|
||||
|
||||
const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b));
|
||||
|
||||
function debugString(val) {
|
||||
// primitive types
|
||||
const type = typeof val;
|
||||
if (type == 'number' || type == 'boolean' || val == null) {
|
||||
return `${val}`;
|
||||
}
|
||||
if (type == 'string') {
|
||||
return `"${val}"`;
|
||||
}
|
||||
if (type == 'symbol') {
|
||||
const description = val.description;
|
||||
if (description == null) {
|
||||
return 'Symbol';
|
||||
} else {
|
||||
return `Symbol(${description})`;
|
||||
}
|
||||
}
|
||||
if (type == 'function') {
|
||||
const name = val.name;
|
||||
if (typeof name == 'string' && name.length > 0) {
|
||||
return `Function(${name})`;
|
||||
} else {
|
||||
return 'Function';
|
||||
}
|
||||
}
|
||||
// objects
|
||||
if (Array.isArray(val)) {
|
||||
const length = val.length;
|
||||
let debug = '[';
|
||||
if (length > 0) {
|
||||
debug += debugString(val[0]);
|
||||
}
|
||||
for(let i = 1; i < length; i++) {
|
||||
debug += ', ' + debugString(val[i]);
|
||||
}
|
||||
debug += ']';
|
||||
return debug;
|
||||
}
|
||||
// Test for built-in
|
||||
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
|
||||
let className;
|
||||
if (builtInMatches && builtInMatches.length > 1) {
|
||||
className = builtInMatches[1];
|
||||
} else {
|
||||
// Failed to match the standard '[object ClassName]'
|
||||
return toString.call(val);
|
||||
}
|
||||
if (className == 'Object') {
|
||||
// we're a user defined class or Object
|
||||
// JSON.stringify avoids problems with cycles, and is generally much
|
||||
// easier than looping through ownProperties of `val`.
|
||||
try {
|
||||
return 'Object(' + JSON.stringify(val) + ')';
|
||||
} catch (_) {
|
||||
return 'Object';
|
||||
}
|
||||
}
|
||||
// errors
|
||||
if (val instanceof Error) {
|
||||
return `${val.name}: ${val.message}\n${val.stack}`;
|
||||
}
|
||||
// TODO we could test for more things here, like `Set`s and `Map`s.
|
||||
return className;
|
||||
}
|
||||
|
||||
function getArrayU8FromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
return decodeText(ptr >>> 0, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function handleError(f, args) {
|
||||
try {
|
||||
return f.apply(this, args);
|
||||
} catch (e) {
|
||||
const idx = addToExternrefTable0(e);
|
||||
wasm.__wbindgen_exn_store(idx);
|
||||
}
|
||||
}
|
||||
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
function makeMutClosure(arg0, arg1, f) {
|
||||
const state = { a: arg0, b: arg1, cnt: 1 };
|
||||
const real = (...args) => {
|
||||
|
||||
// First up with a closure we increment the internal reference
|
||||
// count. This ensures that the Rust closure environment won't
|
||||
// be deallocated while we're invoking it.
|
||||
state.cnt++;
|
||||
const a = state.a;
|
||||
state.a = 0;
|
||||
try {
|
||||
return f(a, state.b, ...args);
|
||||
} finally {
|
||||
state.a = a;
|
||||
real._wbg_cb_unref();
|
||||
}
|
||||
};
|
||||
real._wbg_cb_unref = () => {
|
||||
if (--state.cnt === 0) {
|
||||
wasm.__wbindgen_destroy_closure(state.a, state.b);
|
||||
state.a = 0;
|
||||
CLOSURE_DTORS.unregister(state);
|
||||
}
|
||||
};
|
||||
CLOSURE_DTORS.register(real, state, state);
|
||||
return real;
|
||||
}
|
||||
|
||||
function passArray8ToWasm0(arg, malloc) {
|
||||
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
|
||||
if (!('encodeInto' in cachedTextEncoder)) {
|
||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
|
||||
let wasm;
|
||||
export function __wbg_set_wasm(val) {
|
||||
wasm = val;
|
||||
}
|
||||
Binary file not shown.
-32
@@ -1,32 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const blob_delete: (a: number, b: number) => [number, number];
|
||||
export const blob_get: (a: number, b: number) => [number, number, number, number];
|
||||
export const blob_put: (a: number, b: number, c: number, d: number) => [number, number];
|
||||
export const boot: () => any;
|
||||
export const rpc: (a: number, b: number, c: any, d: number, e: number) => [number, number, number];
|
||||
export const rust_sqlite_wasm_abort: () => void;
|
||||
export const rust_sqlite_wasm_assert_fail: (a: number, b: number, c: number, d: number) => void;
|
||||
export const rust_sqlite_wasm_calloc: (a: number, b: number) => number;
|
||||
export const rust_sqlite_wasm_free: (a: number) => void;
|
||||
export const rust_sqlite_wasm_getentropy: (a: number, b: number) => number;
|
||||
export const rust_sqlite_wasm_localtime: (a: number) => number;
|
||||
export const rust_sqlite_wasm_malloc: (a: number) => number;
|
||||
export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
|
||||
export const sqlite3_os_end: () => number;
|
||||
export const sqlite3_os_init: () => number;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc: (a: number, b: number) => void;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __wbindgen_exn_store: (a: number) => void;
|
||||
export const __externref_table_alloc: () => number;
|
||||
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
export const __wbindgen_destroy_closure: (a: number, b: number) => void;
|
||||
export const __externref_table_dealloc: (a: number) => void;
|
||||
export const __wbindgen_start: () => void;
|
||||
@@ -1,356 +0,0 @@
|
||||
//! The desktop's model layer, running in a browser.
|
||||
//!
|
||||
//! Nothing here is model logic. This crate registers a persistent VFS, opens
|
||||
//! the database through the same `init_standalone` the CLI uses, and answers
|
||||
//! the `models_*` commands by calling the same `ClientDb` queries the desktop
|
||||
//! does. A browser tab therefore stores exactly what a desktop install stores,
|
||||
//! migrations and all — the only thing that differs is where the SQLite pages
|
||||
//! live (IndexedDB) and who is calling in (a worker instead of Tauri).
|
||||
//!
|
||||
//! It is meant to be loaded once, in one place — a SharedWorker — because two
|
||||
//! SQLite instances over the same IndexedDB pages would corrupt them. The
|
||||
//! JavaScript side owns that; this crate assumes it is the only writer.
|
||||
//!
|
||||
//! The command surface is deliberately narrow: what the frontend needs to keep
|
||||
//! its model store coherent, and blob storage. Sending, plugins, git, sync and
|
||||
//! everything else with a socket or a filesystem behind it lives elsewhere.
|
||||
|
||||
// Nothing in here means anything off wasm32, and building it there would drag
|
||||
// SQLite's wasm C shim into a native compile. So on any other target the crate
|
||||
// is empty — a workspace-wide `cargo test` passes through it.
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use yaak_models::blob_manager::{BlobManager, BodyChunk};
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_models::models_ops;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
|
||||
/// Names inside the VFS, not paths on any disk. Two files because the desktop
|
||||
/// keeps two: models in one, blobs in the other.
|
||||
const DB_NAME: &str = "yaak.db";
|
||||
const BLOB_DB_NAME: &str = "yaak-blobs.db";
|
||||
const VFS_NAME: &str = "yaak-idb";
|
||||
|
||||
struct Host {
|
||||
queries: QueryManager,
|
||||
blobs: BlobManager,
|
||||
events: mpsc::Receiver<ModelPayload>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOST: RefCell<Option<Host>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Errors */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// What a failed command hands back to JavaScript: a real `Error`, so it
|
||||
/// throws like one, with `message` set to the model layer's own text.
|
||||
fn js_error(e: impl std::fmt::Display) -> JsValue {
|
||||
js_sys::Error::new(&e.to_string()).into()
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, JsValue>;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Boot */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// Register the IndexedDB-backed VFS and open the database.
|
||||
///
|
||||
/// Migrations run inside `init_standalone`, exactly as they do for the CLI.
|
||||
/// Safe to call more than once; later calls are no-ops.
|
||||
#[wasm_bindgen]
|
||||
pub async fn boot() -> Result<()> {
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
if HOST.with(|h| h.borrow().is_some()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// "Relaxed" means writes land in memory first and are flushed to
|
||||
// IndexedDB shortly after, rather than on every commit. It is the right
|
||||
// trade for a client app: a tab closing mid-flush loses at most the last
|
||||
// few writes, and the alternative (OPFS sync access handles) needs a
|
||||
// dedicated worker per file and is not available everywhere.
|
||||
let cfg = sqlite_wasm_vfs::relaxed_idb::RelaxedIdbCfgBuilder::new()
|
||||
.vfs_name(VFS_NAME)
|
||||
.preload(sqlite_wasm_vfs::relaxed_idb::Preload::All)
|
||||
.build();
|
||||
sqlite_wasm_vfs::relaxed_idb::install::<sqlite_wasm_rs::WasmOsCallback>(&cfg, true)
|
||||
.await
|
||||
.map_err(js_error)?;
|
||||
|
||||
let (queries, blobs, events) =
|
||||
yaak_models::init_standalone(DB_NAME, BLOB_DB_NAME).map_err(js_error)?;
|
||||
|
||||
HOST.with(|h| *h.borrow_mut() = Some(Host { queries, blobs, events }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_host<T>(f: impl FnOnce(&Host) -> Result<T>) -> Result<T> {
|
||||
HOST.with(|h| {
|
||||
let h = h.borrow();
|
||||
let host = h.as_ref().ok_or_else(|| js_error("yaak-web: call boot() before rpc()"))?;
|
||||
f(host)
|
||||
})
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Commands */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// A command's outcome, plus every model write it caused.
|
||||
///
|
||||
/// The writes ride along with the result rather than being fetched separately
|
||||
/// so the caller can announce them atomically with completion — a tab that
|
||||
/// awaits `models_upsert` must see its echo before or with the response, never
|
||||
/// after, or the store races the reply.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RpcOutcome {
|
||||
result: serde_json::Value,
|
||||
events: Vec<ModelPayload>,
|
||||
}
|
||||
|
||||
/// Run one command as `label` (the calling tab's identity, which stands in for
|
||||
/// the desktop's window label on every write it makes).
|
||||
///
|
||||
/// The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are
|
||||
/// declared locally and dispatched by name, which is the one place this host
|
||||
/// does not share the desktop's guarantees: the desktop builds its router from
|
||||
/// the schema, so every command has a handler by construction. Here a renamed
|
||||
/// command would surface as a runtime "not a command this host answers".
|
||||
///
|
||||
/// The fix is `yaak-commands` (the `Host` trait), not more machinery here —
|
||||
/// its `models::*` handlers are already this file, typed. Three things have to
|
||||
/// give before a wasm host can register them:
|
||||
///
|
||||
/// 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread
|
||||
/// and the connection pool is an `Rc`.
|
||||
/// 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn
|
||||
/// onto here.
|
||||
/// 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP
|
||||
/// stack and the Node sidecar and do not build for wasm32.
|
||||
///
|
||||
/// None of those are hard; they are just not this PR.
|
||||
#[wasm_bindgen]
|
||||
pub fn rpc(cmd: &str, payload: JsValue, label: &str) -> Result<JsValue> {
|
||||
let source = UpdateSource::from_window_label(label);
|
||||
|
||||
let result = with_host(|host| dispatch(host, cmd, payload, &source))?;
|
||||
let events = with_host(|host| Ok(host.events.try_iter().collect::<Vec<_>>()))?;
|
||||
|
||||
use serde::Serialize as _;
|
||||
RpcOutcome { result, events }
|
||||
.serialize(&serde_wasm_bindgen::Serializer::json_compatible())
|
||||
.map_err(js_error)
|
||||
}
|
||||
|
||||
fn from_js<T: for<'de> Deserialize<'de>>(payload: JsValue) -> Result<T> {
|
||||
serde_wasm_bindgen::from_value(payload).map_err(js_error)
|
||||
}
|
||||
|
||||
fn to_json<T: Serialize>(value: T) -> Result<serde_json::Value> {
|
||||
serde_json::to_value(value).map_err(js_error)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspaceModelsReq {
|
||||
workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ModelReq {
|
||||
model: AnyModel,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DuplicateReq {
|
||||
model_type: String,
|
||||
model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WorkspaceIdReq {
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RequestIdReq {
|
||||
request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpsertIntrospectionReq {
|
||||
workspace_id: String,
|
||||
request_id: String,
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
fn dispatch(
|
||||
host: &Host,
|
||||
cmd: &str,
|
||||
payload: JsValue,
|
||||
source: &UpdateSource,
|
||||
) -> Result<serde_json::Value> {
|
||||
match cmd {
|
||||
// The one big read. Same list, same order, and the same four lazy
|
||||
// creates (settings, first workspace, cookie jar, base environment) as
|
||||
// `models_workspace_models` on the desktop — this call is where an
|
||||
// empty database becomes a usable one. Returned as a JSON *string*
|
||||
// because that is what the desktop returns and what the store parses.
|
||||
"models_workspace_models" => {
|
||||
let req: WorkspaceModelsReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
let mut list: Vec<AnyModel> = Vec::new();
|
||||
|
||||
list.push(db.get_settings().into());
|
||||
list.extend(db.list_workspaces().map_err(js_error)?.into_iter().map(Into::into));
|
||||
list.extend(db.list_key_values().map_err(js_error)?.into_iter().map(Into::into));
|
||||
// No plugin runtime to resolve these against; the rows are still
|
||||
// the truth about what is installed.
|
||||
list.extend(db.list_plugins().map_err(js_error)?.into_iter().map(Into::into));
|
||||
|
||||
if let Some(wid) = req.workspace_id.as_deref() {
|
||||
let e = js_error;
|
||||
list.extend(db.list_cookie_jars(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(
|
||||
db.list_environments_ensure_base(wid).map_err(e)?.into_iter().map(Into::into),
|
||||
);
|
||||
list.extend(db.list_folders(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(db.list_grpc_connections(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(db.list_grpc_requests(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(db.list_http_requests(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
list.extend(
|
||||
db.list_http_responses(wid, None).map_err(e)?.into_iter().map(Into::into),
|
||||
);
|
||||
list.extend(
|
||||
db.list_websocket_connections(wid).map_err(e)?.into_iter().map(Into::into),
|
||||
);
|
||||
list.extend(
|
||||
db.list_websocket_requests(wid).map_err(e)?.into_iter().map(Into::into),
|
||||
);
|
||||
list.extend(db.list_workspace_metas(wid).map_err(e)?.into_iter().map(Into::into));
|
||||
}
|
||||
|
||||
to_json(serde_json::to_string(&list).map_err(js_error)?)
|
||||
}
|
||||
|
||||
"models_upsert" => {
|
||||
let req: ModelReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
let id =
|
||||
models_ops::upsert_model(&db, &host.blobs, req.model, source).map_err(js_error)?;
|
||||
to_json(id)
|
||||
}
|
||||
|
||||
// Deletes and duplicates cascade, so they run in a transaction, as on
|
||||
// the desktop.
|
||||
"models_delete" => {
|
||||
let req: ModelReq = from_js(payload)?;
|
||||
let id = host
|
||||
.queries
|
||||
.with_tx(|tx| models_ops::delete_model(tx, &host.blobs, req.model, source))
|
||||
.map_err(js_error)?;
|
||||
to_json(id)
|
||||
}
|
||||
|
||||
"models_duplicate" => {
|
||||
let req: DuplicateReq = from_js(payload)?;
|
||||
let id = host
|
||||
.queries
|
||||
.with_tx(|tx| {
|
||||
models_ops::duplicate_model(tx, &req.model_type, &req.model_id, source)
|
||||
})
|
||||
.map_err(js_error)?;
|
||||
to_json(id)
|
||||
}
|
||||
|
||||
"models_get_settings" => to_json(host.queries.connect().get_settings()),
|
||||
|
||||
"models_get_graphql_introspection" => {
|
||||
let req: RequestIdReq = from_js(payload)?;
|
||||
to_json(host.queries.connect().get_graphql_introspection(&req.request_id))
|
||||
}
|
||||
|
||||
"models_upsert_graphql_introspection" => {
|
||||
let req: UpsertIntrospectionReq = from_js(payload)?;
|
||||
let saved = host
|
||||
.queries
|
||||
.connect()
|
||||
.upsert_graphql_introspection(
|
||||
&req.workspace_id,
|
||||
&req.request_id,
|
||||
req.content,
|
||||
source,
|
||||
)
|
||||
.map_err(js_error)?;
|
||||
to_json(saved)
|
||||
}
|
||||
|
||||
// Nothing here can open a socket, so no connection ever produced any.
|
||||
"models_grpc_events" | "models_websocket_events" => to_json(Vec::<()>::new()),
|
||||
|
||||
"cmd_get_workspace_meta" => {
|
||||
let req: WorkspaceIdReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
let workspace = db.get_workspace(&req.workspace_id).map_err(js_error)?;
|
||||
to_json(db.get_or_create_workspace_meta(&workspace.id).map_err(js_error)?)
|
||||
}
|
||||
|
||||
other => Err(js_error(format!("yaak-web: `{other}` is not a command this host answers"))),
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Blobs */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// The bytes stored under an id, or none. Ids are the desktop's: a response's
|
||||
/// own id for its body, `{responseId}.request` for the request that produced
|
||||
/// it. Bytes cross to JS as a `Uint8Array` rather than through JSON.
|
||||
#[wasm_bindgen]
|
||||
pub fn blob_get(id: &str) -> Result<Option<Vec<u8>>> {
|
||||
with_host(|host| {
|
||||
let chunks = host.blobs.connect().get_chunks(id).map_err(js_error)?;
|
||||
if chunks.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(chunks.into_iter().flat_map(|c| c.data).collect()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Store bytes under an id, replacing anything already there. Chunked the way
|
||||
/// the desktop chunks, so a body written here reads back on a desktop that
|
||||
/// imports the database, and vice versa.
|
||||
#[wasm_bindgen]
|
||||
pub fn blob_put(id: &str, bytes: &[u8]) -> Result<()> {
|
||||
const CHUNK: usize = 512 * 1024;
|
||||
with_host(|host| {
|
||||
let ctx = host.blobs.connect();
|
||||
ctx.delete_chunks(id).map_err(js_error)?;
|
||||
for (i, part) in bytes.chunks(CHUNK).enumerate() {
|
||||
ctx.insert_chunk(&BodyChunk::new(id, i as i32, part.to_vec())).map_err(js_error)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn blob_delete(id: &str) -> Result<()> {
|
||||
with_host(|host| host.blobs.connect().delete_chunks(id).map_err(js_error))
|
||||
}
|
||||
@@ -6,7 +6,6 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
|
||||
log = { workspace = true }
|
||||
md5 = "0.8.0"
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pub mod error;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod models_ops;
|
||||
pub mod plugin_events;
|
||||
pub mod render;
|
||||
pub mod response_body;
|
||||
pub mod send;
|
||||
|
||||
pub use error::Error;
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
//! `UpdateSource` identifying who is writing; nothing here knows whether the
|
||||
//! caller is a desktop window or an HTTP request.
|
||||
|
||||
use crate::blob_manager::BlobManager;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::models::AnyModel;
|
||||
use crate::util::UpdateSource;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::error::Error::GenericError;
|
||||
use yaak_models::error::Result;
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
pub fn upsert_model(
|
||||
db: &ClientDb,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user