Compare commits

..
Author SHA1 Message Date
Gregory Schier 145c2315e5 test(openapi): align 3.2 URLs with base variable 2026-08-19 07:27:49 -07:00
Gregory Schier df1fd864b2 Support OpenAPI 3.2 QUERY and additional operations (#591) 2026-08-19 07:10:32 -07:00
Gregory Schier a2d54ca774 Preserve base URL variable when OpenAPI servers are omitted (#585) 2026-08-19 07:07:18 -07:00
Gregory Schier 36fec8b005 Render all columns in irregular CSV responses (#584) 2026-08-18 20:24:57 -07:00
Gregory Schier 115615d994 Let the web image workflow publish a version by hand
`latest` could only ever come from a release tag, and tagging also fires the
app and CLI releases. A dispatch that names a version now publishes it and
latest, which is how the first image gets out before a release exists.
2026-08-18 11:48:59 -07:00
Gregory Schier 538f782068 Serve the web client from yaak-web (#582) 2026-08-18 10:39:17 -07:00
Gregory Schier 3f202ff664 Fix macOS relaunch activation (#583) 2026-08-18 09:56:40 -07:00
Ngo Quoc Viet 131b7e5ab1 fix(importer-curl): read combined short flags as separate options (#577) 2026-08-18 09:09:04 -07:00
Gregory Schier 69083918f9 Add the browser send proxy and web sender (#572) 2026-08-18 08:28:01 -07:00
Gregory Schier 4b2dcf9a1a Open Settings in a dialog on the web build (#581) 2026-08-17 19:07:25 -07:00
Gregory Schier 569f552d79 Stop sending scope on the authorization code token request (#579) 2026-08-17 12:23:08 -07:00
Gregory Schier 33f32cccf6 Leave browser zoom to the browser (#578)
The web build bound Cmd/Ctrl +, - and 0 and then did nothing with them, so zoom appeared broken. Those keys are the browser's own page zoom, which scales the whole page and remembers it per site.

Adds an interfaceZoom capability: true on desktop, where the host zooms the webview, false in a browser. When false the app binds nothing and the hotkeys screen drops the three rows it can't configure.
2026-08-17 11:29:20 -07:00
Gregory Schier 7ca772347f Sweep orphaned response bodies at browser startup (#576) 2026-08-17 10:38:52 -07:00
Gregory Schier b89c448345 Fix cookie defaults and Timeline ordering, and give models a real Default (#573) 2026-08-17 09:14:17 -07:00
Gregory Schier 2021df112a Implement response deletes in the browser host instead of declining them (#574) 2026-08-17 09:02:18 -07:00
Gregory Schier 2d2a390bfd Use clang-18 from apt.llvm.org for the wasm build on 22.04 runners
clang-15 got past the C23 [[noreturn]] error but still fails compiling
sqlite-wasm-rs for wasm32: its stdint.h falls through to host glibc headers
(bits/libc-header-start.h not found). clang-18 handles wasm32 as freestanding
and compiles it (it is what ubuntu-24.04 uses). 22.04's repos stop at clang-15,
so install 18 from apt.llvm.org. Runners stay on 22.04 to keep the glibc floor.
2026-08-16 22:54:34 -07:00
Gregory Schier 5cce23566a Allow manual worktree setup 2026-08-16 22:32:33 -07:00
Gregory Schier e99f6d2bc7 Gate the titlebar inset on a windowChrome capability instead of osType (#570) 2026-08-16 22:21:38 -07:00
Gregory Schier bea58b16b4 Force text presentation for the Enter hotkey symbol (#569) 2026-08-16 22:21:04 -07:00
Gregory Schier 93fba4d9b4 Restore ubuntu-22.04 release runners; use clang-15 for the wasm build (#568) 2026-08-16 21:52:56 -07:00
Gregory Schier b9071eafe0 Revert "Guard app releases against missing artifacts"
This reverts commit 778c74c635.
2026-08-16 18:19:47 -07:00
Gregory Schier 778c74c635 Guard app releases against missing artifacts 2026-08-16 17:30:10 -07:00
Gregory Schier 0f434361a7 Fix Linux WASM release builds 2026-08-16 15:41:00 -07:00
Gregory Schier d27d11af7c Move plugin actions and authentication onto PluginHost (#563) 2026-08-16 11:41:03 -07:00
Gregory Schier 1a19a06a23 Add native OpenAPI importer (#486) 2026-08-16 11:40:34 -07:00
Gregory Schier 07a9a6c6c0 Update NTLM auth tests for the new send() response shape (#567) 2026-08-16 11:35:17 -07:00
Gregory Schier e54240d579 Let plugins declare assets to place beside the bundle (#565) 2026-08-16 11:22:43 -07:00
Gregory Schier 8bca013ab4 Fix plugin runtime build and JSON linter crash (#566) 2026-08-16 11:22:02 -07:00
Gregory Schier 10e962a0e6 Add a plugin API for reading HTTP response bodies (#560) 2026-08-16 11:10:14 -07:00
Gregory Schier 78954e10c8 Fix 23 Dependabot alerts (#562) 2026-08-16 10:27:28 -07:00
Gregory Schier 9eb7a001da Move template rendering and themes onto PluginHost (#559) 2026-08-16 09:22:46 -07:00
Gregory Schier 6a02cbe525 Add a Host trait and move DB/model commands off Tauri (#558) 2026-08-16 08:41:35 -07:00
Gregory Schier 6f91f76064 Run the desktop's model layer in the browser (#557) 2026-08-16 07:34:48 -07:00
Gregory Schier 32e92d484b Let yaak-models compile for wasm32-unknown-unknown (#556) 2026-08-15 14:55:54 -07:00
Gregory Schier cdbbef34f8 Fix native TLS client certificates on Linux (#554) 2026-08-15 11:36:36 -07:00
Gregory SchierandClaude Fable 5 4838353585 Move the RPC wire schema into a Tauri-free crate (#553)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:25:27 -07:00
218 changed files with 21393 additions and 10772 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
**/node_modules
dist
**/dist
target
**/target
.claude
vendored
**/vendored
*.log
.git
+12
View File
@@ -103,6 +103,18 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
# crates/yaak-wasm 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
+123
View File
@@ -0,0 +1,123 @@
name: Release web image
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
# One image per architecture on its own native runner (emulating a Rust release build is hours),
# joined into one multi-arch tag at the end.
on:
push:
tags: [v*]
workflow_dispatch:
inputs:
version:
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
required: false
type: string
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/mountain-loop/yaak-web
jobs:
build:
if: github.repository == 'mountain-loop/yaak'
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-22.04
arch: amd64
- platform: linux/arm64
runner: ubuntu-22.04-arm
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.web
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
publish:
name: Publish manifest
needs: build
runs-on: ubuntu-latest
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# `latest` follows a release tag, and a manual run that names a version — the way to
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
- name: Tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
flavor: latest=false
tags: |
type=semver,pattern={{version}}
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
type=ref,event=branch
type=sha,format=short
- name: Create and push the manifest
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
Generated
+412 -153
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -2,9 +2,11 @@
resolver = "2"
members = [
"crates/yaak",
"crates/yaak-commands",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
"crates/common/yaak-rpc-schema",
# Shared crates (no Tauri dependency)
"crates/yaak-core",
"crates/yaak-common",
@@ -12,6 +14,7 @@ members = [
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
"crates/yaak-lifecycle",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-sse",
@@ -19,14 +22,15 @@ members = [
"crates/yaak-templates",
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-wasm",
"crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# Server crates (the browser tier's hosted send executor)
"crates-server/yaak-web",
# 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",
@@ -65,15 +69,18 @@ ts-rs = "11.1.0"
# Internal crates - common/foundation
yaak-database = { path = "crates/common/yaak-database" }
yaak-rpc = { path = "crates/common/yaak-rpc" }
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
# Internal crates - shared
yaak-core = { path = "crates/yaak-core" }
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" }
yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" }
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-sse = { path = "crates/yaak-sse" }
+19
View File
@@ -44,6 +44,25 @@ After bootstrapping, start the app in development mode:
npm start
```
## Run the App in a Browser
The client can also run as a plain web page, with no Tauri and no local process
behind it. Set `YAAK_TARGET=web` and start the frontend on its own:
```shell
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
```
That flag picks the browser host in `packages/platform/src/web/`, which answers
commands from an IndexedDB database the page owns instead of from the Rust
engine. Data persists across reloads and is shared between tabs on the same
origin. Sending HTTP is not available yet — the Send button reports that and
everything else about the request is still saved. `packages/platform/src/web/README.md`
lists which commands the browser host implements and which it declines.
Desktop builds are unaffected: without the flag the platform package installs
the Tauri host exactly as before.
## SQLite Migrations
New migrations can be created from the `src-tauri/` directory:
+46
View File
@@ -0,0 +1,46 @@
# syntax=docker/dockerfile:1
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
# image serving both from one origin.
#
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
#
# See crates-server/yaak-web/README.md for the knobs.
FROM node:22-slim AS web
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
COPY . .
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
RUN git init -q && git add -A \
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
# only to build a bundle for a deployment whose server lives somewhere else.
ARG VITE_YAAK_WEB_URL=""
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
ENV YAAK_TARGET=web
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
# backend, which this image has no reason to carry.
ENV SKIP_WASM_BUILD=1
RUN npm ci
RUN node_modules/.bin/vp -C apps/yaak-client build
FROM rust:1-bookworm AS server
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
COPY . .
RUN cargo build --release -p yaak-web
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
COPY --from=web /app/dist/apps/yaak-client /srv
ENV YAAK_WEB_BIND=0.0.0.0:8080
EXPOSE 8080
USER nobody
# Overriding the command (dropping --serve) leaves the stateless send executor:
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
CMD ["yaak-web", "--serve", "/srv"]
+23 -5
View File
@@ -1,19 +1,37 @@
import type { SettingsTab } from "../components/Settings/Settings";
import { platform } from "@yaakapp-internal/platform";
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { router } from "../lib/router";
import { rpc } from "../lib/rpc";
// Allow tab with optional subtab (e.g., "plugins:installed")
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
mutationKey: ["open_settings"],
mutationFn: async (tab) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
// Settings is its own window where the host has windows to give. Where it
// doesn't — a browser tab — it's a dialog like any other, so opening it
// doesn't take you away from the request you were working on.
if (!platform.capabilities.multiWindow) {
// Imported here so Settings stays out of the startup bundle, the way the
// route that renders it on desktop already keeps it
const { default: Settings } = await import("../components/Settings/Settings");
showDialog({
id: "settings",
size: "md",
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
noPadding: true,
noScroll: true,
// Keyed so opening a specific tab while the dialog is already up moves to it
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
});
return;
}
const location = router.buildLocation({
to: "/workspaces/$workspaceId/settings",
params: { workspaceId },
@@ -1,3 +1,4 @@
import { platform } from "@yaakapp-internal/platform";
import { createFastMutation } from "../hooks/useFastMutation";
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
@@ -24,7 +25,9 @@ export const switchWorkspace = createFastMutation<
request_id: requestId,
};
if (inNewWindow) {
// A host without windows opens the workspace here instead. Refusing would
// strand the user on the workspace they were trying to leave.
if (inNewWindow && platform.capabilities.multiWindow) {
const location = router.buildLocation({
to: "/workspaces/$workspaceId",
params: { workspaceId },
@@ -1,4 +1,3 @@
import { useSearch } from "@tanstack/react-router";
import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
@@ -20,6 +19,8 @@ import { SettingsProxy } from "./SettingsProxy";
import { SettingsTheme } from "./SettingsTheme";
interface Props {
tab?: SettingsTabWithSubtab | null;
/** Set when Settings is in a dialog rather than owning a window. */
hide?: () => void;
}
@@ -42,25 +43,19 @@ const tabs = [
TAB_LICENSE,
] as const;
export type SettingsTab = (typeof tabs)[number];
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
export default function Settings({ tab, hide }: Props) {
// Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
const [mainTab, subtab] = tab?.split(":") ?? [];
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
// Close settings window on escape
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
useKeyPressEvent("Escape", async () => {
if (hide != null) {
// It's being shown in a dialog, so close the dialog
hide();
} else {
// It's being shown in a window, so close the window
await platform.window.close();
}
if (hide == null) await platform.window.close();
});
return (
@@ -90,7 +85,7 @@ export default function Settings({ hide }: Props) {
)}
<Tabs
layout="horizontal"
defaultValue={mainTab || tabFromQuery}
defaultValue={mainTab}
addBorders
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action]}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
@@ -1,9 +1,29 @@
import type { Diagnostic } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { parse as jsonLintParse } from "@prantlf/jsonlint";
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
// jsonc-parser reports error codes, so these are the words the editor shows for them
const MESSAGES: Record<string, string> = {
InvalidSymbol: "Invalid symbol",
InvalidNumberFormat: "Invalid number format",
PropertyNameExpected: "Property name expected",
ValueExpected: "Value expected",
ColonExpected: "Colon expected",
CommaExpected: "Comma expected",
CloseBraceExpected: "Closing brace expected",
CloseBracketExpected: "Closing bracket expected",
EndOfFileExpected: "End of file expected",
InvalidCommentToken: "Comments are not allowed",
UnexpectedEndOfComment: "Unexpected end of comment",
UnexpectedEndOfString: "Unexpected end of string",
UnexpectedEndOfNumber: "Unexpected end of number",
InvalidUnicode: "Invalid unicode sequence",
InvalidEscapeCharacter: "Invalid escape character",
InvalidCharacter: "Invalid character",
};
interface JsonLintOptions {
allowComments?: boolean;
allowTrailingCommas?: boolean;
@@ -11,34 +31,28 @@ interface JsonLintOptions {
export function jsonParseLinter(options?: JsonLintOptions) {
return (view: EditorView): Diagnostic[] => {
try {
const doc = view.state.doc.toString();
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
jsonLintParse(escapedDoc, {
mode: (options?.allowComments ?? true) ? "cjson" : "json",
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
});
// oxlint-disable-next-line no-explicit-any
} catch (err: any) {
if (!("location" in err)) {
return [];
}
const doc = view.state.doc.toString();
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
// const line = location?.start?.line;
// const column = location?.start?.column;
if (err.location.start.offset) {
return [
{
from: err.location.start.offset,
to: err.location.start.offset,
severity: "error",
message: err.message,
},
];
}
}
return [];
const errors: ParseError[] = [];
parse(escapedDoc, errors, {
allowTrailingComma: options?.allowTrailingCommas ?? false,
disallowComments: !(options?.allowComments ?? true),
});
// Later errors are mostly consequences of the first one, so only that one is shown
const error = errors[0];
if (error == null) return [];
return [
{
from: error.offset,
to: error.offset + error.length,
severity: "error",
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
},
];
};
}
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test, vi } from "vite-plus/test";
import { CsvViewerInner } from "./CsvViewer";
vi.mock("@yaakapp-internal/ui", () => ({
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
}));
describe("CsvViewer", () => {
test("renders columns that extend beyond the first row", () => {
const markup = renderToStaticMarkup(
<CsvViewerInner
text={[
"startDate,2026-02-03T00:00-03:00",
"endDate,2026-02-03T23:59:59-03:00",
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
].join("\n")}
/>,
);
expect(markup).toContain("ID de usuario");
expect(markup).toContain("42041");
expect(markup.match(/<td>/g)).toHaveLength(20);
});
});
@@ -26,27 +26,33 @@ export function CsvViewer({ text, className }: Props) {
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
const parsed = useMemo(() => {
if (text == null) return null;
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
return Papa.parse<string[]>(text, { skipEmptyLines: true });
}, [text]);
if (parsed === null) return null;
const header = parsed.data[0] ?? [];
const rows = parsed.data.slice(1);
const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
return (
<div className="overflow-auto h-full">
<Table className={classNames(className, "text-sm")}>
<TableHead>
<TableRow>
{parsed.meta.fields?.map((field) => (
<TableHeaderCell key={field}>{field}</TableHeaderCell>
{columnIndexes.map((columnIndex) => (
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
))}
</TableRow>
</TableHead>
<TableBody>
{parsed.data.map((row, i) => (
{rows.map((row, i) => (
// oxlint-disable-next-line react/no-array-index-key
<TableRow key={i}>
{parsed.meta.fields?.map((key) => (
<TableCell key={key}>{row[key] ?? ""}</TableCell>
{row.map((cell, columnIndex) => (
// oxlint-disable-next-line react/no-array-index-key
<TableCell key={columnIndex}>{cell}</TableCell>
))}
</TableRow>
))}
+26 -12
View File
@@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => {
// Merge default hotkeys with custom hotkeys from settings
// Custom hotkeys override defaults for the same action
// An empty array means the hotkey is intentionally disabled
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
const merged: Partial<Record<HotkeyAction, string[]>> = {};
for (const action of hotkeyActions) {
merged[action] = defaultHotkeys[action];
}
for (const [action, keys] of Object.entries(customHotkeys)) {
if (action in defaultHotkeys && Array.isArray(keys)) {
if (action in merged && Array.isArray(keys)) {
merged[action as HotkeyAction] = keys;
}
}
@@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => {
});
/** Helper function to get current hotkeys from the store */
function getHotkeys(): Record<HotkeyAction, string[]> {
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
return jotaiStore.get(hotkeysAtom);
}
@@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [
"Space",
];
/** Zoom is the browser's own on these keys, so the app has no such action there. */
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
/**
* The actions this host actually has. An action left out of here has no keys in
* `hotkeysAtom`, so it never matches and never claims the keystroke.
*/
export const hotkeyActions: HotkeyAction[] = (
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
).sort((a, b) => {
const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
)
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
.sort((a, b) => {
const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
export type HotKeyOptions = {
enable?: boolean | (() => boolean);
@@ -333,7 +345,9 @@ export function formatHotkeyString(trigger: string): string[] {
} else if (p === "Alt") {
labelParts.push("⌥");
} else if (p === "Enter") {
labelParts.push("↩");
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
labelParts.push("↩︎");
} else if (p === "Tab") {
labelParts.push("⇥");
} else if (p === "Backspace") {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { RpcPayload } from "@yaakapp-internal/platform";
import { platform } from "@yaakapp-internal/platform";
import type { RpcSchema } from "@yaakapp-internal/tauri-client";
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
/**
* Every backend command the app can call: the generated wire schema, one field
+1 -1
View File
@@ -1,5 +1,5 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import type { EphemeralHttpResponse } from "@yaakapp-internal/tauri-client";
import type { EphemeralHttpResponse } from "@yaakapp-internal/rpc-schema";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { rpc } from "./rpc";
+3 -5
View File
@@ -23,7 +23,6 @@
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@mjackson/multipart-parser": "^0.10.1",
"@prantlf/jsonlint": "^16.0.0",
"@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2",
@@ -54,6 +53,7 @@
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"js-md5": "^0.8.3",
"jsonc-parser": "^3.3.1",
"lucide-react": "^0.525.0",
"mime": "^4.0.4",
"motion": "^12.4.7",
@@ -93,14 +93,12 @@
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"babel-plugin-react-compiler": "^1.0.0",
"decompress": "^4.2.1",
"internal-ip": "^8.0.0",
"rollup": "^4.60.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
"vite-plugin-static-copy": "^3.3.0",
"vite-plugin-svgr": "^4.5.0",
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.2.1"
"vite-plus": "^0.2.9"
}
}
+6 -6
View File
@@ -46,9 +46,9 @@ const WorkspacesWorkspaceIdRequestsRequestIdRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/workspaces': typeof WorkspacesIndexRoute
'/workspaces/': typeof WorkspacesIndexRoute
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId/': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
}
export interface FileRoutesByTo {
@@ -70,9 +70,9 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/workspaces'
| '/workspaces/'
| '/workspaces/$workspaceId/settings'
| '/workspaces/$workspaceId'
| '/workspaces/$workspaceId/'
| '/workspaces/$workspaceId/requests/$requestId'
fileRoutesByTo: FileRoutesByTo
to:
@@ -110,14 +110,14 @@ declare module '@tanstack/react-router' {
'/workspaces/': {
id: '/workspaces/'
path: '/workspaces'
fullPath: '/workspaces'
fullPath: '/workspaces/'
preLoaderRoute: typeof WorkspacesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/workspaces/$workspaceId/': {
id: '/workspaces/$workspaceId/'
path: '/workspaces/$workspaceId'
fullPath: '/workspaces/$workspaceId'
fullPath: '/workspaces/$workspaceId/'
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
@@ -14,5 +14,6 @@ export const Route = createFileRoute("/workspaces/$workspaceId/settings")({
});
function RouteComponent() {
return <Settings />;
const { tab } = Route.useSearch();
return <Settings tab={tab} />;
}
+29 -2
View File
@@ -6,7 +6,6 @@ import path from "node:path";
import { defineConfig, normalizePath } from "vite-plus";
import { viteStaticCopy } from "vite-plugin-static-copy";
import svgr from "vite-plugin-svgr";
import topLevelAwait from "vite-plugin-top-level-await";
import wasm from "vite-plugin-wasm";
const require = createRequire(import.meta.url);
@@ -17,9 +16,38 @@ const standardFontsDir = normalizePath(
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"),
);
/**
* Which host the platform package installs. `web` builds Yaak to run in a plain
* browser tab, with its own IndexedDB store instead of the Rust engine; anything
* else builds the desktop app exactly as before.
*/
const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop";
// https://vitejs.dev/config/
export default defineConfig(async () => {
return {
resolve: {
alias:
yaakTarget === "web"
? {
// Resolve the platform package to its browser entry, so a web
// build never pulls `@tauri-apps/*` into the graph at all. A
// build-time branch inside the package would not manage that:
// the dead branch folds away, but the imports it guarded stay.
"@yaakapp-internal/platform": path.resolve(
import.meta.dirname,
"../../packages/platform/src/index.web.ts",
),
}
: {},
},
// The browser host runs the model layer in a worker; that bundle needs the
// same wasm handling as the main one. Top-level await needs no transform
// because the build targets esnext.
worker: {
format: "es" as const,
plugins: () => [wasm()],
},
plugins: [
wasm(),
tanstackRouter({
@@ -30,7 +58,6 @@ export default defineConfig(async () => {
}),
svgr(),
react(),
topLevelAwait(),
viteStaticCopy({
targets: [
{ src: cMapsDir, dest: "" },
+2 -2
View File
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.2.1"
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
"vite-plus": "^0.2.9"
}
}
+1
View File
@@ -45,6 +45,7 @@ yaak-api = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-http = { workspace = true }
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-templates = { workspace = true }
+161 -2
View File
@@ -181,7 +181,11 @@ async fn dev(args: PluginPathArg) -> CommandResult {
ui::info(&format!("Rebuilding plugin {display_path}"));
}
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
match generate_plugin_metadata(&watch_root) {
// Assets are staged on every rebuild, so a changed asset or
// declaration is picked up without restarting.
let result = copy_build_assets(&watch_root)
.and_then(|()| generate_plugin_metadata(&watch_root));
match result {
Ok(()) => ui::success(&format!(
"Generated plugin metadata at {}",
watch_root.join("build/metadata.json").display()
@@ -408,6 +412,7 @@ struct PublishResponse {
async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
prepare_build_output_dir(plugin_dir)?;
copy_build_assets(plugin_dir)?;
let mut bundler = Bundler::new(bundler_options(plugin_dir, false))
.map_err(|err| format!("Failed to initialize Rolldown: {err}"))?;
let output = bundler.write().await.map_err(|err| format!("Plugin build failed:\n{err}"))?;
@@ -498,6 +503,63 @@ fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
.map_err(|e| format!("Failed to create build directory {}: {e}", build_dir.display()))
}
#[derive(Deserialize, Default)]
struct PluginManifest {
#[serde(default)]
yaak: PluginManifestConfig,
}
#[derive(Deserialize, Default)]
struct PluginManifestConfig {
/// Files to place beside the bundle, as paths relative to the plugin
/// directory. Publishing ships everything in `build/`, so these travel with
/// the plugin.
#[serde(default, rename = "buildAssets")]
build_assets: Vec<String>,
}
/// Copy the plugin's declared assets into `build/`.
///
/// This runs after the directory is cleared and before the bundle is written,
/// because a bundle may read an asset from its own directory at import time and
/// metadata generation imports the bundle.
fn copy_build_assets(plugin_dir: &Path) -> CommandResult {
let manifest_path = plugin_dir.join("package.json");
let manifest: PluginManifest = serde_json::from_str(
&fs::read_to_string(&manifest_path)
.map_err(|e| format!("Failed to read {}: {e}", manifest_path.display()))?,
)
.map_err(|e| format!("Failed to parse {}: {e}", manifest_path.display()))?;
let build_dir = plugin_dir.join("build");
let mut names = HashSet::new();
for asset in manifest.yaak.build_assets {
let src = plugin_dir.join(&asset);
let name = src
.file_name()
.ok_or_else(|| format!("yaak.buildAssets entry is not a file path: {asset}"))?;
// A copy that later gets overwritten would pass the build and fail on
// load, so anything the build itself writes, or a second asset with
// the same name, is rejected up front. Names are compared without
// case, because a plugin is installed on case-insensitive filesystems
// wherever it was built.
let key = name.to_string_lossy().to_lowercase();
if key == "index.js" || key == "metadata.json" {
return Err(format!("Build asset {asset} would be overwritten by the build output"));
}
if !names.insert(key) {
return Err(format!("Two build assets share the name {}", name.display()));
}
if !src.is_file() {
return Err(format!("Build asset does not exist: {}", src.display()));
}
fs::copy(&src, build_dir.join(name))
.map_err(|e| format!("Failed to copy build asset {}: {e}", src.display()))?;
}
Ok(())
}
fn bundler_options(plugin_dir: &Path, watch: bool) -> BundlerOptions {
BundlerOptions {
input: Some(vec![InputItem { import: "./src/index.ts".to_string(), ..Default::default() }]),
@@ -750,7 +812,10 @@ describe("Example Plugin", () => {
#[cfg(test)]
mod tests {
use super::{create_publish_archive, generate_plugin_metadata};
use super::{
copy_build_assets, create_publish_archive, generate_plugin_metadata,
prepare_build_output_dir,
};
use serde_json::Value;
use std::collections::HashSet;
use std::fs;
@@ -795,6 +860,100 @@ mod tests {
assert!(!names.contains("ignored/secret.txt"));
}
#[test]
fn prepare_build_output_dir_clears_stale_output() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
let build = root.join("build");
fs::create_dir_all(&build).expect("create build");
fs::write(build.join("index.js"), "stale").expect("write index.js");
fs::write(build.join("left-behind.js"), "stale").expect("write extra");
prepare_build_output_dir(root).expect("prepare build dir");
// Publishing ships everything under build/, so nothing may survive.
assert!(build.is_dir());
assert_eq!(fs::read_dir(&build).expect("read build").count(), 0);
}
#[test]
fn copy_build_assets_places_declared_files_beside_the_bundle() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::create_dir_all(root.join("vendor")).expect("create vendor");
fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset");
fs::write(
root.join("package.json"),
r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#,
)
.expect("write package.json");
copy_build_assets(root).expect("copy assets");
assert_eq!(
fs::read_to_string(root.join("build/core_bg.wasm")).expect("read copied asset"),
"asset"
);
}
#[test]
fn copy_build_assets_is_a_noop_without_declarations() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::write(root.join("package.json"), r#"{"name":"demo"}"#).expect("write package.json");
copy_build_assets(root).expect("copy assets");
assert_eq!(fs::read_dir(root.join("build")).expect("read build").count(), 0);
}
#[test]
fn copy_build_assets_rejects_names_the_build_writes() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::write(root.join("index.js"), "asset").expect("write asset");
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["index.js"]}}"#)
.expect("write package.json");
let err = copy_build_assets(root).expect_err("reserved name should fail");
assert!(err.contains("overwritten by the build output"), "unexpected error: {err}");
}
#[test]
fn copy_build_assets_rejects_duplicate_names() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::create_dir_all(root.join("a")).expect("create a");
fs::create_dir_all(root.join("b")).expect("create b");
// Differ only by case: one file on macOS and Windows.
fs::write(root.join("a/core.wasm"), "one").expect("write a");
fs::write(root.join("b/Core.wasm"), "two").expect("write b");
fs::write(
root.join("package.json"),
r#"{"yaak":{"buildAssets":["a/core.wasm","b/Core.wasm"]}}"#,
)
.expect("write package.json");
let err = copy_build_assets(root).expect_err("duplicate name should fail");
assert!(err.contains("share the name"), "unexpected error: {err}");
}
#[test]
fn copy_build_assets_fails_on_a_missing_asset() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["nope.wasm"]}}"#)
.expect("write package.json");
let err = copy_build_assets(root).expect_err("missing asset should fail");
assert!(err.contains("Build asset does not exist"), "unexpected error: {err}");
}
#[test]
fn generate_plugin_metadata_detects_api_types() {
let dir = TempDir::new().expect("temp dir");
+6 -9
View File
@@ -435,15 +435,12 @@ fn create(
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
let name = name.unwrap_or_default();
let url = url.unwrap_or_default();
let method = method.unwrap_or_else(|| "GET".to_string());
let request = HttpRequest {
workspace_id,
name,
method: method.to_uppercase(),
url,
..Default::default()
};
let mut request = HttpRequest { workspace_id, name, url, ..Default::default() };
// Only override the method when one was given; `HttpRequest::default()` is the
// single place the fallback ("GET") is defined.
if let Some(method) = method {
request.method = method.to_uppercase();
}
let created = ctx
.db()
+8
View File
@@ -49,6 +49,14 @@ impl CliContext {
std::process::exit(1);
}
};
// Guest: the desktop may have this DB open, so only what's safe beside a live session
let _ = yaak_lifecycle::on_launch(
&yaak_lifecycle::Host::guest(),
&query_manager.connect(),
&blob_manager,
);
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
Self {
+14 -2
View File
@@ -1,5 +1,7 @@
use crate::context::CliExecutionContext;
use arboard::Clipboard;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use console::Term;
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
use serde_json::Value;
@@ -11,7 +13,8 @@ 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_models::render::{render_grpc_request, render_http_request};
use yaak::response_body::FileResponseBodyStore;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::get_cookie_value_from_jar;
@@ -131,6 +134,7 @@ async fn build_plugin_reply(
match handle_shared_plugin_event(
&host_context.query_manager,
&FileResponseBodyStore::new(&host_context.query_manager),
&event.payload,
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
) {
@@ -223,7 +227,15 @@ async fn build_plugin_reply(
.await
{
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
SendHttpRequestResponse { http_response: result.response },
SendHttpRequestResponse {
http_response: result.response,
// Nothing saved this body, so the reply is the only
// place the plugin can get it.
body: result
.response_body
.returned_bytes()
.map(|b| BASE64_STANDARD.encode(b)),
},
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to send HTTP request in CLI: {err}"),
+3 -3
View File
@@ -10,9 +10,9 @@ chrono = { workspace = true, features = ["serde"] }
log = { workspace = true }
include_dir = "0.7"
r2d2 = "0.8.10"
r2d2_sqlite = "0.25.0"
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
r2d2_sqlite = "0.32"
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl"] }
-47
View File
@@ -1,47 +0,0 @@
[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-sse = { workspace = true }
yaak-templates = { workspace = true }
-94
View File
@@ -1,94 +0,0 @@
# Yaak Bridge
A headless binary that runs the real Yaak engine for a browser tab.
The tab is the unmodified Yaak UI. Everything a page cannot do — send an HTTP
request and see every response header, follow redirects, keep a cookie jar, run
the plugin runtime, read a response body off disk — happens in this process,
reached over local HTTP and a WebSocket.
This is the reason a browser Yaak can be credible at all. An in-page `fetch`
sender only ever sees the CORS-safelisted response headers: measured against
httpbin, a server that sent 8 headers yielded 2. Through the bridge the same
request yields all 8, plus the redirect chain, `Set-Cookie`, connection timings
and client certificates.
## Running it
Start the bridge:
```bash
cargo run -p yaak-server -- --port 9444
```
It binds `127.0.0.1` only and prints a bearer token that every route requires.
Then point a frontend at it. In dev, run Vite separately and tell it where the
bridge is:
```bash
YAAK_CLIENT_DEV_PORT=1472 VITE_YAAK_BRIDGE_URL=http://127.0.0.1:9444 npm run dev --workspace apps/yaak-client
```
Open `http://localhost:1472/?bridgeToken=<token>`. The token is consumed from
the query, kept for the session, and stripped from the address bar. Without one
you get a small connect form.
To serve the built frontend from the bridge itself instead, so there is only one
process:
```bash
npm run build --workspace apps/yaak-client
cargo run -p yaak-server -- --web-dir dist/apps/yaak-client
```
## Shape
| Route | What it carries |
| --- | --- |
| `POST /rpc` | The yaak-rpc envelope, the same one Tauri's `invoke` wraps on the desktop |
| `GET /events` | WebSocket. Server to client: `model_writes`, `stream_{id}`, toasts, plugin events. Client to server: the tab's location, and replies to prompts |
| `GET /responses/:id/body` | Response bodies, with Range support. Replaces reading `bodyPath` off disk |
| `GET /bridge/info` | Capabilities and the implemented command list |
Auth is a bearer token in the `Authorization` header, or a `token` query
parameter for the two requests the browser issues itself (the WebSocket, and
`<img src>`-style body loads). It is dev-grade and deliberately minimal: OTP
pairing and request encryption replace it, and `require_token` in `http.rs` is
where they go.
## Relationship to the other hosts
The engine crates under `crates/` are Tauri-free, and `crates-cli/yaak-cli`
already proved they run headless. This crate is structurally the CLI's
`CliContext` with an event hub attached — same `init_standalone` database, same
`PluginManager` over the same Node sidecar.
Two things are ported deliberately rather than invented:
- **Model writes** (`model_writes.rs`) keep the desktop's two paths: an
in-memory channel for writes this process made, and a poll of the
`model_changes` table so external writers — the CLI, the desktop app open on
the same database — show up live in the browser.
- **Plugin host requests** (`plugin_events.rs`) let `yaak::plugin_events`
answer everything that is only a database question, exactly as the CLI and the
desktop do. Only the host-specific arms differ, and where the CLI answers a
prompt from a TTY, the bridge round-trips it to the tab the way the desktop
round-trips it to a window.
## Known gaps
- **Settings is unreachable.** The desktop opens it via `cmd_new_child_window`.
A tab is one window, `multiWindow` is false, and this task did not add in-page
routing for it.
- **One tab at a time.** Model writes broadcast correctly to every connected
tab, so two tabs stay in sync for reads. What breaks is the session: the
tab's reported URL lives in a single slot, so with two tabs in different
workspaces a plugin's template render resolves against whichever attached
last. Prompts also broadcast, so a dialog raised by one tab appears in both.
- **No local files.** There is no file dialog, so request bodies from disk,
export, and save-response are unsupported. `cmd_import_data` is registered and
works, but only for a path typed by hand on the bridge's machine.
- **Command subset.** Roughly 40 of the desktop's 107 commands are implemented.
The rest return a structured "not supported on this host" error naming the
command; `UNSUPPORTED_COMMANDS` in `rpc/mod.rs` lists them.
-117
View File
@@ -1,117 +0,0 @@
//! The events channel: everything the browser tab would have received as a
//! Tauri window event.
//!
//! Two directions ride the same WebSocket. Server to client is a broadcast, so
//! `model_writes`, `stream_{id}` messages, toasts and plugin events all reach
//! the tab through one pipe. Client to server exists because some plugin host
//! requests are questions — a prompt round-trips through the UI and comes back
//! keyed by the originating event's id, exactly as the desktop app's
//! `call_frontend` does with window events.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::{broadcast, mpsc};
/// One frame in either direction: a name and a JSON payload.
///
/// Deliberately the same shape both ways, and the same shape as the desktop's
/// event payloads, so `platform.listen` on the browser side hands the payload
/// to callers unwrapped.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventFrame {
pub event: String,
#[serde(default)]
pub payload: serde_json::Value,
}
#[derive(Clone)]
pub struct EventHub {
outbound: broadcast::Sender<EventFrame>,
/// Listeners waiting on a named event from the client, keyed by event name.
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
}
/// A subscription to one named client-sent event. Deregisters on drop, so a
/// prompt that is never answered doesn't leak a listener for the process's life.
pub struct InboundSubscription {
event: String,
rx: mpsc::UnboundedReceiver<serde_json::Value>,
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
}
impl InboundSubscription {
pub async fn recv(&mut self) -> Option<serde_json::Value> {
self.rx.recv().await
}
}
impl Drop for InboundSubscription {
fn drop(&mut self) {
let mut inbound = match self.inbound.lock() {
Ok(inbound) => inbound,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(senders) = inbound.get_mut(&self.event) {
senders.retain(|tx| !tx.is_closed());
if senders.is_empty() {
inbound.remove(&self.event);
}
}
}
}
impl EventHub {
pub fn new() -> Self {
// Bounded: a tab that stops reading gets dropped frames rather than
// growing the server's memory without limit. Model writes are the
// high-volume case (imports, bulk deletes) and they arrive in batches.
let (outbound, _) = broadcast::channel(1024);
Self { outbound, inbound: Arc::new(Mutex::new(HashMap::new())) }
}
/// Send an event to every connected tab. Fails silently when none is
/// connected, which is the normal state before a browser attaches.
pub fn emit<T: Serialize>(&self, event: impl Into<String>, payload: &T) {
let payload = match serde_json::to_value(payload) {
Ok(payload) => payload,
Err(e) => {
log::warn!("Failed to serialize event payload: {e}");
return;
}
};
let _ = self.outbound.send(EventFrame { event: event.into(), payload });
}
pub fn subscribe(&self) -> broadcast::Receiver<EventFrame> {
self.outbound.subscribe()
}
/// Listen for a named event sent *by* the client.
pub fn subscribe_inbound(&self, event: impl Into<String>) -> InboundSubscription {
let event = event.into();
let (tx, rx) = mpsc::unbounded_channel();
let mut inbound = match self.inbound.lock() {
Ok(inbound) => inbound,
Err(poisoned) => poisoned.into_inner(),
};
inbound.entry(event.clone()).or_default().push(tx);
drop(inbound);
InboundSubscription { event, rx, inbound: Arc::clone(&self.inbound) }
}
/// Route a frame that arrived from a tab to whoever is waiting on it.
pub fn dispatch_inbound(&self, frame: EventFrame) {
let mut inbound = match self.inbound.lock() {
Ok(inbound) => inbound,
Err(poisoned) => poisoned.into_inner(),
};
let Some(senders) = inbound.get_mut(&frame.event) else {
return;
};
senders.retain(|tx| tx.send(frame.payload.clone()).is_ok());
if senders.is_empty() {
inbound.remove(&frame.event);
}
}
}
-354
View File
@@ -1,354 +0,0 @@
//! The front door: one HTTP surface for the browser tab.
//!
//! Three routes carry everything. `POST /rpc` is the yaak-rpc envelope, byte for
//! byte what the desktop puts inside Tauri's `invoke`. `GET /events` is the
//! WebSocket that replaces window events, in both directions. And
//! `GET /responses/:id/body` replaces reading `bodyPath` off disk, which a tab
//! cannot do.
use crate::events::EventFrame;
use crate::rpc::BridgeCtx;
use crate::session::SessionContext;
use crate::state::BridgeState;
use axum::body::Body;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, Request, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tower_http::cors::CorsLayer;
use yaak_rpc::{RpcRequest, RpcResponse, RpcRouter};
#[derive(Clone)]
pub struct AppState {
pub state: Arc<BridgeState>,
pub router: Arc<RpcRouter<BridgeCtx>>,
}
pub fn build_app(state: Arc<BridgeState>, router: Arc<RpcRouter<BridgeCtx>>) -> Router {
let app_state = AppState { state: state.clone(), router };
let api = Router::new()
.route("/bridge/info", get(bridge_info))
.route("/rpc", post(rpc_handler))
.route("/events", get(events_handler))
.route("/responses/:id/body", get(response_body))
.layer(axum::middleware::from_fn_with_state(state.clone(), require_token))
// The dev setup serves the frontend from Vite on another port, so the
// tab's origin is not the bridge's. Credentials never ride on cookies
// here — the token is explicit — so a permissive CORS layer is safe and
// is bounded by the token check that runs before it.
.layer(CorsLayer::permissive())
.with_state(app_state);
match std::env::var("YAAK_BRIDGE_WEB_DIR").ok() {
// Serving the built frontend makes the bridge a single process to run.
// `index.html` is the fallback because the router owns the paths.
Some(dir) => api.fallback_service(
tower_http::services::ServeDir::new(&dir)
.fallback(tower_http::services::ServeFile::new(format!("{dir}/index.html"))),
),
None => api,
}
}
// -- Auth --
#[derive(Debug, Deserialize)]
struct TokenQuery {
token: Option<String>,
}
/// Dev-grade bearer check on every route.
///
/// The header is the normal path. The query parameter exists because two of
/// these are opened by the browser itself — the WebSocket and the `<img src>`
/// pointing at a response body — and neither lets the page set headers.
///
/// This is the seam where OTP pairing and per-session keys go. It is not one
/// today: the token is a process-lifetime shared secret, and anything that can
/// read the tab's URL can read it.
async fn require_token(
State(state): State<Arc<BridgeState>>,
request: Request,
next: Next,
) -> Response {
let from_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|v| v.to_string());
let from_query = request
.uri()
.query()
.and_then(|q| serde_urlencoded::from_str::<TokenQuery>(q).ok())
.and_then(|q| q.token);
let presented = from_header.or(from_query);
match presented {
Some(token) if constant_time_eq(&token, &state.token) => next.run(request).await,
_ => (StatusCode::UNAUTHORIZED, "Invalid or missing bridge token").into_response(),
}
}
/// Compares without returning early on the first differing byte, so a caller
/// can't learn the token one character at a time.
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
// -- Routes --
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct BridgeInfo {
name: String,
version: String,
capabilities: crate::state::BridgeCapabilities,
/// Commands this build implements. The browser host uses it to fail fast
/// with a clear message instead of waiting for a round trip.
commands: Vec<String>,
}
async fn bridge_info(State(app): State<AppState>) -> Json<BridgeInfo> {
Json(BridgeInfo {
name: "Yaak Bridge".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
capabilities: app.state.capabilities.clone(),
commands: crate::rpc::implemented_commands(&app.router),
})
}
/// One envelope in, one out. Errors are carried inside the envelope, not as an
/// HTTP status, so the browser host can reject the caller's promise with the
/// backend's own message.
async fn rpc_handler(
State(app): State<AppState>,
Json(req): Json<RpcRequest>,
) -> Json<RpcResponse> {
let ctx = BridgeCtx { state: app.state.clone(), session: app.state.session.get() };
log::debug!("RPC {}", req.cmd);
let response = app.router.handle(req, &ctx).await;
if let RpcResponse::Error { error, .. } = &response {
log::warn!("RPC failed: {error}");
}
Json(response)
}
async fn events_handler(State(app): State<AppState>, ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(move |socket| handle_events_socket(socket, app))
}
/// The tab's first frame reports who and where it is; everything after that is
/// a reply to something the server asked.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AttachPayload {
label: String,
url: String,
}
async fn handle_events_socket(socket: WebSocket, app: AppState) {
use futures::{SinkExt, StreamExt};
let (mut sink, mut stream) = socket.split();
let mut outbound = app.state.events.subscribe();
// Server to client.
let send_task = tokio::spawn(async move {
loop {
match outbound.recv().await {
Ok(frame) => {
let Ok(text) = serde_json::to_string(&frame) else {
continue;
};
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
// A tab that fell behind has missed writes, and the model store
// would be silently stale. Close instead, so a reconnect
// re-reads the workspace from scratch.
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
log::warn!("Events client lagged by {n} frames; closing so it resyncs");
break;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
// Client to server.
let state = app.state.clone();
let recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = stream.next().await {
let Message::Text(text) = message else {
continue;
};
let Ok(frame) = serde_json::from_str::<EventFrame>(&text) else {
log::warn!("Ignoring malformed event frame from browser");
continue;
};
// `bridge_attach` is the browser telling us what the desktop would
// have read off the window: its label and its current URL.
if frame.event == "bridge_attach" {
match serde_json::from_value::<AttachPayload>(frame.payload.clone()) {
Ok(attach) => {
log::info!("Browser attached: {} at {}", attach.label, attach.url);
state.session.set(SessionContext {
label: attach.label,
url: attach.url,
});
}
Err(e) => log::warn!("Bad bridge_attach payload: {e}"),
}
continue;
}
state.events.dispatch_inbound(frame);
}
});
tokio::select! {
_ = send_task => {},
_ = recv_task => {},
}
}
#[derive(Debug, Deserialize)]
struct BodyQuery {
/// Present so the shared token extractor doesn't reject the request; the
/// value itself is checked in the middleware.
#[allow(dead_code)]
token: Option<String>,
}
/// Stream a response body, with Range support.
///
/// Keyed by response id rather than by path: the tab hands back a `bodyPath`
/// the backend gave it, and resolving that through the database means this
/// route can only ever serve a file the engine wrote, not an arbitrary path a
/// page asked for. Range matters because the video and audio viewers seek.
async fn response_body(
State(app): State<AppState>,
Path(id): Path<String>,
Query(_q): Query<BodyQuery>,
headers: HeaderMap,
) -> Response {
let location = match app.state.locate_response_body(&id) {
Ok(location) => location,
Err(_) => return (StatusCode::NOT_FOUND, "No such response").into_response(),
};
let Some(body_path) = location.path else {
return (StatusCode::NOT_FOUND, "Response has no body").into_response();
};
let mut file = match tokio::fs::File::open(&body_path).await {
Ok(file) => file,
Err(e) => return (StatusCode::NOT_FOUND, format!("Body unavailable: {e}")).into_response(),
};
let total = match file.metadata().await {
Ok(meta) => meta.len(),
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Body unreadable: {e}"))
.into_response();
}
};
let content_type = if location.content_type.is_empty() {
"application/octet-stream".to_string()
} else {
location.content_type
};
let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok()).and_then(parse_range);
let (start, end, status) = match range {
Some((start, end)) => {
let end = end.unwrap_or(total.saturating_sub(1)).min(total.saturating_sub(1));
if total == 0 || start > end {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
.body(Body::empty())
.unwrap();
}
(start, end, StatusCode::PARTIAL_CONTENT)
}
None => (0, total.saturating_sub(1), StatusCode::OK),
};
let length = if total == 0 { 0 } else { end - start + 1 };
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to seek body").into_response();
}
let mut buf = vec![0u8; length as usize];
if let Err(e) = file.read_exact(&mut buf).await {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read body: {e}"))
.into_response();
}
let mut builder = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, length);
if status == StatusCode::PARTIAL_CONTENT {
builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"));
}
builder.body(Body::from(buf)).unwrap()
}
/// Parses a single `bytes=start-end` range. Multi-range requests are not
/// answered as multipart; the first range is used, which browsers accept.
fn parse_range(value: &str) -> Option<(u64, Option<u64>)> {
let spec = value.strip_prefix("bytes=")?.split(',').next()?.trim();
let (start, end) = spec.split_once('-')?;
if start.is_empty() {
return None;
}
let start: u64 = start.parse().ok()?;
let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
Some((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ranges() {
assert_eq!(parse_range("bytes=0-499"), Some((0, Some(499))));
assert_eq!(parse_range("bytes=500-"), Some((500, None)));
assert_eq!(parse_range("bytes=0-99,200-299"), Some((0, Some(99))));
// Suffix ranges ("last 500 bytes") aren't supported; callers get the
// whole body, which is correct if wasteful.
assert_eq!(parse_range("bytes=-500"), None);
assert_eq!(parse_range("nonsense"), None);
}
#[test]
fn token_comparison_requires_exact_match() {
assert!(constant_time_eq("abc", "abc"));
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", "abcd"));
}
}
-121
View File
@@ -1,121 +0,0 @@
//! 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()
}
@@ -1,125 +0,0 @@
//! 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
}
@@ -1,582 +0,0 @@
//! 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
}
File diff suppressed because it is too large Load Diff
-142
View File
@@ -1,142 +0,0 @@
//! The bridge's RPC surface.
//!
//! Same envelope and same command names as the desktop, 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`.
//!
//! This is a subset — enough to boot, edit, send and inspect. Anything not
//! registered here still gets a well-formed answer: `unsupported_command`
//! turns it into an RPC error naming the command and this host, so the frontend
//! surfaces "not supported by the Yaak Bridge" instead of a bare failure.
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()
}
/// Every command the desktop has that the bridge does not implement.
///
/// Registered explicitly rather than left to fall through to "unknown command",
/// so the message says *why* — the frontend can tell a host that will never
/// support git from one that is simply out of date.
pub const UNSUPPORTED_COMMANDS: &[&str] = &[
// 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",
"cmd_new_main_window",
// gRPC and WebSocket sending.
"cmd_grpc_reflect",
"cmd_grpc_go",
"cmd_grpc_request_actions",
"cmd_call_grpc_request_action",
"cmd_delete_all_grpc_connections",
"cmd_ws_connect",
"cmd_ws_send",
"cmd_ws_close",
"cmd_ws_delete_connections",
"cmd_websocket_request_actions",
"cmd_call_websocket_request_action",
// Git-backed workspaces.
"cmd_git_checkout",
"cmd_git_branch",
"cmd_git_delete_branch",
"cmd_git_delete_remote_branch",
"cmd_git_merge_branch",
"cmd_git_rename_branch",
"cmd_git_status",
"cmd_git_branch_info",
"cmd_git_worktree_status",
"cmd_git_log",
"cmd_git_log_for_file",
"cmd_git_file_diff_for_commit",
"cmd_git_initialize",
"cmd_git_clone",
"cmd_git_commit",
"cmd_git_fetch_all",
"cmd_git_push",
"cmd_git_pull",
"cmd_git_pull_force_reset",
"cmd_git_pull_merge",
"cmd_git_add",
"cmd_git_unstage",
"cmd_git_reset_changes",
"cmd_git_restore_files",
"cmd_git_restore_file_from_commit",
"cmd_git_add_credential",
"cmd_git_remotes",
"cmd_git_add_remote",
"cmd_git_rm_remote",
"cmd_git_watch_worktree_status",
// Filesystem sync.
"cmd_sync_calculate",
"cmd_sync_calculate_fs",
"cmd_sync_apply",
"cmd_sync_watch",
// Workspace encryption.
"cmd_enable_encryption",
"cmd_disable_encryption",
"cmd_reveal_workspace_key",
"cmd_set_workspace_key",
// Things that need a local filesystem the tab can point at.
"cmd_export_data",
"cmd_save_response",
"cmd_save_base64_to_binary",
"cmd_plugins_install_from_directory",
// Desktop application management.
"cmd_restart",
"cmd_check_for_updates",
"cmd_dismiss_notification",
"cmd_send_feedback",
"cmd_plugins_search",
"cmd_plugins_install",
"cmd_plugins_uninstall",
"cmd_plugins_updates",
"cmd_plugins_update_all",
"cmd_reload_plugins",
];
pub fn unsupported_command(cmd: &str) -> RpcError {
RpcError {
message: format!("`{cmd}` is not supported on this host (Yaak Bridge)"),
}
}
-140
View File
@@ -1,140 +0,0 @@
//! What the connected tab is currently looking at.
//!
//! The desktop reads workspace, environment, cookie jar and request straight off
//! the window's URL (crates-tauri/yaak-tauri-utils/src/window.rs). A browser tab
//! runs the same router and so has the same URL, but the server cannot see it —
//! so the tab reports it, on connect and whenever it changes, and the same
//! parsing happens here.
//!
//! One session for the whole process: this slice serves a single tab. A second
//! tab overwrites the first's context rather than getting its own.
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, Default)]
pub struct SessionContext {
/// Identifies the tab, and lands in `UpdateSource::Window { label }` so
/// model-write echo suppression works exactly as it does on the desktop.
pub label: String,
pub url: String,
}
impl SessionContext {
pub fn workspace_id(&self) -> Option<String> {
let rest = self.url.split("/workspaces/").nth(1)?;
let id: String =
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
if id.is_empty() { None } else { Some(id) }
}
pub fn request_id(&self) -> Option<String> {
let rest = self.url.split("/requests/").nth(1)?;
let id: String =
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
if id.is_empty() { None } else { Some(id) }
}
pub fn environment_id(&self) -> Option<String> {
self.query_param("environment_id")
}
pub fn cookie_jar_id(&self) -> Option<String> {
self.query_param("cookie_jar_id")
}
fn query_param(&self, key: &str) -> Option<String> {
let query = self.url.split('?').nth(1)?;
let value = query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
if k != key {
return None;
}
Some(percent_decode(v))
})?;
// The router writes `environment_id=null` when nothing is selected.
// Neither of these is an id, and treating them as one sends a lookup
// for a model that cannot exist.
if value.is_empty() || value == "null" || value == "undefined" {
return None;
}
Some(value)
}
}
fn percent_decode(input: &str) -> String {
let bytes = input.replace('+', " ").into_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).to_string()
}
#[derive(Clone, Default)]
pub struct SessionStore {
inner: Arc<RwLock<SessionContext>>,
}
impl SessionStore {
pub fn get(&self) -> SessionContext {
match self.inner.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
pub fn set(&self, context: SessionContext) {
let mut guard = match self.inner.write() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
*guard = context;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(url: &str) -> SessionContext {
SessionContext { label: "tab".into(), url: url.into() }
}
#[test]
fn parses_ids_from_a_router_url() {
let c = ctx(
"http://localhost:1472/workspaces/wk_abc123/requests/rq_def456?environment_id=ev_1&cookie_jar_id=cj_2",
);
assert_eq!(c.workspace_id().as_deref(), Some("wk_abc123"));
assert_eq!(c.request_id().as_deref(), Some("rq_def456"));
assert_eq!(c.environment_id().as_deref(), Some("ev_1"));
assert_eq!(c.cookie_jar_id().as_deref(), Some("cj_2"));
}
#[test]
fn placeholder_query_values_are_not_ids() {
let c = ctx("http://localhost:1472/workspaces/wk_a?environment_id=null&cookie_jar_id=");
assert_eq!(c.environment_id(), None);
assert_eq!(c.cookie_jar_id(), None);
}
#[test]
fn missing_parts_are_none() {
let c = ctx("http://localhost:1472/");
assert_eq!(c.workspace_id(), None);
assert_eq!(c.request_id(), None);
assert_eq!(c.environment_id(), None);
assert_eq!(c.cookie_jar_id(), None);
}
}
-274
View File
@@ -1,274 +0,0 @@
//! 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(())
}
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "yaak-web"
version = "0.1.0"
edition = "2024"
publish = false
description = "The server behind Yaak in the browser: executes sends, and can serve the app"
# The send engine (yaak-http) and the model types it speaks (yaak-models, for
# HttpRequest / Cookie / HttpResponseEventData). Deliberately NOT yaak (the
# render + storage orchestration), yaak-plugins, or the RPC router: this binary
# opens no database, runs no plugins, and renders nothing. yaak-models comes
# along only because yaak-http's types are its types; nothing here calls into
# its query layer.
[[bin]]
name = "yaak-web"
path = "src/main.rs"
[dependencies]
async-trait = "0.1"
axum = "0.7"
base64 = "0.22.1"
bytes = "1.11.1"
clap = { version = "4.5", features = ["derive", "env"] }
env_logger = "0.11"
futures-util = "0.3"
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync", "io-util", "time", "net"] }
tower-http = { version = "0.6", features = ["compression-gzip", "compression-zstd", "cors", "fs"] }
ts-rs = { workspace = true }
url = "2"
uuid = { version = "1", features = ["v4"] }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
+219
View File
@@ -0,0 +1,219 @@
# yaak-web
The network half of Yaak in a browser — and, with `--serve`, the half that
hands the browser the app in the first place.
A tab can't see an HTTP response the way a desktop app can: CORS hides most
headers (2 of 8 in a typical response), redirects are followed silently, and
there is no timeline. So the tab renders the request and posts it here, and this
process puts it on the network with the desktop's own engine (`yaak-http`) and
streams back everything that happened — every header, every redirect hop, DNS
timing, the body — for the tab to store.
It is a **stateless executor**. It keeps nothing: no database, no files, no
sessions, no cookies between calls. Every byte it sees comes from the tab in the
request, and every byte it returns is stored by the tab. Restart it any time.
## Self-hosting it
One container, no configuration, nothing behind it:
```shell
docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
```
Open <http://localhost:8080>. The image carries the built web client and this
binary, which serves it — so the app and its sends are on one origin, and the
tab's send URL is a path (`/v1/http/send`) rather than an address anyone has to
configure. The image is `linux/amd64` and `linux/arm64`, built from
`Dockerfile.web` at the repo root.
Your data lives in your browser (SQLite compiled to wasm, in IndexedDB), not in
the container. The container is stateless: nothing is written to disk, so
upgrading is `docker pull` and nothing else.
Two settings are worth knowing about:
```shell
docker run -p 8080:8080 \
-e YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true \
-e YAAK_WEB_RATE_LIMIT_PER_MINUTE=0 \
ghcr.io/mountain-loop/yaak-web
```
- **`YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true`** lets sends reach loopback,
private and link-local addresses. Off by default, and it should stay off on
anything strangers can reach — see [What it refuses](#what-it-refuses-and-why).
Turn it on for an instance on your own network, where calling the API on the
next machine is the whole point. Note that "private" is relative to the
*container*: `127.0.0.1` is the container itself, and reaching the Docker
host means `host.docker.internal` (or `--network host`).
- **`YAAK_WEB_RATE_LIMIT_PER_MINUTE`** defaults to 120 sends per client IP,
which suits a public instance and not a team of your own; `0` disables it.
Behind a reverse proxy, add `YAAK_WEB_TRUST_FORWARDED_FOR=true` so the rate
limit sees real client addresses instead of its own — and only then, since
otherwise anyone can spoof the header. If the reverse proxy buffers responses,
tell it not to: sends are streamed, and the `X-Accel-Buffering: no` header this
binary sets is honoured by nginx-shaped ones.
## Running it from source
```shell
cargo run -p yaak-web -- --serve dist/apps/yaak-client
```
after a `YAAK_TARGET=web SKIP_WASM_BUILD=1 npx vp -C apps/yaak-client build`.
Without `--serve` it is the send executor alone, which is what the frontend
dev server wants:
```shell
cargo run -p yaak-web
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
```
A dev build looks for the server at `http://127.0.0.1:9227` (the Vite server is a
different origin and serves no `/v1`); a production build sends to its own
origin unless `VITE_YAAK_WEB_URL` was set when it was built.
## Configuration
Every flag has a `YAAK_WEB_*` environment variable, so a container needs no
arguments; `--help` lists them all.
| Flag | Default | What |
| --- | --- | --- |
| `--serve` | off | Also serve a built web client from this directory, on the same origin. |
| `--bind` | `127.0.0.1:9227` | Listen address. The image sets `0.0.0.0:8080`. |
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
| `--max-concurrent` | 256 | Sends in flight at once. |
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
## Serving the app
`--serve DIR` puts a file server behind the API routes: `/v1/*` is matched
first, everything else comes from `DIR`, and a path with no file behind it gets
`index.html` so the app's own routes survive a refresh. Responses are compressed
(gzip or zstd) on the fly. `/assets/*` is cached forever — Vite content-hashes
those names — and everything else is `no-cache`, so a new deploy arrives on the
next reload.
Serving files changes nothing about sending: the same rendered request, the same
destination policy, the same stateless executor. It exists so that a
self-hosted Yaak is one thing to run rather than two.
## Split deployments
The app and the sender can still be separate services — one CDN-hosted bundle and
one server elsewhere, or one server shared by several fronts. Then the bundle has
to be told where to send, at build time:
```shell
docker build -f Dockerfile.web \
--build-arg VITE_YAAK_WEB_URL=https://send.example.com .
```
and the server needs the CORS origins its callers use, since the requests are no
longer same-origin:
```shell
docker run -p 8080:8080 \
-e YAAK_WEB_ALLOWED_ORIGINS=https://yaak.example.com \
ghcr.io/mountain-loop/yaak-web \
yaak-web
```
The trailing `yaak-web` is a command override: the same image run without
`--serve`, so it executes sends and serves no app.
## What it refuses, and why
A hosted sender is, by construction, a machine that makes HTTP requests on
behalf of strangers. Left alone that is an open relay into whatever network it
sits on. So by default it refuses to connect to:
- loopback (`127/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`,
`fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
`fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges,
IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the well-known
NAT64 prefix, 6to4), and the whole NAT64 local-use range;
- anything not `http://` or `https://`.
The check runs **on the resolved addresses, after DNS**, for every hop of a
redirect chain, so a public hostname that points at an internal address is
caught, and so is a `Location:` header that points at one. It also refuses body
types that would read files on its own disk (`binary`, multipart file
fields), since no browser tab could legitimately mean those.
Refusals are logged with the reason. On a public instance (`web.yaak.app`, or
anything else strangers can reach) this must stay on: the machine's private
network is the host's, not the user's, so a `localhost` or LAN API is not the
user's to reach through it — the desktop app is what reaches those. On an
instance you run for yourself, that reasoning is inverted, and
`--allow-private-networks` inverts the policy with it. It allows every range
above, including `169.254.169.254`, so use it only where the network on the
other side is one the users are entitled to.
There is no authentication either way: an instance is anonymous, protected by
the per-client rate limit and the destination policy. Anything more (a shared
token, per-user quotas) is a later slice and would sit in front of `send_http`
in `main.rs`. Put TLS in front of a public instance.
## The wire
`POST /v1/http/send` with a JSON body:
```json
{
"request": { "url": "https://…", "method": "GET", "headers": [], "body": {}, "bodyType": null, "urlParameters": [] },
"settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true },
"cookies": [ ]
}
```
`request` is a Yaak `HttpRequest` in the desktop's own model shape with every
template already rendered by the tab; the server builds the URL, headers and
body from it exactly the way the desktop does after rendering. `cookies` is the
jar's contents (or `null` for no jar).
The reply is `application/x-ndjson`, one JSON frame per line, in the order things
happened:
| `type` | When | Carries |
| --- | --- | --- |
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
| `response` | once, when the final hop's headers arrive | status, all headers, request headers as sent, remote address, HTTP version, timing |
| `body` | as the body is read | a decompressed chunk, base64 |
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
| `error` | last, on failure | the reason, and any cookies collected before the failure |
Refusals that happen before anything is sent (a blocked destination, a bad body,
rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
`{"error": "…"}`, not streams.
Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by
construction, cancellable by closing the connection, readable with `curl`, and
needs no upgrade handling on either side. A WebSocket only earns its keep when
traffic is bidirectional, which a single send is not.
The TypeScript side of this contract is generated from `src/wire.rs` by ts-rs
into `bindings/` (run `cargo test -p yaak-web` after changing a frame)
and published to the tab as `@yaakapp-internal/web`, so a change to the
wire on one side is a type error on the other.
`GET /v1/health` reports the version and the effective limits.
## What comes later
Not built, by design, but the router is shaped for it: a WebSocket relay
(`/v1/ws/relay`) and a gRPC relay (`/v1/grpc/relay`) would be long-lived,
bidirectional endpoints on the same binary, behind the same destination policy
and limits. They differ from this endpoint in holding per-connection
in-memory state while a connection is open (never persisted), which brings
connection limits and a larger abuse surface — the reason they are separate
work.
+48
View File
@@ -0,0 +1,48 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieSameSite = "Strict" | "Lax" | "None";
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the Yaak server, and what the server reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
export type InheritedIntSetting = { enabled?: boolean, value: number, };
+64
View File
@@ -0,0 +1,64 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings } from "./gen_models";
/**
* One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
* camelCase like every model the tab stores.
*/
export type Frame = { "type": "event", event: HttpResponseEventData, } | { "type": "response", status: number, statusReason: string | null,
/**
* The URL that answered, after redirects.
*/
url: string, remoteAddr: string | null, version: string | null, headers: Array<HttpResponseHeader>,
/**
* The headers that were actually sent on the final hop, cookies and all.
*/
requestHeaders: Array<HttpResponseHeader>,
/**
* `Content-Length` as declared by the server, if it declared one.
*/
contentLength: number | null,
/**
* Milliseconds from the start of the send to the response head.
*/
elapsedHeaders: number,
/**
* Milliseconds spent in DNS on the last lookup, or zero.
*/
elapsedDns: number, } | { "type": "body", data: string, } | { "type": "done",
/**
* Milliseconds from the start of the send to the end of the body.
*/
elapsed: number,
/**
* Bytes of body relayed, after decompression.
*/
contentLength: number,
/**
* Bytes on the wire as declared by the server, or the relayed size when unknown.
*/
contentLengthCompressed: number,
/**
* The jar as the send left it, for the tab to persist. `None` when the tab sent none.
*/
cookies: Array<Cookie> | null, } | { "type": "error", message: string, cookies: Array<Cookie> | null, };
/**
* The body of `POST /v1/http/send`.
*/
export type SendRequest = {
/**
* The request to send, in the desktop's own model shape but with every template already
* rendered by the tab. The server builds the URL, headers and body from it exactly the way
* the desktop does after rendering.
*/
request: HttpRequest,
/**
* The resolved settings, values only. Where they came from is the tab's to record in
* its timeline; the server only needs to obey them.
*/
settings: HttpSendSettings,
/**
* The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
*/
cookies: Array<Cookie> | null, };
+4
View File
@@ -0,0 +1,4 @@
// The server's wire contract, generated by ts-rs from src/wire.rs
// (`cargo test -p yaak-web`). The tab imports these so a change to a
// frame on the Rust side is a type error in packages/platform/src/web.
export type { Frame, SendRequest } from "./bindings/gen_web";
+6
View File
@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/web",
"version": "1.0.0",
"private": true,
"main": "index.ts"
}
+67
View File
@@ -0,0 +1,67 @@
use clap::Parser;
use std::net::SocketAddr;
use std::path::PathBuf;
/// The server behind Yaak running in a browser.
///
/// The tab renders the request and owns the data; this binary puts the bytes on the network
/// and streams back what came back, and with `--serve` hands the browser the app as well.
/// Nothing is written to disk or a database.
#[derive(Parser, Debug, Clone)]
#[command(name = "yaak-web", version, about, long_about = None)]
pub struct Config {
/// Address to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container.
#[arg(long, env = "YAAK_WEB_BIND", default_value = "127.0.0.1:9227")]
pub bind: SocketAddr,
/// Also serve a built web client from this directory, on the same origin as the API.
/// Unknown paths fall back to `index.html` so the app's own routes work on a refresh.
/// Without this the binary is only the send executor.
#[arg(long, env = "YAAK_WEB_SERVE", value_name = "DIR")]
pub serve: Option<PathBuf>,
/// Allow sends to loopback, private and link-local addresses. Off by default, because a
/// server reachable by strangers is an open relay into the network it sits on. Turn it on
/// only for an instance whose users are meant to reach that network — a self-hosted one
/// on a LAN, where the point is to call the API on the next machine.
#[arg(long, env = "YAAK_WEB_ALLOW_PRIVATE_NETWORKS", default_value_t = false)]
pub allow_private_networks: bool,
/// Browser origins allowed to call this server (CORS), comma-separated. `*` allows any.
/// A local dev instance wants the Vite origin; a hosted instance wants its own web origin.
#[arg(
long,
env = "YAAK_WEB_ALLOWED_ORIGINS",
default_value = "*",
value_delimiter = ','
)]
pub allowed_origins: Vec<String>,
/// Largest request the server accepts from the tab (the rendered request JSON, body included).
#[arg(long, env = "YAAK_WEB_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
pub max_request_bytes: usize,
/// Largest upstream response body the server will relay before cutting the send off.
#[arg(long, env = "YAAK_WEB_MAX_RESPONSE_BYTES", default_value_t = 64 * 1024 * 1024)]
pub max_response_bytes: usize,
/// Ceiling on a send's timeout, in seconds. A request asking for longer (or for no timeout)
/// gets this instead.
#[arg(long, env = "YAAK_WEB_MAX_TIMEOUT_SECS", default_value_t = 60)]
pub max_timeout_secs: u64,
/// Sends allowed per client IP per minute. 0 disables the limit. This and the concurrency
/// cap are the whole of what protects an instance: there is no authentication.
#[arg(long, env = "YAAK_WEB_RATE_LIMIT_PER_MINUTE", default_value_t = 120)]
pub rate_limit_per_minute: u32,
/// Sends in flight at once across all clients.
#[arg(long, env = "YAAK_WEB_MAX_CONCURRENT", default_value_t = 256)]
pub max_concurrent: usize,
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only turn
/// this on behind a load balancer that sets the header; otherwise anyone can spoof their way
/// past the rate limit.
#[arg(long, env = "YAAK_WEB_TRUST_FORWARDED_FOR", default_value_t = false)]
pub trust_forwarded_for: bool,
}
+271
View File
@@ -0,0 +1,271 @@
//! Where a send may go.
//!
//! A hosted sender is, by construction, a machine that makes HTTP requests on
//! behalf of strangers. Left alone that is an open relay into whatever network
//! it sits on: cloud metadata endpoints, internal admin panels, the database
//! next door. So every destination is checked twice — once on the URL before a
//! hop is attempted (literal IPs, host allow/deny lists) and once on the
//! addresses a hostname actually resolves to, right before the connection is
//! made. The second check is the one that matters for a hostname pointing at
//! an internal address, and it runs on every redirect hop because the engine
//! resolves every hop.
use async_trait::async_trait;
use log::warn;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use tokio::sync::mpsc;
use url::Url;
use yaak_http::dns::AddressFilter;
use yaak_http::sender::{HttpResponse, HttpResponseEvent, HttpSender};
use yaak_http::types::SendableHttpRequest;
/// The destination policy, shared by every send: public addresses only, unless the operator
/// has said otherwise. A hosted server's "private network" is the cloud's, not the user's, so
/// the default is public-only; a self-hosted instance on a LAN can be told that its private
/// network *is* the user's, which is what `--allow-private-networks` means.
#[derive(Clone, Default)]
pub struct DestinationPolicy {
allow_private: bool,
}
impl DestinationPolicy {
pub fn new(allow_private: bool) -> Self {
Self { allow_private }
}
/// Check a URL before a hop is attempted: scheme and literal IPs. A hostname that passes
/// here still has its resolved addresses checked by [`Self::address_filter`].
pub fn check_url(&self, raw: &str) -> Result<(), String> {
let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?;
match url.scheme() {
"http" | "https" => {}
other => return Err(format!("Refusing to send over {other:?}; only http and https")),
}
let host = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?;
let host = host.trim_matches(|c| c == '[' || c == ']');
// A literal IP never reaches the resolver, so it is checked here. Hostnames are checked
// where their addresses become known.
if let Ok(ip) = host.parse::<IpAddr>() {
self.check_ip(ip)?;
}
Ok(())
}
/// The veto the engine's resolver applies to every address a hostname resolves to.
pub fn address_filter(&self) -> AddressFilter {
let policy = self.clone();
Arc::new(move |ip| policy.check_ip(ip))
}
pub fn check_ip(&self, ip: IpAddr) -> Result<(), String> {
if self.allow_private {
return Ok(());
}
match non_public_reason(ip) {
Some(reason) => Err(format!(
"Refusing to connect to {ip}: {reason}. This server only sends to public addresses"
)),
None => Ok(()),
}
}
}
/// Why an address is not a public internet address, or `None` if it is one.
///
/// Every range here is one a hosted relay must never be talked into reaching: the machine
/// itself, the network it sits on, and the link-local range where cloud metadata services
/// (169.254.169.254) live. IPv4 addresses carried inside fixed-layout IPv6 forms — IPv4-mapped,
/// the well-known NAT64 prefix, 6to4 — are unwrapped and judged as IPv4, since that is where
/// the packets end up; the NAT64 local-use range is refused outright. This is the stable-Rust
/// stand-in for `IpAddr::is_global`, which is still behind `#![feature(ip)]`; a network-specific
/// NAT64 prefix is not knowable here.
pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> {
match ip {
IpAddr::V4(v4) => non_public_v4(v4),
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return non_public_v4(v4);
}
if let Some(v4) = embedded_v4(&v6) {
return non_public_v4(v4);
}
if v6.is_loopback() {
Some("loopback")
} else if v6.is_unspecified() {
Some("unspecified")
} else if v6.is_unique_local() {
Some("unique local (fc00::/7)")
} else if v6.is_unicast_link_local() {
Some("link-local (fe80::/10)")
} else if v6.is_multicast() {
Some("multicast")
} else if v6.segments()[..3] == [0x64, 0xff9b, 1] {
Some("NAT64 local-use (64:ff9b:1::/48)")
} else if v6.segments()[..4] == [0x100, 0, 0, 0] {
Some("discard-only (100::/64)")
} else if (v6.segments()[0] & 0xffc0) == 0xfec0 {
Some("site-local (fec0::/10)")
} else if v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8 {
Some("documentation (2001:db8::/32)")
} else {
None
}
}
}
}
fn non_public_v4(v4: Ipv4Addr) -> Option<&'static str> {
let o = v4.octets();
if v4.is_loopback() {
Some("loopback (127.0.0.0/8)")
} else if v4.is_private() {
Some("private (10/8, 172.16/12, 192.168/16)")
} else if v4.is_link_local() {
Some("link-local (169.254.0.0/16, where cloud metadata lives)")
} else if v4.is_unspecified() || o[0] == 0 {
Some("this network (0.0.0.0/8)")
} else if o[0] == 100 && (o[1] & 0xc0) == 64 {
Some("carrier-grade NAT (100.64.0.0/10)")
} else if v4.is_broadcast() {
Some("broadcast")
} else if v4.is_multicast() {
Some("multicast (224.0.0.0/4)")
} else if o[0] >= 240 {
Some("reserved (240.0.0.0/4)")
} else if v4.is_documentation() {
Some("documentation")
} else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
Some("IETF protocol assignments (192.0.0.0/24)")
} else if o[0] == 198 && (o[1] & 0xfe) == 18 {
Some("benchmarking (198.18.0.0/15)")
} else {
None
}
}
/// The IPv4 address an IPv6 address stands for, when it is one of the fixed-layout translation
/// forms: the NAT64 well-known prefix (64:ff9b::/96) or 6to4 (2002::/16, IPv4 in the next 32
/// bits). The NAT64 local-use range (64:ff9b:1::/48) is a pool operators carve their own
/// prefix from, at a length only they know, so it is refused wholesale in [`non_public_reason`]
/// rather than decoded — the same call `std`'s (still unstable) `Ipv6Addr::is_global` makes.
fn embedded_v4(v6: &Ipv6Addr) -> Option<Ipv4Addr> {
let s = v6.segments();
let o = v6.octets();
if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|x| *x == 0) {
return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
}
if s[0] == 0x2002 {
return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5]));
}
None
}
/// An [`HttpSender`] that checks each hop's URL against the policy before delegating.
///
/// The engine's redirect loop calls the sender once per hop with the hop's URL, so wrapping
/// the sender is what makes `Location:` headers subject to the same rules as the first URL —
/// including a redirect to a literal internal IP, which the resolver would never see.
pub struct GuardedSender<S> {
inner: S,
policy: DestinationPolicy,
}
impl<S: HttpSender> GuardedSender<S> {
pub fn new(inner: S, policy: DestinationPolicy) -> Self {
Self { inner, policy }
}
}
#[async_trait]
impl<S: HttpSender> HttpSender for GuardedSender<S> {
async fn send(
&self,
request: SendableHttpRequest,
event_tx: mpsc::Sender<HttpResponseEvent>,
) -> yaak_http::error::Result<HttpResponse> {
if let Err(reason) = self.policy.check_url(&request.url) {
warn!("Refused {} {}: {reason}", request.method, request.url);
return Err(yaak_http::error::Error::RequestError(reason));
}
self.inner.send(request, event_tx).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn refuses_the_ranges_a_relay_must_never_reach() {
for addr in [
"127.0.0.1",
"127.9.9.9",
"10.0.0.1",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"169.254.169.254",
"169.254.0.1",
"0.0.0.0",
"100.64.0.1",
"255.255.255.255",
"224.0.0.1",
"240.0.0.1",
"::1",
"::",
"fc00::1",
"fd12::1",
"fe80::1",
"::ffff:127.0.0.1",
"::ffff:169.254.169.254",
"64:ff9b::7f00:1",
"ff02::1",
] {
assert!(non_public_reason(ip(addr)).is_some(), "{addr} should be refused");
}
}
#[test]
fn allows_public_addresses() {
for addr in [
"1.1.1.1",
"8.8.8.8",
"93.184.216.34",
"172.32.0.1",
"2606:4700:4700::1111",
] {
assert!(non_public_reason(ip(addr)).is_none(), "{addr} should be allowed");
}
}
#[test]
fn literal_private_addresses_in_urls_are_refused() {
let policy = DestinationPolicy::new(false);
assert!(policy.check_url("http://127.0.0.1/").is_err());
assert!(policy.check_url("http://[::1]/").is_err());
assert!(policy.check_url("http://169.254.169.254/latest/meta-data").is_err());
}
#[test]
fn allow_private_networks_opens_the_local_ranges_but_not_other_schemes() {
let policy = DestinationPolicy::new(true);
assert!(policy.check_url("http://127.0.0.1/").is_ok());
assert!(policy.check_ip(ip("10.0.0.1")).is_ok());
assert!(policy.check_ip(ip("169.254.169.254")).is_ok());
assert!(policy.check_url("file:///etc/passwd").is_err());
}
#[test]
fn only_http_schemes() {
let policy = DestinationPolicy::new(false);
assert!(policy.check_url("ftp://example.com/").is_err());
assert!(policy.check_url("file:///etc/passwd").is_err());
assert!(policy.check_url("https://example.com/").is_ok());
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Per-client rate limiting, kept deliberately small.
//!
//! One token bucket per client IP, refilled continuously, in a mutex-guarded
//! map that is swept of idle entries as it goes. Good enough to keep one
//! caller from monopolising a hosted instance; not a substitute for whatever
//! sits in front of it in production.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub struct RateLimiter {
per_minute: u32,
buckets: Mutex<HashMap<IpAddr, Bucket>>,
}
struct Bucket {
tokens: f64,
last: Instant,
}
impl RateLimiter {
/// `per_minute == 0` disables limiting.
pub fn new(per_minute: u32) -> Self {
Self { per_minute, buckets: Mutex::new(HashMap::new()) }
}
/// Take one token for `client`, or say how long until one is available.
pub fn check(&self, client: IpAddr) -> Result<(), Duration> {
if self.per_minute == 0 {
return Ok(());
}
let capacity = self.per_minute as f64;
let per_second = capacity / 60.0;
let now = Instant::now();
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
// Sweep buckets that have been idle long enough to be full again; there is nothing
// to remember about them.
if buckets.len() > 1024 {
buckets.retain(|_, b| now.duration_since(b.last).as_secs_f64() * per_second < capacity);
}
let bucket = buckets.entry(client).or_insert(Bucket { tokens: capacity, last: now });
let elapsed = now.duration_since(bucket.last).as_secs_f64();
bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
bucket.last = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
Ok(())
} else {
let wait = (1.0 - bucket.tokens) / per_second;
Err(Duration::from_secs_f64(wait.max(0.001)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_full_bucket_then_a_wait() {
let limiter = RateLimiter::new(3);
let ip: IpAddr = "203.0.113.5".parse().unwrap();
assert!(limiter.check(ip).is_ok());
assert!(limiter.check(ip).is_ok());
assert!(limiter.check(ip).is_ok());
let wait = limiter.check(ip).expect_err("fourth call in a burst should wait");
assert!(wait > Duration::ZERO && wait <= Duration::from_secs(20));
}
#[test]
fn clients_are_independent_and_zero_disables() {
let limiter = RateLimiter::new(1);
let a: IpAddr = "203.0.113.5".parse().unwrap();
let b: IpAddr = "203.0.113.6".parse().unwrap();
assert!(limiter.check(a).is_ok());
assert!(limiter.check(a).is_err());
assert!(limiter.check(b).is_ok());
let unlimited = RateLimiter::new(0);
for _ in 0..1000 {
assert!(unlimited.check(a).is_ok());
}
}
}
+251
View File
@@ -0,0 +1,251 @@
//! yaak-web: the network half of Yaak in a browser.
//!
//! A tab can't see a response the way a desktop app can — CORS hides most
//! headers, redirects are followed silently, there is no timeline. So the tab
//! renders the request and hands it here; this process puts it on the network
//! with the desktop's own engine and streams back everything that happened,
//! for the tab to store. It keeps nothing: no database, no files, no session.
//!
//! One binary, configured by flags or `YAAK_WEB_*` environment variables.
//! See README.md for running and deploying it, and `guard.rs` for what it
//! refuses to talk to.
mod config;
mod guard;
mod limits;
mod send;
mod wire;
use axum::Router;
use axum::body::Body;
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use clap::Parser;
use config::Config;
use guard::DestinationPolicy;
use limits::RateLimiter;
use log::{info, warn};
use send::{Refusal, SendLimits};
use serde_json::json;
use std::net::{IpAddr, SocketAddr};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use wire::SendRequest;
#[derive(Clone)]
struct AppState {
config: Arc<Config>,
limits: Arc<SendLimits>,
rate_limiter: Arc<RateLimiter>,
in_flight: Arc<Semaphore>,
}
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = Config::parse();
let policy = DestinationPolicy::new(config.allow_private_networks);
if config.allow_private_networks {
warn!(
"Sends to loopback, private and link-local addresses are ALLOWED. Only run this way \
on an instance strangers cannot reach"
);
}
let state = AppState {
limits: Arc::new(SendLimits {
policy,
max_response_bytes: config.max_response_bytes,
max_timeout: Duration::from_secs(config.max_timeout_secs),
}),
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
config: Arc::new(config),
};
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([header::CONTENT_TYPE])
.allow_origin(allowed_origins(&state.config.allowed_origins));
let api = Router::new()
.route("/v1/health", get(health))
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay`
// on the same router, behind the same policy, limits and auth. Not built; see README.
.route("/v1/http/send", post(send_http))
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
.layer(cors)
.with_state(state.clone());
let app = match &state.config.serve {
Some(dir) => {
info!("Serving the web client from {}", dir.display());
api.merge(web_router(dir))
}
None => api,
};
let bind = state.config.bind;
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
eprintln!("Failed to bind {bind}: {e}");
std::process::exit(1);
});
info!(
"yaak-web listening on http://{bind} (rate limit: {}/min)",
state.config.rate_limit_per_minute,
);
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
info!("Shutting down");
})
.await
.expect("server error");
}
/// The built web client, served on the same origin as the API.
///
/// This is what makes a single container zero-configuration: the tab's send URL is a path on
/// the page's own origin, so there is no CORS, no second service and no URL to bake in. It is
/// only a file server — a send behaves exactly as it does without this flag.
///
/// Merged as a fallback, so the `/v1` routes are matched first and a request that matches no
/// file at all gets `index.html` (the app routes client-side; a deep link must survive a
/// refresh).
fn web_router(dir: &Path) -> Router {
let index = ServeFile::new(dir.join("index.html"));
Router::new()
// `fallback`, not `not_found_service`: the app's own routes are real pages, so
// index.html is served with the 200 the browser expects, not a 404 carrying HTML.
.fallback_service(ServeDir::new(dir).fallback(index))
.layer(middleware::from_fn(cache_control))
.layer(CompressionLayer::new())
}
/// Vite gives everything in `/assets` a content-hashed name, so those can be cached forever.
/// Everything else — `index.html` above all, including the copy served for an unknown path —
/// must be revalidated, or a browser keeps serving the deploy before last.
async fn cache_control(req: Request, next: Next) -> Response {
let hashed_name = req.uri().path().starts_with("/assets/");
let mut res = next.run(req).await;
if !res.status().is_success() {
return res;
}
let is_html = res
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("text/html"));
let value = if hashed_name && !is_html {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
res
}
fn allowed_origins(origins: &[String]) -> AllowOrigin {
if origins.iter().any(|o| o.trim() == "*") {
return AllowOrigin::any();
}
let parsed: Vec<HeaderValue> =
origins.iter().filter_map(|o| HeaderValue::from_str(o.trim()).ok()).collect();
AllowOrigin::list(parsed)
}
async fn health(State(state): State<AppState>) -> impl IntoResponse {
Json(json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"maxResponseBytes": state.config.max_response_bytes,
"maxTimeoutSecs": state.config.max_timeout_secs,
}))
}
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
let message = message.into();
(status, Json(json!({ "error": message }))).into_response()
}
/// The client's address for rate limiting: the socket peer, or the first `X-Forwarded-For`
/// hop when the operator has said the header can be trusted.
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
if config.trust_forwarded_for
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
&& let Some(first) = forwarded.split(',').next()
&& let Ok(ip) = first.trim().parse::<IpAddr>()
{
return ip;
}
peer.ip()
}
async fn send_http(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(body): Json<SendRequest>,
) -> Response {
let ip = client_ip(&state.config, &headers, peer);
if let Err(wait) = state.rate_limiter.check(ip) {
warn!("Rate limited {ip}");
let mut res = error_response(
StatusCode::TOO_MANY_REQUESTS,
format!("Rate limit reached; try again in {}s", wait.as_secs().max(1)),
);
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(wait.as_secs().max(1)));
return res;
}
let Ok(permit) = state.in_flight.clone().try_acquire_owned() else {
warn!("At capacity; refusing {ip}");
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This server is at capacity");
};
let prepared = match send::prepare(state.limits.clone(), body).await {
Ok(p) => p,
Err(Refusal::Unsupported(m)) => return error_response(StatusCode::BAD_REQUEST, m),
Err(Refusal::Invalid(m)) => return error_response(StatusCode::BAD_REQUEST, m),
Err(Refusal::Destination(m)) => {
warn!("Refused send from {ip}: {m}");
return error_response(StatusCode::FORBIDDEN, m);
}
};
let description = prepared.describe();
info!("{ip} -> {description}");
let started = Instant::now();
let (tx, rx) = tokio::sync::mpsc::channel(send::FRAME_CHANNEL_CAPACITY);
tokio::spawn(async move {
prepared.run(tx).await;
send::log_outcome(&description, started, "finished");
drop(permit);
});
let stream = tokio_stream_from(rx);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/x-ndjson")
.header(header::CACHE_CONTROL, "no-store")
// Some reverse proxies buffer streamed responses unless told not to
.header("x-accel-buffering", "no")
.body(Body::from_stream(stream))
.expect("valid response")
}
fn tokio_stream_from<T: Send + 'static>(
mut rx: tokio::sync::mpsc::Receiver<T>,
) -> impl futures_util::Stream<Item = T> + Send + 'static {
futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx))
}
+357
View File
@@ -0,0 +1,357 @@
//! The one thing this binary does: execute a rendered request and stream back what happened.
//!
//! This is the "execute" half of the desktop's `send_http_request` — the part after rendering
//! and before storage — driven through the same `HttpTransaction` the desktop drives, with the
//! same redirect loop, cookie jar, decompression and timeline events. Everything the desktop
//! would write to its database is written to the reply stream instead, and the tab stores it.
use crate::guard::{DestinationPolicy, GuardedSender};
use crate::wire::{Frame, SendRequest};
use base64::Engine;
use bytes::Bytes;
use log::{info, warn};
use std::convert::Infallible;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::io::AsyncReadExt;
use tokio::sync::{mpsc, watch};
use yaak_http::client::{HttpConnectionOptions, HttpConnectionProxySetting};
use yaak_http::cookies::CookieStore;
use yaak_http::sender::{HttpResponseEvent, ReqwestSender};
use yaak_http::transaction::HttpTransaction;
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
use yaak_models::models::HttpResponseHeader;
/// How many frames may sit unread by the client before body reading pauses. Backpressure, so a
/// slow tab slows the upstream read rather than filling memory.
pub const FRAME_CHANNEL_CAPACITY: usize = 64;
const EVENT_CHANNEL_CAPACITY: usize = 256;
const BODY_READ_CHUNK: usize = 64 * 1024;
/// What a send needs from the process, beyond the request itself.
pub struct SendLimits {
pub policy: DestinationPolicy,
pub max_response_bytes: usize,
pub max_timeout: Duration,
}
/// Why a send was refused before anything was put on the network. Distinct from a failure
/// mid-stream: these become a plain HTTP error, not a stream with an error frame.
#[derive(Debug)]
pub enum Refusal {
/// The request asks for something a browser-originated send cannot mean.
Unsupported(String),
/// The destination is not one this server will talk to.
Destination(String),
/// The request could not be turned into something sendable.
Invalid(String),
}
pub type FrameSender = mpsc::Sender<Result<Bytes, Infallible>>;
/// Check and prepare a send, then hand back the task that runs it. Refusals happen here, before
/// the caller has committed to a streaming response.
pub async fn prepare(limits: Arc<SendLimits>, send: SendRequest) -> Result<PreparedSend, Refusal> {
let request = send.request;
// The engine reads files for these body types. There are no files here that a browser tab
// could legitimately mean, and letting a request name a path on this machine would be a
// local file read for anyone who can reach the server.
if request.body_type.as_deref() == Some("binary") {
return Err(Refusal::Unsupported(
"Binary file bodies can't be sent from the browser: the server has no access to your files"
.to_string(),
));
}
if request.body_type.as_deref() == Some("multipart/form-data") {
let names_a_file =
request.body.get("form").and_then(|f| f.as_array()).is_some_and(|entries| {
entries.iter().any(|e| {
e.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true)
&& e.get("file").and_then(|v| v.as_str()).is_some_and(|f| !f.is_empty())
})
});
if names_a_file {
return Err(Refusal::Unsupported(
"Multipart file fields can't be sent from the browser: the server has no access to your files"
.to_string(),
));
}
}
// The tab's requested timeout, capped. Zero means "none", which here means the cap.
let requested = if send.settings.timeout_ms > 0 {
Some(Duration::from_millis(send.settings.timeout_ms as u64))
} else {
None
};
let timeout = requested.map_or(limits.max_timeout, |t| t.min(limits.max_timeout));
let timeout_capped = requested.is_none_or(|t| t > limits.max_timeout);
let sendable = SendableHttpRequest::from_http_request(
&request,
SendableHttpRequestOptions {
timeout: Some(timeout),
follow_redirects: send.settings.follow_redirects,
},
)
.await
.map_err(|e| Refusal::Invalid(e.to_string()))?;
// The first hop, checked up front so a bad destination is a clean refusal rather than a
// stream that opens and immediately errors. Every later hop is checked by GuardedSender.
limits.policy.check_url(&sendable.url).map_err(Refusal::Destination)?;
Ok(PreparedSend {
limits,
sendable,
settings: send.settings,
cookies: send.cookies,
timeout,
timeout_capped,
})
}
pub struct PreparedSend {
limits: Arc<SendLimits>,
sendable: SendableHttpRequest,
settings: yaak_models::models::HttpSendSettings,
cookies: Option<Vec<yaak_models::models::Cookie>>,
timeout: Duration,
timeout_capped: bool,
}
impl PreparedSend {
pub fn describe(&self) -> String {
format!("{} {}", self.sendable.method, self.sendable.url)
}
/// Run the send, writing frames to `frames` until the terminal frame. Returns when the
/// stream is complete or the client has gone away.
pub async fn run(mut self, frames: FrameSender) {
let cookie_store = self.cookies.take().map(CookieStore::from_cookies);
let store_for_result = cookie_store.clone();
let outcome = self.execute(frames.clone(), cookie_store).await;
let cookies = store_for_result.as_ref().map(|s| s.get_all_cookies());
let terminal = match outcome {
Ok(done) => Frame::Done {
elapsed: done.elapsed,
content_length: done.content_length,
content_length_compressed: done.content_length_compressed,
cookies,
},
Err(message) => Frame::Error { message, cookies },
};
let _ = write_frame(&frames, &terminal).await;
}
async fn execute(
self,
frames: FrameSender,
cookie_store: Option<CookieStore>,
) -> Result<DoneStats, String> {
let limits = self.limits;
let (client, resolver) = HttpConnectionOptions {
id: uuid::Uuid::new_v4().to_string(),
validate_certificates: self.settings.validate_certificates,
// The proxy connects directly. Going through a system proxy would move DNS, and
// therefore the address check, somewhere this process can't see.
proxy: HttpConnectionProxySetting::Disabled,
client_certificate: None,
dns_overrides: Vec::new(),
address_filter: Some(limits.policy.address_filter()),
}
.build_client()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
// Timeline events go into the same frame stream as everything else, as they happen.
// The desktop persists them from a task like this one; here the task serialises them.
let (event_tx, mut event_rx) = mpsc::channel::<HttpResponseEvent>(EVENT_CHANNEL_CAPACITY);
resolver.set_event_sender(Some(event_tx.clone())).await;
let dns_elapsed = Arc::new(AtomicU64::new(0));
let event_frames = frames.clone();
let event_dns = dns_elapsed.clone();
let event_task = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if let HttpResponseEvent::DnsResolved { duration, .. } = &event {
event_dns.store(*duration, Ordering::Relaxed);
}
let frame = Frame::Event { event: event.into() };
if write_frame(&event_frames, &frame).await.is_err() {
break;
}
}
});
// Cancellation: the client hanging up, or the overall deadline. The deadline exists
// because a per-hop timeout times each hop separately; ten slow redirects must not add
// up to ten timeouts.
let (cancel_tx, cancel_rx) = watch::channel(false);
let deadline = self.timeout * 2 + Duration::from_secs(5);
let deadline_cancel = cancel_tx.clone();
let deadline_task = tokio::spawn(async move {
tokio::time::sleep(deadline).await;
let _ = deadline_cancel.send(true);
});
let hangup_frames = frames.clone();
let hangup_task = tokio::spawn(async move {
hangup_frames.closed().await;
let _ = cancel_tx.send(true);
});
if self.timeout_capped {
let _ = event_tx.try_send(HttpResponseEvent::Info(format!(
"Timeout set to {:?} (this server's ceiling)",
self.timeout
)));
}
let sender = GuardedSender::new(ReqwestSender::with_client(client), limits.policy.clone());
let transaction = match cookie_store {
Some(store) => HttpTransaction::with_cookie_behavior(
sender,
store,
self.settings.send_cookies,
self.settings.store_cookies,
),
None => HttpTransaction::new(sender),
};
let started_at = Instant::now();
let result = transaction
.execute_with_cancellation(self.sendable, cancel_rx.clone(), event_tx.clone())
.await;
resolver.set_event_sender(None).await;
let mut response = match result {
Ok(response) => response,
Err(err) => {
drop(event_tx);
let _ = event_task.await;
deadline_task.abort();
hangup_task.abort();
return Err(describe_error(&err));
}
};
let elapsed_headers = started_at.elapsed().as_millis() as u64;
let head = Frame::Response {
status: response.status,
status_reason: response.status_reason.clone(),
url: response.url.clone(),
remote_addr: response.remote_addr.clone(),
version: response.version.clone(),
headers: to_wire_headers(&response.headers),
request_headers: to_wire_headers(&response.request_headers),
content_length: response.content_length,
elapsed_headers,
elapsed_dns: dns_elapsed.load(Ordering::Relaxed),
};
write_frame(&frames, &head).await.map_err(|_| "Client went away".to_string())?;
let declared_length = response.content_length;
let mut body = response
.into_body_stream()
.map_err(|e| format!("Failed to read response body: {e}"))?;
let mut buf = vec![0u8; BODY_READ_CHUNK];
let mut total: usize = 0;
let mut cancel_rx = cancel_rx;
let base64 = base64::engine::general_purpose::STANDARD;
let read_result: Result<(), String> = loop {
if *cancel_rx.borrow() {
break Err("Request canceled".to_string());
}
let read = tokio::select! {
biased;
_ = cancel_rx.changed() => break Err("Request canceled".to_string()),
r = body.read(&mut buf) => r,
};
match read {
Ok(0) => break Ok(()),
Ok(n) => {
total += n;
if total > limits.max_response_bytes {
break Err(format!(
"Response body exceeds this server's limit of {} bytes",
limits.max_response_bytes
));
}
let frame = Frame::Body { data: base64.encode(&buf[..n]) };
if write_frame(&frames, &frame).await.is_err() {
break Err("Client went away".to_string());
}
}
Err(e) => break Err(format!("Failed to read response body: {e}")),
}
};
drop(body);
// Let the timeline drain before the terminal frame, so nothing arrives after "done".
drop(event_tx);
let _ = event_task.await;
deadline_task.abort();
hangup_task.abort();
read_result?;
Ok(DoneStats {
elapsed: started_at.elapsed().as_millis() as u64,
content_length: total as u64,
content_length_compressed: declared_length.unwrap_or(total as u64),
})
}
}
/// A send error as a sentence, not a debug dump.
///
/// A connection error from reqwest arrives wrapped several layers deep, and the layer that
/// says something useful — "Refusing to connect to ::1: loopback" — is the innermost. The
/// desktop shows the outer `Debug`; a stranger reading its reply deserves the reason.
fn describe_error(err: &yaak_http::error::Error) -> String {
match err {
yaak_http::error::Error::Client(e) => {
let mut leaf: &dyn std::error::Error = e;
while let Some(next) = leaf.source() {
leaf = next;
}
let outer = e.to_string();
let inner = leaf.to_string();
if inner == outer { outer } else { format!("{outer}: {inner}") }
}
yaak_http::error::Error::RequestError(message) => format!("Request failed: {message}"),
other => other.to_string(),
}
}
struct DoneStats {
elapsed: u64,
content_length: u64,
content_length_compressed: u64,
}
fn to_wire_headers(headers: &[(String, String)]) -> Vec<HttpResponseHeader> {
headers
.iter()
.map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })
.collect()
}
async fn write_frame(frames: &FrameSender, frame: &Frame) -> Result<(), ()> {
let mut line = match serde_json::to_vec(frame) {
Ok(v) => v,
Err(e) => {
warn!("Failed to serialize frame: {e}");
return Err(());
}
};
line.push(b'\n');
frames.send(Ok(Bytes::from(line))).await.map_err(|_| ())
}
/// Log a finished send at info: destination, outcome, and how long, never the content.
pub fn log_outcome(description: &str, started: Instant, outcome: &str) {
info!("{description} -> {outcome} in {:?}", started.elapsed());
}
+97
View File
@@ -0,0 +1,97 @@
//! What crosses the wire between a tab and this server.
//!
//! One `POST /v1/http/send` carries a request the tab has already rendered —
//! templates resolved, inheritance applied — plus the send settings and the
//! cookies the send starts with. The reply is a stream of newline-delimited
//! JSON frames: timeline events as they happen, the response head as soon as
//! headers arrive, body chunks as they are read, and one terminal frame.
//!
//! Nothing here names a workspace, a request id, or a response id. The server
//! does not know what the tab will call this response; it only knows what came
//! back.
//!
//! The TypeScript side of this contract is generated from these types into
//! `bindings/` (`cargo test -p yaak-web`) and published to the tab as
//! `@yaakapp-internal/web`, so a change here is a type error there.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yaak_models::models::{
Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings,
};
/// The body of `POST /v1/http/send`.
#[derive(Deserialize, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_web.ts")]
pub struct SendRequest {
/// The request to send, in the desktop's own model shape but with every template already
/// rendered by the tab. The server builds the URL, headers and body from it exactly the way
/// the desktop does after rendering.
pub request: HttpRequest,
/// The resolved settings, values only. Where they came from is the tab's to record in
/// its timeline; the server only needs to obey them.
pub settings: HttpSendSettings,
/// The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
#[serde(default)]
pub cookies: Option<Vec<Cookie>>,
}
/// One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
/// camelCase like every model the tab stores.
#[derive(Serialize, Debug, TS)]
#[serde(
tag = "type",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
#[ts(export, export_to = "gen_web.ts")]
pub enum Frame {
/// A timeline event, in the same shape the desktop stores. Interleaved with everything
/// else in the order the engine produced it.
Event { event: HttpResponseEventData },
/// The response head. Sent once, as soon as the final hop's headers are in — before any of
/// the body — so the tab can show status and headers while the body streams.
Response {
status: u16,
status_reason: Option<String>,
/// The URL that answered, after redirects.
url: String,
remote_addr: Option<String>,
version: Option<String>,
headers: Vec<HttpResponseHeader>,
/// The headers that were actually sent on the final hop, cookies and all.
request_headers: Vec<HttpResponseHeader>,
/// `Content-Length` as declared by the server, if it declared one.
#[ts(type = "number | null")]
content_length: Option<u64>,
/// Milliseconds from the start of the send to the response head.
#[ts(type = "number")]
elapsed_headers: u64,
/// Milliseconds spent in DNS on the last lookup, or zero.
#[ts(type = "number")]
elapsed_dns: u64,
},
/// A piece of the response body, decompressed, base64-encoded.
Body { data: String },
/// The send finished. The last frame on a successful stream.
Done {
/// Milliseconds from the start of the send to the end of the body.
#[ts(type = "number")]
elapsed: u64,
/// Bytes of body relayed, after decompression.
#[ts(type = "number")]
content_length: u64,
/// Bytes on the wire as declared by the server, or the relayed size when unknown.
#[ts(type = "number")]
content_length_compressed: u64,
/// The jar as the send left it, for the tab to persist. `None` when the tab sent none.
cookies: Option<Vec<Cookie>>,
},
/// The send failed. The last frame on a failed stream. Cookies collected before the failure
/// still come back — the transaction may have set some before the hop that failed.
Error {
message: String,
cookies: Option<Vec<Cookie>>,
},
}
+4 -1
View File
@@ -39,7 +39,7 @@ md5 = "0.8.0"
notify = "8.0.0"
pretty_graphql = "0.2"
r2d2 = "0.8.10"
r2d2_sqlite = "0.25.0"
r2d2_sqlite = "0.32"
mime_guess = "2.0.5"
rand = "0.9.0"
reqwest = { workspace = true, features = [
@@ -73,12 +73,14 @@ url = "2"
tokio-util = { version = "0.7", features = ["codec"] }
ts-rs = { workspace = true }
yaak-rpc = { workspace = true }
yaak-rpc-schema = { workspace = true }
uuid = "1.12.1"
yaak-api = { workspace = true }
yaak-common = { workspace = true }
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 }
@@ -86,6 +88,7 @@ yaak-grpc = { workspace = true }
yaak-http = { workspace = true }
yaak-license = { workspace = true, optional = true }
yaak-mac-window = { workspace = true }
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-sse = { workspace = true }
-4
View File
@@ -1,7 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type GitWatchResult = { unlistenEvent: string, };
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
@@ -12,8 +10,6 @@ export type UpdateResponse = { "type": "ack" } | { "type": "action", action: Upd
export type UpdateResponseAction = "install" | "skip";
export type WatchResult = { unlistenEvent: string, };
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
export type YaakNotificationAction = { label: string, url: string, };
+3 -3
View File
@@ -1,4 +1,4 @@
// ts-rs owns bindings/index.ts and rewrites it on export, so this hand-written
// entry point is where the generated files come together.
export * from "./bindings/gen_rpc";
// ts-rs owns bindings/index.ts and rewrites it on export. What remains here
// after the RPC schema moved to @yaakapp-internal/rpc-schema is the
// desktop-only surface: updater and notification types.
export * from "./bindings/index";
@@ -1,92 +0,0 @@
use crate::PluginContextExt;
use crate::error::Result;
use std::sync::Arc;
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
use yaak_crypto::manager::EncryptionManager;
use yaak_models::models::HttpRequestHeader;
use yaak_models::queries::workspaces::default_headers;
use yaak_plugins::events::GetThemesResponse;
use yaak_plugins::manager::PluginManager;
use yaak_plugins::native_template_functions::{
decrypt_secure_template_function, encrypt_secure_template_function,
};
/// Extension trait for accessing the EncryptionManager from Tauri Manager types.
pub trait EncryptionManagerExt<'a, R> {
fn crypto(&'a self) -> State<'a, EncryptionManager>;
}
impl<'a, R: Runtime, M: Manager<R>> EncryptionManagerExt<'a, R> for M {
fn crypto(&'a self) -> State<'a, EncryptionManager> {
self.state::<EncryptionManager>()
}
}
pub(crate) async fn cmd_decrypt_template<R: Runtime>(
window: WebviewWindow<R>,
template: &str,
) -> Result<String> {
let encryption_manager = window.app_handle().state::<EncryptionManager>();
let plugin_context = window.plugin_context();
Ok(decrypt_secure_template_function(&encryption_manager, &plugin_context, template)?)
}
pub(crate) async fn cmd_secure_template<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
template: &str,
) -> Result<String> {
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let plugin_context = window.plugin_context();
Ok(encrypt_secure_template_function(
plugin_manager,
encryption_manager,
&plugin_context,
template,
)?)
}
pub(crate) async fn cmd_get_themes<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> Result<Vec<GetThemesResponse>> {
Ok(plugin_manager.get_themes(&window.plugin_context()).await?)
}
pub(crate) async fn cmd_enable_encryption<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
) -> Result<()> {
window.crypto().ensure_workspace_key(workspace_id)?;
window.crypto().reveal_workspace_key(workspace_id)?;
Ok(())
}
pub(crate) async fn cmd_reveal_workspace_key<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
) -> Result<String> {
Ok(window.crypto().reveal_workspace_key(workspace_id)?)
}
pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
key: &str,
) -> Result<()> {
window.crypto().set_human_key(workspace_id, key)?;
Ok(())
}
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
) -> Result<()> {
window.crypto().disable_encryption(workspace_id)?;
Ok(())
}
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
default_headers()
}
@@ -41,6 +41,9 @@ pub enum Error {
#[error(transparent)]
YaakError(#[from] yaak::Error),
#[error(transparent)]
CommandError(#[from] yaak_commands::Error),
#[error(transparent)]
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
@@ -2,7 +2,6 @@ use crate::error::{Error, Result};
use chrono::Utc;
use log::{debug, error, warn};
use notify::Watcher;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
@@ -10,18 +9,11 @@ use tauri::{AppHandle, Listener, Runtime};
use tokio::select;
use tokio::sync::watch;
use tokio::time::sleep;
use ts_rs::TS;
use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
use yaak_rpc_schema::GitWatchResult;
const GIT_STATUS_COALESCE_WINDOW: Duration = Duration::from_millis(250);
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub(crate) struct GitWatchResult {
unlisten_event: String,
}
pub(crate) async fn watch_git_worktree_status<R, F>(
app_handle: AppHandle<R>,
dir: &Path,
-17
View File
@@ -2,7 +2,6 @@ use std::collections::BTreeMap;
use crate::PluginContextExt;
use crate::error::Result;
use crate::models_ext::QueryManagerExt;
use KeyAndValueRef::{Ascii, Binary};
use tauri::{Manager, Runtime, WebviewWindow};
use yaak_grpc::{KeyAndValueRef, MetadataMap};
@@ -21,22 +20,6 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String>
entries
}
pub(crate) fn resolve_grpc_request<R: Runtime>(
window: &WebviewWindow<R>,
request: &GrpcRequest,
) -> Result<(GrpcRequest, String)> {
let mut new_request = request.clone();
let (authentication_type, authentication, authentication_context_id) =
window.db().resolve_auth_for_grpc_request(request)?;
new_request.authentication_type = authentication_type;
new_request.authentication = authentication;
let metadata = window.db().resolve_metadata_for_grpc_request(request)?;
new_request.metadata = metadata;
Ok((new_request, authentication_context_id))
}
pub(crate) async fn build_metadata<R: Runtime>(
window: &WebviewWindow<R>,
@@ -179,19 +179,3 @@ async fn send_http_request_inner<R: Runtime>(
Ok(SentHttpRequest { response: result.response, body: result.response_body })
}
pub fn resolve_http_request<R: Runtime>(
window: &WebviewWindow<R>,
request: &HttpRequest,
) -> Result<(HttpRequest, String)> {
let mut new_request = request.clone();
let (authentication_type, authentication, authentication_context_id) =
window.db().resolve_auth_for_http_request(request)?;
new_request.authentication_type = authentication_type;
new_request.authentication = authentication;
let headers = window.db().resolve_headers_for_http_request(request)?;
new_request.headers = headers;
Ok((new_request, authentication_context_id))
}
+34 -566
View File
@@ -2,20 +2,18 @@ extern crate core;
use crate::encoding::read_response_body;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};
use crate::http_request::{resolve_http_request, send_http_request};
use crate::grpc::{build_metadata, metadata_to_map};
use crate::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_json_value, render_template};
use crate::rpc_ext::EphemeralHttpResponse;
use crate::render::{render_grpc_request, render_template};
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use crate::uri_scheme::handle_deep_link;
use error::Result as YaakResult;
use eventsource_client::{EventParser, SSE};
use log::{debug, error, info, warn};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
@@ -30,41 +28,32 @@ use tauri_plugin_log::{Builder, Target, TargetKind, log};
use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak::export::{self, ExportDataParams};
use yaak::send::ResponseBody;
use yaak_commands::responses::locate_response_body;
use yaak_commands::resolve::resolve_grpc_request;
use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
use yaak_grpc::{Code, ServiceDefinition};
use yaak_mac_window::AppHandleMacWindowExt;
use yaak_models::models::{
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
WorkspaceMeta,
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_plugins::events::{
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
CallWorkspaceActionRequest, Color, FilterResponse, GetFolderActionsResponse,
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse,
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, InternalEvent,
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
RenderPurpose, ShowToastRequest,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
use yaak_sse::sse::ServerSentEvent;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_templates::format_json::format_json;
use yaak_templates::strip_json_comments::strip_json_comments;
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
mod commands;
mod encoding;
mod error;
mod feedback;
@@ -79,6 +68,7 @@ mod notifications;
mod plugin_events;
mod plugins_ext;
mod render;
mod restart;
mod rpc_ext;
mod sync_ext;
mod updates;
@@ -184,22 +174,6 @@ impl<R: Runtime> PluginContextExt<R> for WebviewWindow<R> {
}
}
#[derive(serde::Serialize, ts_rs::TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct AppMetaData {
is_dev: bool,
version: String,
cli_version: Option<String>,
name: String,
app_data_dir: String,
app_log_dir: String,
vendored_plugin_dir: String,
default_project_dir: String,
feature_updater: bool,
feature_license: bool,
}
async fn cmd_metadata<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<AppMetaData> {
let app_data_dir = app_handle.path().app_data_dir()?;
let app_log_dir = app_handle.path().app_log_dir()?;
@@ -239,56 +213,6 @@ async fn detect_cli_version_for_binary(program: &str) -> Option<String> {
Some(parts.next().unwrap_or(line).to_string())
}
async fn cmd_template_tokens_to_string<R: Runtime>(
window: WebviewWindow<R>,
app_handle: AppHandle<R>,
tokens: Tokens,
) -> YaakResult<String> {
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new(
plugin_manager,
encryption_manager,
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
RenderPurpose::Preview,
);
let new_tokens = transform_args(tokens, &cb)?;
Ok(new_tokens.to_string())
}
async fn cmd_render_template<R: Runtime>(
window: WebviewWindow<R>,
app_handle: AppHandle<R>,
template: &str,
workspace_id: &str,
environment_id: Option<&str>,
purpose: Option<RenderPurpose>,
ignore_error: Option<bool>,
) -> YaakResult<String> {
let environment_chain =
app_handle.db().resolve_environments(workspace_id, None, environment_id)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let result = render_template(
template,
environment_chain,
&PluginTemplateCallback::new(
plugin_manager,
encryption_manager,
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
purpose.unwrap_or(RenderPurpose::Preview),
),
&RenderOptions {
error_behavior: match ignore_error {
Some(true) => RenderErrorBehavior::ReturnEmpty,
_ => RenderErrorBehavior::Throw,
},
},
)
.await?;
Ok(result)
}
async fn cmd_send_feedback<R: Runtime>(
app_handle: AppHandle<R>,
feature: String,
@@ -315,7 +239,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> YaakResult<Vec<ServiceDefinition>> {
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
let (resolved_request, auth_context_id) =
resolve_grpc_request(&window.db(), &unrendered_request)?;
let environment_chain = app_handle.db().resolve_environments(
&unrendered_request.workspace_id,
@@ -375,7 +300,8 @@ async fn cmd_grpc_go<R: Runtime>(
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> YaakResult<String> {
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
let (resolved_request, auth_context_id) =
resolve_grpc_request(&window.db(), &unrendered_request)?;
let environment_chain = app_handle.db().resolve_environments(
&unrendered_request.workspace_id,
unrendered_request.folder_id.as_deref(),
@@ -979,7 +905,7 @@ async fn cmd_grpc_go<R: Runtime>(
}
async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
app_handle.request_restart();
restart::request_restart(&app_handle);
Ok(())
}
@@ -1027,10 +953,6 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
Ok(EphemeralHttpResponse { response: sent.response, body })
}
async fn cmd_format_json(text: &str) -> YaakResult<String> {
Ok(format_json(text, " "))
}
async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
match pretty_graphql::format_text(text, &Default::default()) {
Ok(formatted) => Ok(formatted),
@@ -1038,44 +960,13 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
}
}
/// Where a response's body is, and what it is meant to be read as.
struct ResponseBodyLocation {
/// None when the response has no stored body.
path: Option<PathBuf>,
/// The response's declared `Content-Type`, empty when it has none.
content_type: String,
}
/// Find a response's body from its id alone.
///
/// The frontend hands back an id and never a path, so the only bodies reachable
/// here are ones the engine wrote and the database still knows about. A
/// response that was never saved has no entry, and its body came back from the
/// send that made it.
fn locate_response_body<R: Runtime>(
app_handle: &AppHandle<R>,
response_id: &str,
) -> YaakResult<ResponseBodyLocation> {
let response = app_handle.db().get_http_response(response_id)?;
Ok(ResponseBodyLocation {
path: response.body_path.map(PathBuf::from),
content_type: response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone())
.unwrap_or_default(),
})
}
async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
response_id: &str,
filter: Option<&str>,
) -> YaakResult<FilterResponse> {
let location = locate_response_body(window.app_handle(), response_id)?;
let location = locate_response_body(&window.db(), response_id)?;
let Some(body_path) = location.path else {
return Ok(FilterResponse { content: String::new(), error: None });
};
@@ -1093,41 +984,11 @@ async fn cmd_http_response_body<R: Runtime>(
}
}
/// The body's path on this machine, for the desktop host to read or hand to the
/// webview's asset protocol.
///
/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
/// the path, and only because it is about to open the file itself. Hosts
/// without a filesystem serve the same bytes over HTTP instead.
async fn cmd_http_response_body_path<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Option<String>> {
let location = locate_response_body(&app_handle, response_id)?;
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
}
async fn cmd_http_request_body<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Option<Vec<u8>>> {
let body_id = format!("{}.request", response_id);
let chunks = app_handle.blobs().get_chunks(&body_id)?;
if chunks.is_empty() {
return Ok(None);
}
// Concatenate all chunks
let body: Vec<u8> = chunks.into_iter().flat_map(|c| c.data).collect();
Ok(Some(body))
}
async fn cmd_get_sse_events<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Vec<ServerSentEvent>> {
let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else {
let Some(body_path) = locate_response_body(&app_handle.db(), response_id)?.path else {
return Ok(Vec::new());
};
@@ -1150,14 +1011,6 @@ 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,
@@ -1172,299 +1025,19 @@ 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.
///
@@ -1489,20 +1062,6 @@ async fn cmd_save_base64_to_binary<R: Runtime>(
Ok(())
}
async fn cmd_save_response<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
filepath: &str,
) -> YaakResult<()> {
let response = app_handle.db().get_http_response(response_id)?;
let body_path =
response.body_path.ok_or(GenericError("Response does not have a body".to_string()))?;
fs::copy(body_path, filepath).map_err(|e| GenericError(e.to_string()))?;
Ok(())
}
async fn cmd_send_http_request<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
@@ -1574,101 +1133,6 @@ async fn cmd_send_http_request<R: Runtime>(
Ok(r)
}
async fn cmd_reload_plugins<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<Vec<(String, String)>> {
let plugins = app_handle.db().list_plugins()?;
let plugin_context =
PluginContext::new(Some(window.label().to_string()), window.workspace_id());
let errors = plugin_manager.initialize_all_plugins(plugins, &plugin_context).await;
Ok(errors)
}
async fn cmd_plugin_info<R: Runtime>(
id: &str,
app_handle: AppHandle<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<PluginMetadata> {
let plugin = app_handle.db().get_plugin(id)?;
if let Some(plugin_handle) = plugin_manager
.get_plugin_by_dir(plugin.directory.as_str())
.await
{
return Ok(plugin_handle.info());
}
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
return Ok(metadata);
}
Ok(fallback_plugin_metadata(&plugin.directory))
}
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
let display_name = PathBuf::from(directory)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(directory)
.to_string();
PluginMetadata {
version: "Unavailable".to_string(),
name: directory.to_string(),
display_name,
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
homepage_url: None,
repository_url: None,
}
}
async fn cmd_delete_all_grpc_connections<R: Runtime>(
request_id: &str,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<()> {
Ok(app_handle.db().delete_all_grpc_connections_for_request(
request_id,
&UpdateSource::from_window_label(window.label()),
)?)
}
async fn cmd_delete_send_history<R: Runtime>(
workspace_id: &str,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<()> {
Ok(app_handle.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
tx.delete_all_http_responses_for_workspace(workspace_id, source)?;
tx.delete_all_grpc_connections_for_workspace(workspace_id, source)?;
tx.delete_all_websocket_connections_for_workspace(workspace_id, source)?;
Ok(())
})?)
}
async fn cmd_delete_all_http_responses<R: Runtime>(
request_id: &str,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<()> {
app_handle.db().delete_all_http_responses_for_request(
request_id,
&UpdateSource::from_window_label(window.label()),
)?;
Ok(())
}
async fn cmd_get_workspace_meta<R: Runtime>(
app_handle: AppHandle<R>,
workspace_id: &str,
) -> YaakResult<WorkspaceMeta> {
let db = app_handle.db();
let workspace = db.get_workspace(workspace_id)?;
Ok(db.get_or_create_workspace_meta(&workspace.id)?)
}
async fn cmd_new_child_window<R: Runtime>(
parent_window: WebviewWindow<R>,
@@ -1795,6 +1259,14 @@ pub fn run() {
builder
.setup(|app| {
let lifecycle_host = yaak_lifecycle::Host::owner()
.with_responses_dir(app.path().app_data_dir()?.join("responses"));
if let Err(e) =
yaak_lifecycle::on_launch(&lifecycle_host, &app.db(), &app.blob_manager())
{
error!("on_launch hook failed: {e:?}");
}
// The RPC command registry — every frontend command dispatches
// through this via the single `rpc` Tauri command
app.manage(rpc_ext::build_rpc_router::<TauriRuntime>());
@@ -1894,15 +1366,6 @@ pub fn run() {
let info = history::get_or_upsert_launch_info(&h);
debug!("Launched Yaak {:?}", info);
});
// Cancel pending requests
let h = app_handle.clone();
tauri::async_runtime::block_on(async move {
let db = h.db();
let _ = db.cancel_pending_http_responses();
let _ = db.cancel_pending_grpc_connections();
let _ = db.cancel_pending_websocket_connections();
});
}
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -1946,6 +1409,7 @@ pub fn run() {
}
});
}
RunEvent::Exit => restart::relaunch_if_requested(),
_ => {}
};
});
@@ -1988,6 +1452,7 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
let ev = match ev {
Ok(Some(ev)) => ev,
// Nothing to say, or the reply comes later from somewhere else.
Ok(None) => return,
Err(e) => {
warn!("Failed to handle plugin event: {e:?}");
@@ -2000,7 +1465,10 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
timeout: Some(30000),
}),
);
return;
// Tell the plugin as well as the user. It is awaiting a
// reply, and a toast it cannot see would leave it
// waiting for one that never comes.
InternalEventPayload::ErrorResponse(ErrorResponse { error: e.to_string() })
}
};
@@ -12,12 +12,9 @@ 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;
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
@@ -123,163 +120,12 @@ impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
/// Extension trait for accessing the BlobManager from Tauri Manager types.
pub trait BlobManagerExt<'a, R> {
fn blob_manager(&'a self) -> State<'a, BlobManager>;
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext;
}
impl<'a, R: Runtime, M: Manager<R>> BlobManagerExt<'a, R> for M {
fn blob_manager(&'a self) -> State<'a, BlobManager> {
self.state::<BlobManager>()
}
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext {
let manager = self.state::<BlobManager>();
manager.inner().connect()
}
}
// Commands for yaak-models
use tauri::WebviewWindow;
pub(crate) fn models_upsert<R: Runtime>(
window: WebviewWindow<R>,
model: AnyModel,
) -> Result<String> {
let db = window.db();
let blobs = window.blob_manager();
let source = &UpdateSource::from_window_label(window.label());
yaak::models_ops::upsert_model(&db, &blobs, model, source)
}
// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a
// blocking thread instead of stalling the main thread and all other IPC.
pub(crate) async fn models_delete<R: Runtime>(
window: WebviewWindow<R>,
model: AnyModel,
) -> Result<String> {
use yaak_models::error::Error::GenericError;
tauri::async_runtime::spawn_blocking(move || {
let blobs = window.blob_manager();
// Use transaction for deletions because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
yaak::models_ops::delete_model(tx, &blobs, model, source)
})
})
.await
.map_err(|e| GenericError(format!("Delete task failed: {e}")))?
}
pub(crate) fn models_duplicate<R: Runtime>(
window: WebviewWindow<R>,
model_type: String,
model_id: String,
) -> Result<String> {
// Use transaction for duplications because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
yaak::models_ops::duplicate_model(tx, &model_type, &model_id, source)
})
}
pub(crate) fn models_websocket_events<R: Runtime>(
app_handle: tauri::AppHandle<R>,
connection_id: &str,
) -> Result<Vec<WebsocketEvent>> {
Ok(app_handle.db().list_websocket_events(connection_id)?)
}
pub(crate) fn models_grpc_events<R: Runtime>(
app_handle: tauri::AppHandle<R>,
connection_id: &str,
) -> Result<Vec<GrpcEvent>> {
Ok(app_handle.db().list_grpc_events(connection_id)?)
}
pub(crate) fn models_get_settings<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Result<Settings> {
Ok(app_handle.db().get_settings())
}
pub(crate) fn models_get_graphql_introspection<R: Runtime>(
app_handle: tauri::AppHandle<R>,
request_id: &str,
) -> Result<Option<GraphQlIntrospection>> {
Ok(app_handle.db().get_graphql_introspection(request_id))
}
pub(crate) fn models_upsert_graphql_introspection<R: Runtime>(
app_handle: tauri::AppHandle<R>,
request_id: &str,
workspace_id: &str,
content: Option<String>,
window: WebviewWindow<R>,
) -> Result<GraphQlIntrospection> {
let source = UpdateSource::from_window_label(window.label());
Ok(app_handle.db().upsert_graphql_introspection(workspace_id, request_id, content, &source)?)
}
pub(crate) async fn models_workspace_models<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: Option<&str>,
plugin_manager: State<'_, PluginManager>,
) -> Result<String> {
let mut l: Vec<AnyModel> = Vec::new();
// Add the global models
{
let db = window.db();
l.push(db.get_settings().into());
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
}
let plugins = {
let db = window.db();
db.list_plugins()?
};
let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;
l.append(&mut plugins.into_iter().map(Into::into).collect());
// Add the workspace children
if let Some(wid) = workspace_id {
let db = window.db();
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
}
let j = serde_json::to_string(&l)?;
Ok(escape_str_for_webview(&j))
}
fn escape_str_for_webview(input: &str) -> String {
input
.chars()
.map(|c| {
let code = c as u32;
// ASCII
if code <= 0x7F {
c.to_string()
// BMP characters encoded normally
} else if code < 0xFFFF {
format!("\\u{:04X}", code)
// Beyond BMP encoded a surrogate pairs
} else {
let high = ((code - 0x10000) >> 10) + 0xD800;
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
format!("\\u{:04X}\\u{:04X}", high, low)
}
})
.collect()
}
/// Initialize database managers as a plugin (for initialization order).
@@ -305,30 +151,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
}
};
let db = query_manager.connect();
if let Err(err) = db.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 app launch.
let cursor = ModelChangeCursor::from_launch_time();
let poll_query_manager = query_manager.clone();
// GC response bodies orphaned by cascade deletes, which historically
// didn't clean the blob DB or responses directory
let gc_query_manager = query_manager.clone();
let gc_blob_manager = blob_manager.clone();
let gc_responses_dir = app_path.join("responses");
tauri::async_runtime::spawn_blocking(move || {
let db = gc_query_manager.connect();
match db.delete_orphaned_response_bodies(&gc_blob_manager, &gc_responses_dir) {
Ok(0) => {}
Ok(n) => log::info!("Deleted {n} orphaned response bodies"),
Err(e) => error!("Failed to delete orphaned response bodies: {e:?}"),
}
});
app_handle.manage(query_manager);
app_handle.manage(blob_manager);
@@ -7,6 +7,8 @@ use crate::{
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
workspace_from_window,
};
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use chrono::Utc;
use log::error;
use std::sync::Arc;
@@ -16,6 +18,7 @@ use tauri_plugin_opener::OpenerExt;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak::response_body::FileResponseBodyStore;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::get_cookie_value_from_jar;
use yaak_models::models::{HttpResponse, Plugin};
@@ -54,6 +57,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
match handle_shared_plugin_event(
app_handle.db_manager().inner(),
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
&event.payload,
SharedPluginEventContext {
plugin_name: &plugin_name,
@@ -313,8 +317,13 @@ async fn handle_host_plugin_request<R: Runtime>(
)
.await?;
// An ad-hoc request saves nothing, so the engine hands the body
// back and this reply is the only place the plugin can get it.
let body = http_response.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b));
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
http_response: http_response.response,
body,
})))
}
HostRequest::OpenWindow(req) => {
@@ -194,12 +194,6 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
}
pub async fn cmd_plugin_init_errors(
plugin_manager: State<'_, PluginManager>,
) -> Result<Vec<(String, String)>> {
Ok(plugin_manager.take_init_errors().await)
}
pub async fn cmd_plugins_updates<R: Runtime>(
app_handle: AppHandle<R>,
) -> Result<PluginUpdatesResponse> {
+7 -24
View File
@@ -1,25 +1,8 @@
use serde_json::Value;
pub use yaak::render::{render_grpc_request, render_http_request};
use yaak_models::models::Environment;
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
//! One import path for rendering, wherever the pieces actually live.
//!
//! The request renderers are engine code; the template renderers moved to
//! `yaak-commands` when the template commands did. Callers in this crate do not
//! need to track which is which.
pub async fn render_template<T: TemplateCallback>(
template: &str,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<String> {
let vars = &make_vars_hashmap(environment_chain);
parse_and_render(template, vars, cb, &opt).await
}
pub async fn render_json_value<T: TemplateCallback>(
value: Value,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<Value> {
let vars = &make_vars_hashmap(environment_chain);
render_json_value_raw(value, vars, cb, opt).await
}
pub use yaak_models::render::{render_grpc_request, render_http_request};
pub use yaak_commands::render::{render_json_value, render_template};
+107
View File
@@ -0,0 +1,107 @@
#[cfg(target_os = "macos")]
use log::{error, info};
#[cfg(any(target_os = "macos", test))]
use std::path::{Path, PathBuf};
#[cfg(target_os = "macos")]
use std::process::{Command, Stdio};
#[cfg(target_os = "macos")]
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{AppHandle, Runtime};
#[cfg(target_os = "macos")]
static RELAUNCH_WITH_LAUNCH_SERVICES: AtomicBool = AtomicBool::new(false);
/// Restart the app without directly spawning the executable on macOS.
///
/// Tauri's current macOS restart path starts the executable from the dying
/// process. Besides inheriting stale process state, that bypasses
/// LaunchServices and can leave the replacement app running without an active
/// window. Defer the relaunch until `RunEvent::Exit`, when the event loop is
/// already shutting down, and hand it to LaunchServices instead.
pub fn request_restart<R: Runtime>(app_handle: &AppHandle<R>) {
#[cfg(target_os = "macos")]
if current_app_bundle().is_some() {
info!("Requesting restart through macOS LaunchServices");
RELAUNCH_WITH_LAUNCH_SERVICES.store(true, Ordering::SeqCst);
app_handle.exit(0);
return;
}
app_handle.request_restart();
}
/// Complete a pending macOS restart after Tauri has emitted its exit events.
pub fn relaunch_if_requested() {
#[cfg(target_os = "macos")]
{
if !RELAUNCH_WITH_LAUNCH_SERVICES.swap(false, Ordering::SeqCst) {
return;
}
let Some(bundle) = current_app_bundle() else {
error!("Failed to resolve the app bundle for restart");
return;
};
match Command::new("/usr/bin/open")
.arg("-n")
.arg(&bundle)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(_) => info!("Relaunching {} through LaunchServices", bundle.display()),
Err(error) => error!("Failed to relaunch through LaunchServices: {error}"),
}
}
}
#[cfg(target_os = "macos")]
fn current_app_bundle() -> Option<PathBuf> {
app_bundle_from_executable(&std::env::current_exe().ok()?)
}
#[cfg(any(target_os = "macos", test))]
fn app_bundle_from_executable(executable: &Path) -> Option<PathBuf> {
let macos_dir = executable.parent()?;
if macos_dir.file_name()? != "MacOS" {
return None;
}
let contents_dir = macos_dir.parent()?;
if contents_dir.file_name()? != "Contents" {
return None;
}
let bundle = contents_dir.parent()?;
if bundle.extension()? != "app" {
return None;
}
Some(bundle.to_owned())
}
#[cfg(test)]
mod tests {
use super::app_bundle_from_executable;
use std::path::{Path, PathBuf};
#[test]
fn resolves_macos_app_bundle() {
assert_eq!(
app_bundle_from_executable(Path::new(
"/Applications/Yaak.app/Contents/MacOS/yaak-app-client"
)),
Some(PathBuf::from("/Applications/Yaak.app"))
);
}
#[test]
fn ignores_unbundled_executable() {
assert_eq!(
app_bundle_from_executable(Path::new("/workspace/target/debug/yaak-app-client")),
None
);
}
}
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -6,11 +6,10 @@ use crate::error::Result;
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use chrono::Utc;
use log::warn;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tauri::{AppHandle, Listener, Runtime};
use tokio::sync::watch;
use ts_rs::TS;
use yaak_rpc_schema::WatchResult;
use yaak_sync::error::Error::InvalidSyncDirectory;
use yaak_sync::sync::{
FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,
@@ -57,13 +56,6 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub(crate) struct WatchResult {
unlisten_event: String,
}
pub(crate) async fn sync_watch<R, F>(
app_handle: AppHandle<R>,
sync_dir: &Path,
+2 -1
View File
@@ -4,6 +4,7 @@ use std::time::{Duration, Instant};
use crate::error::Result;
use crate::models_ext::QueryManagerExt;
use crate::restart;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
@@ -332,7 +333,7 @@ async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Upd
))
.blocking_show()
{
window.app_handle().request_restart();
restart::request_restart(window.app_handle());
}
}
Err(e) => {
+4 -31
View File
@@ -18,7 +18,7 @@ use yaak_http::cookies::CookieStore;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_models::models::{
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
WebsocketEventType, WebsocketRequest,
WebsocketEventType,
};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
@@ -27,19 +27,9 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
use yaak_commands::resolve::resolve_websocket_request;
use yaak_ws::{WebsocketManager, render_websocket_request};
pub async fn cmd_ws_delete_connections<R: Runtime>(
request_id: &str,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> Result<()> {
Ok(app_handle.db().delete_all_websocket_connections_for_request(
request_id,
&UpdateSource::from_window_label(window.label()),
)?)
}
pub async fn cmd_ws_send<R: Runtime>(
connection_id: &str,
environment_id: Option<&str>,
@@ -86,7 +76,7 @@ async fn send_websocket_message<R: Runtime>(
environment_id,
)?;
let (resolved_request, _auth_context_id) =
resolve_websocket_request(&window, &unrendered_request)?;
resolve_websocket_request(&window.db(), &unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_websocket_request(
@@ -165,7 +155,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
let settings = app_handle.db().get_settings();
let (resolved_request, auth_context_id) =
resolve_websocket_request(&window, &unrendered_request)?;
resolve_websocket_request(&window.db(), &unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_websocket_request(
@@ -465,23 +455,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
Ok(connection)
}
/// Resolve inherited authentication and headers for a websocket request
fn resolve_websocket_request<R: Runtime>(
window: &WebviewWindow<R>,
request: &WebsocketRequest,
) -> Result<(WebsocketRequest, String)> {
let mut new_request = request.clone();
let (authentication_type, authentication, authentication_context_id) =
window.db().resolve_auth_for_websocket_request(request)?;
new_request.authentication_type = authentication_type;
new_request.authentication = authentication;
let headers = window.db().resolve_headers_for_websocket_request(request)?;
new_request.headers = headers;
Ok((new_request, authentication_context_id))
}
/// Convert WS URL to HTTP URL for cookie filtering
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
+1
View File
@@ -7,3 +7,4 @@ publish = false
[dependencies]
tauri = { workspace = true }
regex = "1.11.0"
yaak-core = { workspace = true }
+30 -15
View File
@@ -1,38 +1,53 @@
use regex::Regex;
use tauri::{Runtime, WebviewWindow};
use tauri::{Runtime, Url, WebviewWindow};
use yaak_core::WorkspaceContext;
pub trait WorkspaceWindowTrait {
fn workspace_id(&self) -> Option<String>;
fn cookie_jar_id(&self) -> Option<String>;
fn environment_id(&self) -> Option<String>;
fn request_id(&self) -> Option<String>;
/// All four at once, from a single read of the window URL.
fn workspace_context(&self) -> WorkspaceContext;
}
impl<R: Runtime> WorkspaceWindowTrait for WebviewWindow<R> {
fn workspace_id(&self) -> Option<String> {
let url = self.url().unwrap();
let re = Regex::new(r"/workspaces/(?<id>\w+)").unwrap();
match re.captures(url.as_str()) {
None => None,
Some(captures) => captures.name("id").map(|c| c.as_str().to_string()),
}
workspace_id_from_url(&self.url().unwrap())
}
fn cookie_jar_id(&self) -> Option<String> {
let url = self.url().unwrap();
let mut query_pairs = url.query_pairs();
query_pairs.find(|(k, _v)| k == "cookie_jar_id").map(|(_k, v)| v.to_string())
query_param(&self.url().unwrap(), "cookie_jar_id")
}
fn environment_id(&self) -> Option<String> {
let url = self.url().unwrap();
let mut query_pairs = url.query_pairs();
query_pairs.find(|(k, _v)| k == "environment_id").map(|(_k, v)| v.to_string())
query_param(&self.url().unwrap(), "environment_id")
}
fn request_id(&self) -> Option<String> {
query_param(&self.url().unwrap(), "request_id")
}
fn workspace_context(&self) -> WorkspaceContext {
let url = self.url().unwrap();
let mut query_pairs = url.query_pairs();
query_pairs.find(|(k, _v)| k == "request_id").map(|(_k, v)| v.to_string())
WorkspaceContext {
workspace_id: workspace_id_from_url(&url),
environment_id: query_param(&url, "environment_id"),
cookie_jar_id: query_param(&url, "cookie_jar_id"),
request_id: query_param(&url, "request_id"),
}
}
}
fn workspace_id_from_url(url: &Url) -> Option<String> {
let re = Regex::new(r"/workspaces/(?<id>\w+)").unwrap();
match re.captures(url.as_str()) {
None => None,
Some(captures) => captures.name("id").map(|c| c.as_str().to_string()),
}
}
fn query_param(url: &Url, key: &str) -> Option<String> {
let mut query_pairs = url.query_pairs();
query_pairs.find(|(k, _v)| k == key).map(|(_k, v)| v.to_string())
}
+13 -5
View File
@@ -9,12 +9,20 @@ chrono = { version = "0.4.38", features = ["serde"] }
include_dir = "0.7"
log = { workspace = true }
nanoid = "0.4.0"
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.25.0" }
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.32" }
# nanoid pulls getrandom, which needs to be told how to reach the browser's
# CSPRNG on wasm32-unknown-unknown. Native targets are unaffected.
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
uuid = { version = "1", features = ["js"] }
@@ -1,9 +1,8 @@
use r2d2::PooledConnection;
use r2d2_sqlite::SqliteConnectionManager;
use crate::pool::SqliteConn;
use rusqlite::{Connection, Statement, ToSql, Transaction};
pub enum ConnectionOrTx<'a> {
Connection(PooledConnection<SqliteConnectionManager>),
Connection(SqliteConn),
Transaction(&'a Transaction<'a>),
}
@@ -3,6 +3,7 @@ use crate::error::Error::ModelNotFound;
use crate::error::Result;
use crate::traits::UpsertModelInfo;
use crate::update_source::UpdateSource;
use sea_query::ExprTrait;
use sea_query::{
Asterisk, Expr, Func, IntoColumnRef, IntoIden, OnConflict, Query, SimpleExpr,
SqliteQueryBuilder,
+1 -1
View File
@@ -7,7 +7,7 @@ pub enum Error {
SqlError(#[from] rusqlite::Error),
#[error("SQL Pool error: {0}")]
SqlPoolError(#[from] r2d2::Error),
SqlPoolError(#[from] crate::pool::PoolError),
#[error("Database error: {0}")]
Database(String),
+5 -2
View File
@@ -2,6 +2,7 @@ pub mod connection_or_tx;
pub mod db_context;
pub mod error;
pub mod migrate;
pub mod pool;
pub mod traits;
pub mod update_source;
pub mod util;
@@ -11,13 +12,15 @@ pub use connection_or_tx::ConnectionOrTx;
pub use db_context::DbContext;
pub use error::{Error, Result};
pub use migrate::run_migrations;
pub use pool::{PoolError, SqliteConn, SqlitePool};
pub use traits::{UpsertModelInfo, upsert_date};
pub use update_source::{ModelChangeEvent, UpdateSource};
pub use util::{generate_id, generate_id_of_length, generate_prefixed_id};
// Re-export pool types that consumers will need
// Re-export types that consumers will need
#[cfg(not(target_arch = "wasm32"))]
pub use r2d2;
#[cfg(not(target_arch = "wasm32"))]
pub use r2d2_sqlite;
pub use rusqlite;
pub use sea_query;
pub use sea_query_rusqlite;
+2 -3
View File
@@ -1,8 +1,7 @@
use crate::error::Result;
use crate::pool::SqlitePool;
use include_dir::Dir;
use log::{debug, info};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, params};
const TRACKING_TABLE: &str = "_sqlx_migrations";
@@ -11,7 +10,7 @@ const TRACKING_TABLE: &str = "_sqlx_migrations";
///
/// Migrations are sorted by filename (use timestamp prefixes like `00000001_init.sql`).
/// Applied migrations are tracked in `_sqlx_migrations`.
pub fn run_migrations(pool: &Pool<SqliteConnectionManager>, dir: &Dir<'_>) -> Result<()> {
pub fn run_migrations(pool: &SqlitePool, dir: &Dir<'_>) -> Result<()> {
info!("Running migrations");
// Create tracking table
+80
View File
@@ -0,0 +1,80 @@
//! Where connections come from.
//!
//! Every query in the model layer asks a pool for a connection, uses it, and
//! hands it back. That is the whole contract, and it is the one place the
//! desktop and the browser genuinely differ: the desktop has threads and wants
//! an r2d2 pool; a browser tab has one thread, no way to spawn another, and one
//! connection is exactly enough. Everything above this module is identical on
//! both.
//!
//! On native targets `SqlitePool` *is* `r2d2::Pool` — a type alias, so nothing
//! that already builds pools changes. On wasm it is one connection that every
//! `get()` hands out a shared handle to.
//!
//! A `SqliteConn` only ever derefs immutably. The code above this layer opens
//! transactions with [`rusqlite::Transaction::new_unchecked`], which takes
//! `&Connection`; the `&mut` that `Connection::transaction` demands is a
//! compile-time guard against nesting a transaction on one connection, and it
//! is what would have forced the wasm pool to lend its connection exclusively.
//! The model layer nests connections freely — a helper that already holds one
//! calls another that asks for its own — so an exclusive lend would panic on
//! the second ask. Sharing the handle instead makes nested *reads* work the way
//! they do on the desktop; nested *write transactions* fail on both, only
//! differently (here SQLite refuses the inner `BEGIN`; natively the inner
//! connection blocks on `busy_timeout` and then fails).
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use r2d2_sqlite::SqliteConnectionManager;
pub type SqlitePool = r2d2::Pool<SqliteConnectionManager>;
pub type SqliteConn = r2d2::PooledConnection<SqliteConnectionManager>;
pub type PoolError = r2d2::Error;
}
#[cfg(target_arch = "wasm32")]
mod imp {
use rusqlite::Connection;
use std::ops::Deref;
use std::rc::Rc;
/// One connection, shared by everyone who asks.
///
/// `Rc` rather than `Arc` because a `Connection` is `!Sync`, so wrapping
/// it in an `Arc` would buy no `Send`/`Sync` anyway — and there is one
/// thread here to be honest about.
#[derive(Clone, Debug)]
pub struct SqlitePool {
conn: Rc<Connection>,
}
impl SqlitePool {
pub fn single(conn: Connection) -> Self {
Self { conn: Rc::new(conn) }
}
/// Another handle to the connection. Cannot fail; the `Result` keeps
/// the signature identical to r2d2's so callers are written once.
pub fn get(&self) -> Result<SqliteConn, PoolError> {
Ok(SqliteConn(self.conn.clone()))
}
}
/// The error a `get()` would return if it could. It can't, so this has no
/// variants; it exists so `Error::SqlPoolError` has the same shape on both
/// targets.
#[derive(Debug, thiserror::Error)]
pub enum PoolError {}
#[derive(Debug)]
pub struct SqliteConn(Rc<Connection>);
impl Deref for SqliteConn {
type Target = Connection;
fn deref(&self) -> &Connection {
&self.0
}
}
}
pub use imp::*;
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "yaak-rpc-schema"
version = "0.0.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
serde = { workspace = true, features = ["derive"] }
ts-rs = { workspace = true }
yaak-git = { workspace = true }
yaak-grpc = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-sse = { workspace = true }
yaak-sync = { workspace = true }
yaak-templates = { workspace = true }
yaak-ws = { workspace = true }
+44
View File
@@ -0,0 +1,44 @@
# yaak-rpc-schema
The wire schema for the app's RPC surface: every command name, its request
payload, and its response type, declared once.
Every host that serves the Yaak UI — the desktop app today, the browser bridge
and anything after it — imports these types and implements the commands against
them. That is what keeps a request's shape from drifting between hosts, and it
is why the TypeScript bindings (`bindings/gen_rpc.ts`, exposed to the frontend
as `@yaakapp-internal/rpc-schema`) are generated from one place.
Nothing here depends on Tauri or on any host. Request structs are plain data,
and so are the few response types declared here rather than in an engine crate.
Command *bodies* live with the host that runs them.
## Adding a command
1. Add its request struct and an entry in `with_commands!` in `src/lib.rs`.
2. Write the adapter in each host — the desktop's live in
`crates-tauri/yaak-app-client/src/rpc_ext.rs`. A host that does not support
the command still has to say so; a missing adapter fails to compile.
3. Regenerate the bindings: `cargo test -p yaak-rpc-schema` writes
`bindings/gen_rpc.ts`, which is committed.
## How hosts consume the list
`with_commands!` takes the name of a `macro_rules!` macro and calls it with the
full `name(Req) -> Res` list. Each host writes a small macro that receives that
list and builds its router:
```rust
macro_rules! register_commands {
( $( $name:ident ( $req:ty ) -> $res:ty ),* $(,)? ) => {
pub fn build_router() -> RpcRouter<MyCtx> {
let mut router = RpcRouter::new();
$( router.register(stringify!($name), rpc_handler_async!($name)); )*
router
}
};
}
yaak_rpc_schema::with_commands!(register_commands);
```
The schema decides *what* commands exist; the host decides *how* each one runs.
@@ -55,7 +55,7 @@ urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
@@ -70,6 +70,17 @@ export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the Yaak server, and what the server reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
// The RPC wire schema, generated by ts-rs from the Rust declarations in
// src/lib.rs. `RpcSchema` maps every command name to its (request, response)
// pair; the app's `rpc()` helper derives its command union from it.
export * from "./bindings/gen_rpc";
@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/rpc-schema",
"version": "1.0.0",
"private": true,
"main": "index.ts"
}
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "yaak-commands"
version = "0.0.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
serde_json = { workspace = true }
thiserror = { workspace = true }
yaak = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-rpc-schema = { workspace = true }
yaak-templates = { workspace = true }
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+157
View File
@@ -0,0 +1,157 @@
//! The actions plugins contribute to the UI, and the calls that run them.
//!
//! Listing is a plain question for the plugin runtime. Calling is not: the
//! frontend sends back the model it was showing, and a plugin must act on what
//! that model *actually is* — re-read from the database, with inheritance
//! resolved — not on a snapshot the UI has been holding. That re-reading is the
//! work these handlers do.
use crate::error::{Error, Result};
use crate::host::PluginHost;
use crate::resolve::{resolve_grpc_request, resolve_http_request};
use yaak_models::models::HttpRequest;
use yaak_plugins::events::{
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
CallWorkspaceActionRequest, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpRequestActionsResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse,
};
use yaak_rpc_schema::*;
// -- Listing --
pub async fn cmd_http_request_actions<H: PluginHost>(
host: H,
_req: CmdHttpRequestActionsReq,
) -> Result<Vec<GetHttpRequestActionsResponse>> {
host.http_request_actions().await
}
pub async fn cmd_websocket_request_actions<H: PluginHost>(
host: H,
_req: CmdWebsocketRequestActionsReq,
) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
host.websocket_request_actions().await
}
pub async fn cmd_grpc_request_actions<H: PluginHost>(
host: H,
_req: CmdGrpcRequestActionsReq,
) -> Result<Vec<GetGrpcRequestActionsResponse>> {
host.grpc_request_actions().await
}
pub async fn cmd_workspace_actions<H: PluginHost>(
host: H,
_req: CmdWorkspaceActionsReq,
) -> Result<Vec<GetWorkspaceActionsResponse>> {
host.workspace_actions().await
}
pub async fn cmd_folder_actions<H: PluginHost>(
host: H,
_req: CmdFolderActionsReq,
) -> Result<Vec<GetFolderActionsResponse>> {
host.folder_actions().await
}
// -- Calling --
pub async fn cmd_call_http_request_action<H: PluginHost>(
host: H,
req: CmdCallHttpRequestActionReq,
) -> Result<()> {
let inner = req.req;
let http_request = resolve_http_request(&host.db(), &inner.args.http_request)?.0;
host.call_http_request_action(CallHttpRequestActionRequest {
args: CallHttpRequestActionArgs { http_request },
..inner
})
.await
}
pub async fn cmd_call_grpc_request_action<H: PluginHost>(
host: H,
req: CmdCallGrpcRequestActionReq,
) -> Result<()> {
let inner = req.req;
let grpc_request = resolve_grpc_request(&host.db(), &inner.args.grpc_request)?.0;
host.call_grpc_request_action(CallGrpcRequestActionRequest {
args: CallGrpcRequestActionArgs { grpc_request, ..inner.args },
..inner
})
.await
}
pub async fn cmd_call_websocket_request_action<H: PluginHost>(
host: H,
req: CmdCallWebsocketRequestActionReq,
) -> Result<()> {
let inner = req.req;
let websocket_request = host.db().get_websocket_request(&inner.args.websocket_request.id)?;
host.call_websocket_request_action(CallWebsocketRequestActionRequest {
args: CallWebsocketRequestActionArgs { websocket_request },
..inner
})
.await
}
pub async fn cmd_call_workspace_action<H: PluginHost>(
host: H,
req: CmdCallWorkspaceActionReq,
) -> Result<()> {
let inner = req.req;
let workspace = host.db().get_workspace(&inner.args.workspace.id)?;
host.call_workspace_action(CallWorkspaceActionRequest {
args: CallWorkspaceActionArgs { workspace },
..inner
})
.await
}
pub async fn cmd_call_folder_action<H: PluginHost>(
host: H,
req: CmdCallFolderActionReq,
) -> Result<()> {
let inner = req.req;
let folder = host.db().get_folder(&inner.args.folder.id)?;
host.call_folder_action(CallFolderActionRequest {
args: CallFolderActionArgs { folder },
..inner
})
.await
}
// -- Other things the plugin runtime does --
/// Turn a `curl` command line into an unsaved request, by handing it to the
/// same importer plugins that read files.
pub async fn cmd_curl_to_request<H: PluginHost>(
host: H,
req: CmdCurlToRequestReq,
) -> Result<HttpRequest> {
let imported = host.import_data(&req.command).await?;
let request = imported
.resources
.http_requests
.first()
.ok_or_else(|| Error::Generic("No curl command found".to_string()))?;
// Belongs to the workspace the user is importing into, and is not saved
// until they say so — hence the blank id.
let mut request = request.clone();
request.workspace_id = req.workspace_id;
request.id = String::new();
Ok(request)
}
/// Restart every plugin, returning whatever failed to come back up.
pub async fn cmd_reload_plugins<H: PluginHost>(
host: H,
_req: CmdReloadPluginsReq,
) -> Result<Vec<(String, String)>> {
let plugins = host.db().list_plugins()?;
Ok(host.reload_plugins(plugins).await)
}
+102
View File
@@ -0,0 +1,102 @@
//! Authentication config forms and their actions.
//!
//! Both commands here do the same preparation: the frontend sends the model
//! whose auth is being edited plus the values currently in the form, and those
//! values may contain templates. They have to be rendered against the model's
//! own environment chain before a plugin sees them, or an auth plugin receives
//! `${[ api_key ]}` where it expected a key.
use crate::error::{Error, Result};
use crate::host::PluginHost;
use crate::render::render_json_value;
use std::collections::HashMap;
use yaak_models::models::AnyModel;
use yaak_plugins::events::{
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, JsonPrimitive,
RenderPurpose,
};
use yaak_rpc_schema::*;
use yaak_templates::RenderOptions;
pub async fn cmd_get_http_authentication_summaries<H: PluginHost>(
host: H,
_req: CmdGetHttpAuthenticationSummariesReq,
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
host.http_authentication_summaries().await
}
pub async fn cmd_get_http_authentication_config<H: PluginHost>(
host: H,
req: CmdGetHttpAuthenticationConfigReq,
) -> Result<GetHttpAuthenticationConfigResponse> {
// A config form is being displayed, so a template that cannot resolve
// should show as blank rather than refuse to open the form.
let values = render_auth_values(
&host,
&req.model,
req.environment_id.as_deref(),
req.values,
RenderPurpose::Preview,
&RenderOptions::return_empty(),
)
.await?;
host.http_authentication_config(&req.auth_name, values, req.model.id()).await
}
pub async fn cmd_call_http_authentication_action<H: PluginHost>(
host: H,
req: CmdCallHttpAuthenticationActionReq,
) -> Result<()> {
// An action actually uses these values, so an unresolvable template is an
// error rather than an empty string that would silently authenticate wrong.
let values = render_auth_values(
&host,
&req.model,
req.environment_id.as_deref(),
req.values,
RenderPurpose::Send,
&RenderOptions::throw(),
)
.await?;
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
.await
}
/// Render the form's values against the environment chain the model sits in.
///
/// The chain depends on where the model lives — a request inherits through its
/// folder, a workspace has only its own — so the model is what decides which
/// variables are in scope.
async fn render_auth_values<H: PluginHost>(
host: &H,
model: &AnyModel,
environment_id: Option<&str>,
values: HashMap<String, JsonPrimitive>,
purpose: RenderPurpose,
options: &RenderOptions,
) -> Result<HashMap<String, JsonPrimitive>> {
let (workspace_id, folder_id) = match model {
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
AnyModel::Workspace(w) => (w.id.clone(), None),
other => {
return Err(Error::Generic(format!(
"Cannot resolve authentication for a {}",
other.model()
)));
}
};
let environment_chain =
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
let cb = host.template_callback(purpose);
let rendered =
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
Ok(serde_json::from_value(rendered)?)
}
+23
View File
@@ -0,0 +1,23 @@
//! Export and formatting.
use crate::error::Result;
use crate::host::Host;
use std::path::Path;
use yaak::export::{self, ExportDataParams};
use yaak_rpc_schema::*;
use yaak_templates::format_json::format_json;
pub async fn cmd_export_data<H: Host>(host: H, req: CmdExportDataReq) -> Result<()> {
let version = host.app_version();
Ok(export::export_data(ExportDataParams {
query_manager: host.query_manager(),
yaak_version: &version,
export_path: Path::new(&req.export_path),
workspace_ids: req.workspace_ids.iter().map(|s| s.as_str()).collect(),
include_private_environments: req.include_private_environments,
})?)
}
pub async fn cmd_format_json<H: Host>(_host: H, req: CmdFormatJsonReq) -> Result<String> {
Ok(format_json(&req.text, " "))
}
+41
View File
@@ -0,0 +1,41 @@
//! Workspace encryption keys and the `secure()` template function.
use crate::error::Result;
use crate::host::{Host, PluginHost};
use yaak_plugins::native_template_functions::decrypt_secure_template_function;
use yaak_rpc_schema::*;
pub async fn cmd_enable_encryption<H: Host>(host: H, req: CmdEnableEncryptionReq) -> Result<()> {
host.encryption_manager().ensure_workspace_key(&req.workspace_id)?;
host.encryption_manager().reveal_workspace_key(&req.workspace_id)?;
Ok(())
}
pub async fn cmd_reveal_workspace_key<H: Host>(
host: H,
req: CmdRevealWorkspaceKeyReq,
) -> Result<String> {
Ok(host.encryption_manager().reveal_workspace_key(&req.workspace_id)?)
}
pub async fn cmd_set_workspace_key<H: Host>(host: H, req: CmdSetWorkspaceKeyReq) -> Result<()> {
host.encryption_manager().set_human_key(&req.workspace_id, &req.key)?;
Ok(())
}
pub async fn cmd_disable_encryption<H: Host>(host: H, req: CmdDisableEncryptionReq) -> Result<()> {
host.encryption_manager().disable_encryption(&req.workspace_id)?;
Ok(())
}
pub async fn cmd_decrypt_template<H: Host>(host: H, req: CmdDecryptTemplateReq) -> Result<String> {
let plugin_context = host.plugin_context();
Ok(decrypt_secure_template_function(host.encryption_manager(), &plugin_context, &req.template)?)
}
pub async fn cmd_secure_template<H: PluginHost>(
host: H,
req: CmdSecureTemplateReq,
) -> Result<String> {
host.encrypt_secure_template(&req.template).await
}
+30
View File
@@ -0,0 +1,30 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
Yaak(#[from] yaak::Error),
#[error(transparent)]
Model(#[from] yaak_models::error::Error),
#[error(transparent)]
Plugin(#[from] yaak_plugins::error::Error),
#[error(transparent)]
Crypto(#[from] yaak_crypto::error::Error),
#[error(transparent)]
Template(#[from] yaak_templates::error::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Generic(String),
}
pub type Result<T> = std::result::Result<T, Error>;

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