mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-02 08:37:18 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
448d349af8 | ||
|
|
99a318224f |
@@ -1,11 +0,0 @@
|
|||||||
node_modules
|
|
||||||
**/node_modules
|
|
||||||
dist
|
|
||||||
**/dist
|
|
||||||
target
|
|
||||||
**/target
|
|
||||||
.claude
|
|
||||||
vendored
|
|
||||||
**/vendored
|
|
||||||
*.log
|
|
||||||
.git
|
|
||||||
@@ -31,7 +31,3 @@ jobs:
|
|||||||
run: vp test
|
run: vp test
|
||||||
- name: Run Rust Tests
|
- name: Run Rust Tests
|
||||||
run: cargo test --all --features yaak-app-client/wry
|
run: cargo test --all --features yaak-app-client/wry
|
||||||
- name: OpenAPI import round-trip
|
|
||||||
run: |
|
|
||||||
cargo build -p yaak-cli
|
|
||||||
node plugins/importer-openapi/tests/roundtrip.mjs
|
|
||||||
|
|||||||
@@ -103,18 +103,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
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
|
- name: Install Protoc for plugin-runtime
|
||||||
uses: arduino/setup-protoc@v3
|
uses: arduino/setup-protoc@v3
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
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
+152
-414
File diff suppressed because it is too large
Load Diff
+2
-9
@@ -2,11 +2,9 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/yaak",
|
"crates/yaak",
|
||||||
"crates/yaak-commands",
|
|
||||||
# Common/foundation crates
|
# Common/foundation crates
|
||||||
"crates/common/yaak-database",
|
"crates/common/yaak-database",
|
||||||
"crates/common/yaak-rpc",
|
"crates/common/yaak-rpc",
|
||||||
"crates/common/yaak-rpc-schema",
|
|
||||||
# Shared crates (no Tauri dependency)
|
# Shared crates (no Tauri dependency)
|
||||||
"crates/yaak-core",
|
"crates/yaak-core",
|
||||||
"crates/yaak-common",
|
"crates/yaak-common",
|
||||||
@@ -14,7 +12,6 @@ members = [
|
|||||||
"crates/yaak-git",
|
"crates/yaak-git",
|
||||||
"crates/yaak-grpc",
|
"crates/yaak-grpc",
|
||||||
"crates/yaak-http",
|
"crates/yaak-http",
|
||||||
"crates/yaak-lifecycle",
|
|
||||||
"crates/yaak-models",
|
"crates/yaak-models",
|
||||||
"crates/yaak-plugins",
|
"crates/yaak-plugins",
|
||||||
"crates/yaak-sse",
|
"crates/yaak-sse",
|
||||||
@@ -22,15 +19,14 @@ members = [
|
|||||||
"crates/yaak-templates",
|
"crates/yaak-templates",
|
||||||
"crates/yaak-tls",
|
"crates/yaak-tls",
|
||||||
"crates/yaak-ws",
|
"crates/yaak-ws",
|
||||||
"crates/yaak-wasm",
|
|
||||||
"crates/yaak-api",
|
"crates/yaak-api",
|
||||||
"crates/yaak-proxy",
|
"crates/yaak-proxy",
|
||||||
# Proxy-specific crates
|
# Proxy-specific crates
|
||||||
"crates-proxy/yaak-proxy-lib",
|
"crates-proxy/yaak-proxy-lib",
|
||||||
# Server crates (the browser tier's hosted send executor)
|
|
||||||
"crates-server/yaak-web",
|
|
||||||
# CLI crates
|
# CLI crates
|
||||||
"crates-cli/yaak-cli",
|
"crates-cli/yaak-cli",
|
||||||
|
# Headless server crates
|
||||||
|
"crates-server/yaak-server",
|
||||||
# Tauri-specific crates
|
# Tauri-specific crates
|
||||||
"crates-tauri/yaak-app-client",
|
"crates-tauri/yaak-app-client",
|
||||||
"crates-tauri/yaak-app-proxy",
|
"crates-tauri/yaak-app-proxy",
|
||||||
@@ -69,18 +65,15 @@ ts-rs = "11.1.0"
|
|||||||
# Internal crates - common/foundation
|
# Internal crates - common/foundation
|
||||||
yaak-database = { path = "crates/common/yaak-database" }
|
yaak-database = { path = "crates/common/yaak-database" }
|
||||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
||||||
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
|
|
||||||
|
|
||||||
# Internal crates - shared
|
# Internal crates - shared
|
||||||
yaak-core = { path = "crates/yaak-core" }
|
yaak-core = { path = "crates/yaak-core" }
|
||||||
yaak = { path = "crates/yaak" }
|
yaak = { path = "crates/yaak" }
|
||||||
yaak-commands = { path = "crates/yaak-commands" }
|
|
||||||
yaak-common = { path = "crates/yaak-common" }
|
yaak-common = { path = "crates/yaak-common" }
|
||||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||||
yaak-git = { path = "crates/yaak-git" }
|
yaak-git = { path = "crates/yaak-git" }
|
||||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||||
yaak-http = { path = "crates/yaak-http" }
|
yaak-http = { path = "crates/yaak-http" }
|
||||||
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
|
||||||
yaak-models = { path = "crates/yaak-models" }
|
yaak-models = { path = "crates/yaak-models" }
|
||||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||||
yaak-sse = { path = "crates/yaak-sse" }
|
yaak-sse = { path = "crates/yaak-sse" }
|
||||||
|
|||||||
@@ -44,25 +44,6 @@ After bootstrapping, start the app in development mode:
|
|||||||
npm start
|
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
|
## SQLite Migrations
|
||||||
|
|
||||||
New migrations can be created from the `src-tauri/` directory:
|
New migrations can be created from the `src-tauri/` directory:
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
@@ -1,37 +1,19 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import type { SettingsTab } from "../components/Settings/Settings";
|
||||||
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
|
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
|
// Allow tab with optional subtab (e.g., "plugins:installed")
|
||||||
|
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
||||||
|
|
||||||
|
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
||||||
mutationKey: ["open_settings"],
|
mutationKey: ["open_settings"],
|
||||||
mutationFn: async (tab) => {
|
mutationFn: async (tab) => {
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||||
if (workspaceId == null) return;
|
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({
|
const location = router.buildLocation({
|
||||||
to: "/workspaces/$workspaceId/settings",
|
to: "/workspaces/$workspaceId/settings",
|
||||||
params: { workspaceId },
|
params: { workspaceId },
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
||||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||||
@@ -25,9 +24,7 @@ export const switchWorkspace = createFastMutation<
|
|||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// A host without windows opens the workspace here instead. Refusing would
|
if (inNewWindow) {
|
||||||
// strand the user on the workspace they were trying to leave.
|
|
||||||
if (inNewWindow && platform.capabilities.multiWindow) {
|
|
||||||
const location = router.buildLocation({
|
const location = router.buildLocation({
|
||||||
to: "/workspaces/$workspaceId",
|
to: "/workspaces/$workspaceId",
|
||||||
params: { workspaceId },
|
params: { workspaceId },
|
||||||
|
|||||||
@@ -67,14 +67,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
|||||||
)}
|
)}
|
||||||
// If no environments, the button simply opens the dialog.
|
// If no environments, the button simply opens the dialog.
|
||||||
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
||||||
onClick={
|
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
||||||
subEnvironments.length === 0
|
|
||||||
? (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
editEnvironment(null);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ function EnsureCompleteResponse({
|
|||||||
Component,
|
Component,
|
||||||
}: {
|
}: {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
Component: ComponentType<{ bodyUrl: string }>;
|
Component: ComponentType<{ url: string }>;
|
||||||
}) {
|
}) {
|
||||||
// Wait until the response has been fully-downloaded before asking for it
|
// Wait until the response has been fully-downloaded before asking for it
|
||||||
const complete = response.state === "closed";
|
const complete = response.state === "closed";
|
||||||
@@ -432,7 +432,7 @@ function EnsureCompleteResponse({
|
|||||||
return <div>Empty response body</div>;
|
return <div>Empty response body</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Component bodyUrl={bodyUrl.data} />;
|
return <Component url={bodyUrl.data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||||
|
|||||||
@@ -1,138 +1,56 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import { VStack } from "@yaakapp-internal/ui";
|
||||||
import { Icon, VStack } from "@yaakapp-internal/ui";
|
import { useState } from "react";
|
||||||
import classNames from "classnames";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { SelectFile } from "./SelectFile";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
importFile: (filePath: string) => Promise<void>;
|
importData: (filePath: string) => Promise<void>;
|
||||||
importUrl: (url: string) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function ImportDataDialog({ importData }: Props) {
|
||||||
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
|
|
||||||
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
|
|
||||||
*/
|
|
||||||
function isFilePath(value: string): boolean {
|
|
||||||
return (
|
|
||||||
value.startsWith("/") ||
|
|
||||||
value.startsWith("./") ||
|
|
||||||
value.startsWith("../") ||
|
|
||||||
value.startsWith("~/") ||
|
|
||||||
value.startsWith("\\\\") ||
|
|
||||||
/^[a-zA-Z]:[\\/]/.test(value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileName(path: string): string {
|
|
||||||
return path.split(/[/\\]/).at(-1) || path;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImportDataDialog({ importFile, importUrl }: Props) {
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
|
||||||
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
|
|
||||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
|
||||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const trimmedSource = source?.trim() ?? "";
|
|
||||||
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
|
|
||||||
|
|
||||||
const selectSource = (value: string) => {
|
|
||||||
setSource(value);
|
|
||||||
// Remount the input so it shows the path of the newly-picked file
|
|
||||||
setForceUpdateKey((k) => k + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
|
|
||||||
useEffect(() => {
|
|
||||||
return platform.window.onDragDrop((event) => {
|
|
||||||
if (event.type === "over") {
|
|
||||||
const p = event.position;
|
|
||||||
const r = ref.current?.getBoundingClientRect();
|
|
||||||
if (r == null) return;
|
|
||||||
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
|
|
||||||
} else if (event.type === "drop" && isHovering) {
|
|
||||||
const p = event.paths[0];
|
|
||||||
if (p) selectSource(p);
|
|
||||||
setIsHovering(false);
|
|
||||||
} else {
|
|
||||||
setIsHovering(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [isHovering, setSource]);
|
|
||||||
|
|
||||||
const handleSelectFile = async () => {
|
|
||||||
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
|
||||||
if (selected == null) return;
|
|
||||||
selectSource(selected);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImport = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
if (filePath != null) {
|
|
||||||
await importFile(filePath);
|
|
||||||
} else {
|
|
||||||
await importUrl(trimmedSource);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack ref={ref} space={4} className="pb-4">
|
<VStack space={5} className="pb-4">
|
||||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||||
|
|
||||||
<button
|
<VStack space={1}>
|
||||||
type="button"
|
<ul className="list-disc pl-5">
|
||||||
onClick={handleSelectFile}
|
<li>OpenAPI 3.0, 3.1</li>
|
||||||
className={classNames(
|
<li>Postman Collection v2, v2.1</li>
|
||||||
"w-full rounded-lg border border-dashed px-4 py-6",
|
<li>Insomnia v4+</li>
|
||||||
"flex flex-col items-center gap-1 text-center",
|
<li>Swagger 2.0</li>
|
||||||
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
|
<li>
|
||||||
)}
|
Curl commands <em className="text-text-subtle">(or paste into URL)</em>
|
||||||
>
|
</li>
|
||||||
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
</ul>
|
||||||
{/* Fixed height so the region doesn't resize between the empty and selected states */}
|
</VStack>
|
||||||
<div className="h-6 w-full flex items-center justify-center">
|
|
||||||
{filePath == null ? (
|
|
||||||
<div className="text-text">
|
|
||||||
<strong className="font-semibold">Choose a file</strong> or drag it here
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
|
|
||||||
{fileName(filePath)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-subtlest">
|
|
||||||
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<VStack space={2}>
|
<VStack space={2}>
|
||||||
<PlainInput
|
<SelectFile
|
||||||
label="Or enter a file path or URL"
|
filePath={filePath ?? null}
|
||||||
size="sm"
|
onChange={({ filePath }) => setFilePath(filePath)}
|
||||||
placeholder="https://example.com/openapi.json"
|
|
||||||
defaultValue={source ?? ""}
|
|
||||||
forceUpdateKey={String(forceUpdateKey)}
|
|
||||||
onChange={setSource}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
{filePath && (
|
||||||
color="primary"
|
<Button
|
||||||
disabled={trimmedSource === "" || isLoading}
|
color="primary"
|
||||||
isLoading={isLoading}
|
disabled={!filePath || isLoading}
|
||||||
size="sm"
|
isLoading={isLoading}
|
||||||
onClick={handleImport}
|
size="sm"
|
||||||
>
|
onClick={async () => {
|
||||||
{isLoading ? "Importing" : "Import"}
|
setIsLoading(true);
|
||||||
</Button>
|
try {
|
||||||
|
await importData(filePath);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? "Importing" : "Import"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import type {
|
|||||||
Folder,
|
Folder,
|
||||||
GrpcRequest,
|
GrpcRequest,
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
HttpVersion,
|
|
||||||
InheritedBoolSetting,
|
InheritedBoolSetting,
|
||||||
InheritedHttpVersionSetting,
|
|
||||||
InheritedIntSetting,
|
InheritedIntSetting,
|
||||||
WebsocketRequest,
|
WebsocketRequest,
|
||||||
Workspace,
|
Workspace,
|
||||||
@@ -15,7 +13,6 @@ import {
|
|||||||
modelSupportsSetting,
|
modelSupportsSetting,
|
||||||
type RequestSettingDefinition,
|
type RequestSettingDefinition,
|
||||||
SETTING_FOLLOW_REDIRECTS,
|
SETTING_FOLLOW_REDIRECTS,
|
||||||
SETTING_HTTP_VERSION,
|
|
||||||
SETTING_REQUEST_MESSAGE_SIZE,
|
SETTING_REQUEST_MESSAGE_SIZE,
|
||||||
SETTING_REQUEST_TIMEOUT,
|
SETTING_REQUEST_TIMEOUT,
|
||||||
SETTING_SEND_COOKIES,
|
SETTING_SEND_COOKIES,
|
||||||
@@ -24,7 +21,6 @@ import {
|
|||||||
} from "../lib/requestSettings";
|
} from "../lib/requestSettings";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { Select } from "./core/Select";
|
|
||||||
import {
|
import {
|
||||||
SettingOverrideRow,
|
SettingOverrideRow,
|
||||||
SettingRow,
|
SettingRow,
|
||||||
@@ -42,21 +38,37 @@ interface Props {
|
|||||||
model: ModelWithSettings;
|
model: ModelWithSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
type ModelWithSettings =
|
||||||
|
| Workspace
|
||||||
|
| Folder
|
||||||
|
| HttpRequest
|
||||||
|
| WebsocketRequest
|
||||||
|
| GrpcRequest;
|
||||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||||
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
type ModelWithTlsSettings =
|
||||||
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
|
| Workspace
|
||||||
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
|
| Folder
|
||||||
|
| HttpRequest
|
||||||
|
| WebsocketRequest
|
||||||
|
| GrpcRequest;
|
||||||
|
type ModelWithCookieSettings =
|
||||||
|
| Workspace
|
||||||
|
| Folder
|
||||||
|
| HttpRequest
|
||||||
|
| WebsocketRequest;
|
||||||
|
type ModelWithMessageSizeSettings =
|
||||||
|
| Workspace
|
||||||
|
| Folder
|
||||||
|
| WebsocketRequest
|
||||||
|
| GrpcRequest;
|
||||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
type BooleanSetting = boolean | InheritedBoolSetting;
|
||||||
type IntegerSetting = number | InheritedIntSetting;
|
type IntegerSetting = number | InheritedIntSetting;
|
||||||
type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
|
|
||||||
type CookieSettingsPatch = {
|
type CookieSettingsPatch = {
|
||||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
||||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
||||||
};
|
};
|
||||||
type HttpSettingsPatch = {
|
type HttpSettingsPatch = {
|
||||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
||||||
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
|
|
||||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
||||||
};
|
};
|
||||||
type TlsSettingsPatch = {
|
type TlsSettingsPatch = {
|
||||||
@@ -66,7 +78,10 @@ type MessageSizeSettingsPatch = {
|
|||||||
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
export function ModelSettingsEditor({
|
||||||
|
model,
|
||||||
|
showSectionTitles = false,
|
||||||
|
}: Props) {
|
||||||
const ancestors = useModelAncestors(model);
|
const ancestors = useModelAncestors(model);
|
||||||
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
||||||
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
||||||
@@ -139,26 +154,12 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{supportsHttpSettings && (
|
|
||||||
<HttpVersionSettingRow
|
|
||||||
settingDefinition={SETTING_HTTP_VERSION}
|
|
||||||
setting={model.settingHttpVersion}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
SETTING_HTTP_VERSION.modelKey,
|
|
||||||
model.settingHttpVersion,
|
|
||||||
)}
|
|
||||||
onChange={(settingHttpVersion) =>
|
|
||||||
patchHttpSettings(model, {
|
|
||||||
settingHttpVersion,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
{supportsCookieSettings && (
|
{supportsCookieSettings && (
|
||||||
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
|
<SettingsSection
|
||||||
|
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
|
||||||
|
>
|
||||||
<BooleanSettingRow
|
<BooleanSettingRow
|
||||||
settingDefinition={SETTING_SEND_COOKIES}
|
settingDefinition={SETTING_SEND_COOKIES}
|
||||||
setting={model.settingSendCookies}
|
setting={model.settingSendCookies}
|
||||||
@@ -194,7 +195,7 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
export function countOverriddenSettings(model: ModelWithSettings) {
|
||||||
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
|
const settings: (BooleanSetting | IntegerSetting)[] = [];
|
||||||
|
|
||||||
if (modelSupportsCookieSettings(model)) {
|
if (modelSupportsCookieSettings(model)) {
|
||||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
||||||
@@ -203,22 +204,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
|
|||||||
settings.push(model.settingValidateCertificates);
|
settings.push(model.settingValidateCertificates);
|
||||||
|
|
||||||
if (modelSupportsHttpSettings(model)) {
|
if (modelSupportsHttpSettings(model)) {
|
||||||
settings.push(
|
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
||||||
model.settingFollowRedirects,
|
|
||||||
model.settingRequestTimeout,
|
|
||||||
model.settingHttpVersion,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelSupportsMessageSizeSettings(model)) {
|
if (modelSupportsMessageSizeSettings(model)) {
|
||||||
settings.push(model.settingRequestMessageSize);
|
settings.push(model.settingRequestMessageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
return settings.filter(
|
||||||
.length;
|
(setting) => isInheritedSetting(setting) && setting.enabled === true,
|
||||||
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
function patchCookieSettings(
|
||||||
|
model: ModelWithCookieSettings,
|
||||||
|
patch: Partial<CookieSettingsPatch>,
|
||||||
|
) {
|
||||||
switch (model.model) {
|
switch (model.model) {
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
@@ -231,7 +232,10 @@ function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<Cook
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
function patchHttpSettings(
|
||||||
|
model: ModelWithHttpSettings,
|
||||||
|
patch: Partial<HttpSettingsPatch>,
|
||||||
|
) {
|
||||||
switch (model.model) {
|
switch (model.model) {
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
@@ -242,7 +246,10 @@ function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSett
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
|
function patchTlsSettings(
|
||||||
|
model: ModelWithTlsSettings,
|
||||||
|
patch: Partial<TlsSettingsPatch>,
|
||||||
|
) {
|
||||||
switch (model.model) {
|
switch (model.model) {
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
@@ -273,15 +280,21 @@ function patchMessageSizeSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
|
function modelSupportsHttpSettings(
|
||||||
|
model: ModelWithSettings,
|
||||||
|
): model is ModelWithHttpSettings {
|
||||||
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
|
function modelSupportsCookieSettings(
|
||||||
|
model: ModelWithSettings,
|
||||||
|
): model is ModelWithCookieSettings {
|
||||||
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
|
function modelSupportsTlsSettings(
|
||||||
|
model: ModelWithSettings,
|
||||||
|
): model is ModelWithTlsSettings {
|
||||||
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +317,11 @@ function BooleanSettingRow({
|
|||||||
}) {
|
}) {
|
||||||
const inherited = isInheritedSetting(setting);
|
const inherited = isInheritedSetting(setting);
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
const value = inherited
|
||||||
|
? overridden
|
||||||
|
? setting.value
|
||||||
|
: inheritedValue
|
||||||
|
: setting;
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
@@ -335,63 +352,6 @@ function BooleanSettingRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
|
|
||||||
{ label: "Automatic", value: "auto" },
|
|
||||||
{ label: "HTTP/1.1", value: "http1" },
|
|
||||||
{ label: "HTTP/2", value: "http2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function HttpVersionSettingRow({
|
|
||||||
inheritedValue,
|
|
||||||
setting,
|
|
||||||
settingDefinition,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
inheritedValue: HttpVersion;
|
|
||||||
setting: HttpVersionSetting;
|
|
||||||
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
|
|
||||||
onChange: (setting: HttpVersionSetting) => void;
|
|
||||||
}) {
|
|
||||||
const inherited = isInheritedSetting(setting);
|
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
|
||||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name={settingDefinition.modelKey}
|
|
||||||
label={settingDefinition.title}
|
|
||||||
size="sm"
|
|
||||||
value={value}
|
|
||||||
options={HTTP_VERSION_OPTIONS}
|
|
||||||
onChange={(value) => onChange(value)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingOverrideRow
|
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
overridden={overridden}
|
|
||||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name={settingDefinition.modelKey}
|
|
||||||
label={settingDefinition.title}
|
|
||||||
size="sm"
|
|
||||||
value={value}
|
|
||||||
options={HTTP_VERSION_OPTIONS}
|
|
||||||
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
|
||||||
/>
|
|
||||||
</SettingOverrideRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IntegerSettingRow({
|
function IntegerSettingRow({
|
||||||
inheritedValue,
|
inheritedValue,
|
||||||
setting,
|
setting,
|
||||||
@@ -405,11 +365,18 @@ function IntegerSettingRow({
|
|||||||
}) {
|
}) {
|
||||||
const inherited = isInheritedSetting(setting);
|
const inherited = isInheritedSetting(setting);
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
const value = inherited
|
||||||
|
? overridden
|
||||||
|
? setting.value
|
||||||
|
: inheritedValue
|
||||||
|
: setting;
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
<SettingRow
|
||||||
|
title={settingDefinition.title}
|
||||||
|
description={settingDefinition.description}
|
||||||
|
>
|
||||||
<NumberUnitInput
|
<NumberUnitInput
|
||||||
name={settingDefinition.modelKey}
|
name={settingDefinition.modelKey}
|
||||||
label={settingDefinition.title}
|
label={settingDefinition.title}
|
||||||
@@ -462,13 +429,20 @@ function MessageSizeSettingRow({
|
|||||||
}) {
|
}) {
|
||||||
const inherited = isInheritedSetting(setting);
|
const inherited = isInheritedSetting(setting);
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
const value = inherited
|
||||||
|
? overridden
|
||||||
|
? setting.value
|
||||||
|
: inheritedValue
|
||||||
|
: setting;
|
||||||
const displayValue = formatMegabytes(value);
|
const displayValue = formatMegabytes(value);
|
||||||
const placeholder = "0";
|
const placeholder = "0";
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
<SettingRow
|
||||||
|
title={settingDefinition.title}
|
||||||
|
description={settingDefinition.description}
|
||||||
|
>
|
||||||
<MessageSizeInput
|
<MessageSizeInput
|
||||||
name={settingDefinition.modelKey}
|
name={settingDefinition.modelKey}
|
||||||
label={settingDefinition.title}
|
label={settingDefinition.title}
|
||||||
@@ -593,18 +567,13 @@ function resolveInheritedValue(
|
|||||||
key: BooleanWorkspaceSettingKey,
|
key: BooleanWorkspaceSettingKey,
|
||||||
fallback: BooleanSetting,
|
fallback: BooleanSetting,
|
||||||
): boolean;
|
): boolean;
|
||||||
function resolveInheritedValue(
|
|
||||||
ancestors: (Folder | Workspace)[],
|
|
||||||
key: "settingHttpVersion",
|
|
||||||
fallback: HttpVersionSetting,
|
|
||||||
): HttpVersion;
|
|
||||||
function resolveInheritedValue(
|
function resolveInheritedValue(
|
||||||
ancestors: (Folder | Workspace)[],
|
ancestors: (Folder | Workspace)[],
|
||||||
key: keyof WorkspaceSettings,
|
key: keyof WorkspaceSettings,
|
||||||
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
|
fallback: BooleanSetting | IntegerSetting,
|
||||||
) {
|
) {
|
||||||
for (const ancestor of ancestors) {
|
for (const ancestor of ancestors) {
|
||||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
|
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
||||||
if (isInheritedSetting(setting)) {
|
if (isInheritedSetting(setting)) {
|
||||||
if (setting.enabled === true) {
|
if (setting.enabled === true) {
|
||||||
return setting.value;
|
return setting.value;
|
||||||
@@ -620,7 +589,6 @@ function resolveInheritedValue(
|
|||||||
type WorkspaceSettings = Pick<
|
type WorkspaceSettings = Pick<
|
||||||
Workspace,
|
Workspace,
|
||||||
| "settingFollowRedirects"
|
| "settingFollowRedirects"
|
||||||
| "settingHttpVersion"
|
|
||||||
| "settingRequestMessageSize"
|
| "settingRequestMessageSize"
|
||||||
| "settingRequestTimeout"
|
| "settingRequestTimeout"
|
||||||
| "settingSendCookies"
|
| "settingSendCookies"
|
||||||
@@ -630,12 +598,14 @@ type WorkspaceSettings = Pick<
|
|||||||
|
|
||||||
type BooleanWorkspaceSettingKey = Exclude<
|
type BooleanWorkspaceSettingKey = Exclude<
|
||||||
keyof WorkspaceSettings,
|
keyof WorkspaceSettings,
|
||||||
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
|
"settingRequestTimeout" | "settingRequestMessageSize"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function formatMegabytes(bytes: number) {
|
function formatMegabytes(bytes: number) {
|
||||||
const megabytes = bytes / BYTES_PER_MB;
|
const megabytes = bytes / BYTES_PER_MB;
|
||||||
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
|
return Number.isInteger(megabytes)
|
||||||
|
? `${megabytes}`
|
||||||
|
: megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMegabytes(value: string) {
|
function parseMegabytes(value: string) {
|
||||||
@@ -656,5 +626,9 @@ function isValidInteger(value: string) {
|
|||||||
function isValidMegabytes(value: string) {
|
function isValidMegabytes(value: string) {
|
||||||
if (value === "") return true;
|
if (value === "") return true;
|
||||||
const megabytes = Number(value);
|
const megabytes = Number(value);
|
||||||
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
|
return (
|
||||||
|
Number.isFinite(megabytes) &&
|
||||||
|
megabytes >= 0 &&
|
||||||
|
megabytes <= MAX_MESSAGE_SIZE_MB
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useSearch } from "@tanstack/react-router";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||||
@@ -19,8 +20,6 @@ import { SettingsProxy } from "./SettingsProxy";
|
|||||||
import { SettingsTheme } from "./SettingsTheme";
|
import { SettingsTheme } from "./SettingsTheme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tab?: SettingsTabWithSubtab | null;
|
|
||||||
/** Set when Settings is in a dialog rather than owning a window. */
|
|
||||||
hide?: () => void;
|
hide?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,19 +42,25 @@ const tabs = [
|
|||||||
TAB_LICENSE,
|
TAB_LICENSE,
|
||||||
] as const;
|
] as const;
|
||||||
export type SettingsTab = (typeof tabs)[number];
|
export type SettingsTab = (typeof tabs)[number];
|
||||||
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
|
|
||||||
|
|
||||||
export default function Settings({ tab, hide }: Props) {
|
export default function Settings({ hide }: Props) {
|
||||||
|
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
|
||||||
// Parse tab and subtab (e.g., "plugins:installed")
|
// Parse tab and subtab (e.g., "plugins:installed")
|
||||||
const [mainTab, subtab] = tab?.split(":") ?? [];
|
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const plugins = useAtomValue(pluginsAtom);
|
const plugins = useAtomValue(pluginsAtom);
|
||||||
const licenseCheck = useLicense();
|
const licenseCheck = useLicense();
|
||||||
|
|
||||||
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
|
// Close settings window on escape
|
||||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||||
useKeyPressEvent("Escape", async () => {
|
useKeyPressEvent("Escape", async () => {
|
||||||
if (hide == null) await platform.window.close();
|
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -85,7 +90,7 @@ export default function Settings({ tab, hide }: Props) {
|
|||||||
)}
|
)}
|
||||||
<Tabs
|
<Tabs
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
defaultValue={mainTab}
|
defaultValue={mainTab || tabFromQuery}
|
||||||
addBorders
|
addBorders
|
||||||
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||||
label="Settings"
|
label="Settings"
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
|
|||||||
<HotkeyRow
|
<HotkeyRow
|
||||||
key={action}
|
key={action}
|
||||||
action={action}
|
action={action}
|
||||||
currentKeys={hotkeys[action] ?? []}
|
currentKeys={hotkeys[action]}
|
||||||
defaultKeys={defaultHotkeys[action]}
|
defaultKeys={defaultHotkeys[action]}
|
||||||
onSave={async (keys) => {
|
onSave={async (keys) => {
|
||||||
const newHotkeys = { ...settings.hotkeys };
|
const newHotkeys = { ...settings.hotkeys };
|
||||||
|
|||||||
@@ -36,16 +36,12 @@ import { fireAndForget } from "../../lib/fireAndForget";
|
|||||||
import { ErrorBoundary } from "../ErrorBoundary";
|
import { ErrorBoundary } from "../ErrorBoundary";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Hotkey } from "./Hotkey";
|
import { Hotkey } from "./Hotkey";
|
||||||
import { IconButton } from "./IconButton";
|
|
||||||
import type { SeparatorAction } from "./Separator";
|
|
||||||
import { Separator } from "./Separator";
|
import { Separator } from "./Separator";
|
||||||
|
|
||||||
export type DropdownItemSeparator = {
|
export type DropdownItemSeparator = {
|
||||||
type: "separator";
|
type: "separator";
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
/** A control shown beside the label, eg. revealing the labelled file on disk. */
|
|
||||||
action?: SeparatorAction;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DropdownItemContent = {
|
export type DropdownItemContent = {
|
||||||
@@ -70,12 +66,6 @@ export type DropdownItemDefault = {
|
|||||||
submenu?: DropdownItem[];
|
submenu?: DropdownItem[];
|
||||||
/** If true, submenu opens on click instead of hover */
|
/** If true, submenu opens on click instead of hover */
|
||||||
submenuOpenOnClick?: boolean;
|
submenuOpenOnClick?: boolean;
|
||||||
/**
|
|
||||||
* How the submenu opens. "row" (default) opens it from the row itself (hover, or click
|
|
||||||
* with submenuOpenOnClick). "button" keeps the row selectable via onSelect and renders
|
|
||||||
* a dedicated button on the right that opens the submenu.
|
|
||||||
*/
|
|
||||||
submenuTrigger?: "row" | "button";
|
|
||||||
icon?: IconProps["icon"];
|
icon?: IconProps["icon"];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -512,15 +502,9 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.keepOpenOnSelect) {
|
if (!item.keepOpenOnSelect) handleCloseAll();
|
||||||
handleCloseAll();
|
|
||||||
} else if (isSubmenu) {
|
|
||||||
// Keep the parent menu open, but close this submenu — its items may no
|
|
||||||
// longer describe the row after the action (e.g. Pin → Unpin, Remove)
|
|
||||||
handleClose();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[handleCloseAll, handleClose, isSubmenu, setSelectedIndex],
|
[handleCloseAll, setSelectedIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => {
|
useImperativeHandle(ref, () => {
|
||||||
@@ -645,7 +629,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
const item = filteredItems[selectedIndex ?? -1];
|
const item = filteredItems[selectedIndex ?? -1];
|
||||||
if (!item || item.type === "separator" || item.type === "content") return;
|
if (!item || item.type === "separator" || item.type === "content") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (item.submenu && item.submenuTrigger !== "button") {
|
if (item.submenu) {
|
||||||
const parent = document.activeElement as HTMLButtonElement;
|
const parent = document.activeElement as HTMLButtonElement;
|
||||||
if (parent) {
|
if (parent) {
|
||||||
setActiveSubmenu({ item, parent, viaKeyboard: true });
|
setActiveSubmenu({ item, parent, viaKeyboard: true });
|
||||||
@@ -664,11 +648,9 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
clearTimeout(submenuTimeoutRef.current);
|
clearTimeout(submenuTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
|
if (item.submenu && !item.submenuOpenOnClick) {
|
||||||
setActiveSubmenu({ item, parent });
|
setActiveSubmenu({ item, parent });
|
||||||
} else if (activeSubmenu && activeSubmenu.item !== item) {
|
} else if (activeSubmenu) {
|
||||||
// Hovering the row that owns the open submenu must not dismiss it — the
|
|
||||||
// pointer travels across the row on its way to a button-triggered submenu
|
|
||||||
submenuTimeoutRef.current = window.setTimeout(() => {
|
submenuTimeoutRef.current = window.setTimeout(() => {
|
||||||
const submenuEl = submenuRef.current;
|
const submenuEl = submenuRef.current;
|
||||||
if (!submenuEl || !activeSubmenu) {
|
if (!submenuEl || !activeSubmenu) {
|
||||||
@@ -794,7 +776,6 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
// oxlint-disable-next-line no-array-index-key -- Nothing else available
|
// oxlint-disable-next-line no-array-index-key -- Nothing else available
|
||||||
key={i}
|
key={i}
|
||||||
className={classNames("my-1.5", item.label ? "ml-2" : null)}
|
className={classNames("my-1.5", item.label ? "ml-2" : null)}
|
||||||
action={item.action}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Separator>
|
</Separator>
|
||||||
@@ -816,7 +797,6 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onHover={handleItemHover}
|
onHover={handleItemHover}
|
||||||
onOpenSubmenu={(item, el) => setActiveSubmenu({ item, parent: el })}
|
|
||||||
// oxlint-disable-next-line no-array-index-key -- It's fine
|
// oxlint-disable-next-line no-array-index-key -- It's fine
|
||||||
key={i}
|
key={i}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -888,7 +868,6 @@ interface MenuItemProps {
|
|||||||
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
|
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
|
||||||
onFocus: (item: DropdownItemDefault) => void;
|
onFocus: (item: DropdownItemDefault) => void;
|
||||||
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||||
onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
isParentOfActiveSubmenu?: boolean;
|
isParentOfActiveSubmenu?: boolean;
|
||||||
}
|
}
|
||||||
@@ -900,7 +879,6 @@ function MenuItem({
|
|||||||
onHover,
|
onHover,
|
||||||
item,
|
item,
|
||||||
onSelect,
|
onSelect,
|
||||||
onOpenSubmenu,
|
|
||||||
isParentOfActiveSubmenu,
|
isParentOfActiveSubmenu,
|
||||||
...props
|
...props
|
||||||
}: MenuItemProps) {
|
}: MenuItemProps) {
|
||||||
@@ -936,22 +914,19 @@ function MenuItem({
|
|||||||
e.currentTarget.focus();
|
e.currentTarget.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
|
const rightSlot = item.submenu ? (
|
||||||
|
<Icon icon="chevron_right" color="secondary" />
|
||||||
|
) : (
|
||||||
|
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
|
||||||
|
);
|
||||||
|
|
||||||
const rightSlot =
|
return (
|
||||||
item.submenu && !hasButtonSubmenu ? (
|
|
||||||
<Icon icon="chevron_right" color="secondary" />
|
|
||||||
) : (
|
|
||||||
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
|
|
||||||
);
|
|
||||||
|
|
||||||
const button = (
|
|
||||||
<Button
|
<Button
|
||||||
ref={initRef}
|
ref={initRef}
|
||||||
size="sm"
|
size="sm"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onMouseEnter={hasButtonSubmenu ? undefined : handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={hasButtonSubmenu ? undefined : (e) => e.currentTarget.blur()}
|
onMouseLeave={(e) => e.currentTarget.blur()}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -972,7 +947,6 @@ function MenuItem({
|
|||||||
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
|
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
|
||||||
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
|
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
|
||||||
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
|
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
|
||||||
hasButtonSubmenu && "pr-8",
|
|
||||||
item.color === "danger" && "text-danger!",
|
item.color === "danger" && "text-danger!",
|
||||||
item.color === "primary" && "text-primary!",
|
item.color === "primary" && "text-primary!",
|
||||||
item.color === "success" && "text-success!",
|
item.color === "success" && "text-success!",
|
||||||
@@ -985,52 +959,6 @@ function MenuItem({
|
|||||||
<div className={classNames("truncate min-w-20")}>{item.label}</div>
|
<div className={classNames("truncate min-w-20")}>{item.label}</div>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasButtonSubmenu) {
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The submenu trigger overlays the row as a sibling (not a child) because the row is
|
|
||||||
// itself a button and buttons cannot nest. Hover handling lives on this wrapper so the
|
|
||||||
// row keeps its focus highlight while the mouse is over the trigger.
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative grid group/menuitem"
|
|
||||||
onMouseEnter={() => {
|
|
||||||
const el = buttonRef.current;
|
|
||||||
if (el == null) return;
|
|
||||||
onHover(item, el);
|
|
||||||
el.focus();
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => buttonRef.current?.blur()}
|
|
||||||
>
|
|
||||||
{button}
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"absolute right-1.5 inset-y-0 flex items-center",
|
|
||||||
"opacity-0 group-hover/menuitem:opacity-100 group-focus-within/menuitem:opacity-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
color="custom"
|
|
||||||
size="2xs"
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="ellipsis_vertical"
|
|
||||||
iconColor="secondary"
|
|
||||||
title="More actions"
|
|
||||||
className="h-full! w-7!"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
// Prevent the trigger from stealing focus, which would unhighlight the row
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenSubmenu(item, e.currentTarget);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItemHotKeyProps {
|
interface MenuItemHotKeyProps {
|
||||||
|
|||||||
@@ -1,29 +1,9 @@
|
|||||||
import type { Diagnostic } from "@codemirror/lint";
|
import type { Diagnostic } from "@codemirror/lint";
|
||||||
import type { EditorView } from "@codemirror/view";
|
import type { EditorView } from "@codemirror/view";
|
||||||
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
import { parse as jsonLintParse } from "@prantlf/jsonlint";
|
||||||
|
|
||||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
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 {
|
interface JsonLintOptions {
|
||||||
allowComments?: boolean;
|
allowComments?: boolean;
|
||||||
allowTrailingCommas?: boolean;
|
allowTrailingCommas?: boolean;
|
||||||
@@ -31,28 +11,34 @@ interface JsonLintOptions {
|
|||||||
|
|
||||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||||
return (view: EditorView): Diagnostic[] => {
|
return (view: EditorView): Diagnostic[] => {
|
||||||
const doc = view.state.doc.toString();
|
try {
|
||||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
const doc = view.state.doc.toString();
|
||||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||||
|
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||||
|
jsonLintParse(escapedDoc, {
|
||||||
|
mode: (options?.allowComments ?? true) ? "cjson" : "json",
|
||||||
|
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
|
||||||
|
});
|
||||||
|
// oxlint-disable-next-line no-explicit-any
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!("location" in err)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const errors: ParseError[] = [];
|
// const line = location?.start?.line;
|
||||||
parse(escapedDoc, errors, {
|
// const column = location?.start?.column;
|
||||||
allowTrailingComma: options?.allowTrailingCommas ?? false,
|
if (err.location.start.offset) {
|
||||||
disallowComments: !(options?.allowComments ?? true),
|
return [
|
||||||
});
|
{
|
||||||
|
from: err.location.start.offset,
|
||||||
// Later errors are mostly consequences of the first one, so only that one is shown
|
to: err.location.start.offset,
|
||||||
const error = errors[0];
|
severity: "error",
|
||||||
if (error == null) return [];
|
message: err.message,
|
||||||
|
},
|
||||||
return [
|
];
|
||||||
{
|
}
|
||||||
from: error.offset,
|
}
|
||||||
to: error.offset + error.length,
|
return [];
|
||||||
severity: "error",
|
|
||||||
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,15 +113,7 @@ function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): P
|
|||||||
/** Max number of pairs to show before prompting the user to reveal the rest */
|
/** Max number of pairs to show before prompting the user to reveal the rest */
|
||||||
const MAX_INITIAL_PAIRS = 30;
|
const MAX_INITIAL_PAIRS = 30;
|
||||||
|
|
||||||
// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
|
export function PairEditor({
|
||||||
// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
|
|
||||||
// fast path below would swap in the new data without rebuilding the row editors, leaving any
|
|
||||||
// still-focused input showing the old owner's text.
|
|
||||||
export function PairEditor(props: PairEditorProps) {
|
|
||||||
return <PairEditorInner key={props.stateKey} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PairEditorInner({
|
|
||||||
allowFileValues,
|
allowFileValues,
|
||||||
allowMultilineValues,
|
allowMultilineValues,
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -1,31 +1,13 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
import type { Color } from "@yaakapp-internal/plugins";
|
||||||
import type { IconProps } from "@yaakapp-internal/ui";
|
|
||||||
import { IconButton } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
/**
|
|
||||||
* A single control attached to a labelled separator, rendered between the label
|
|
||||||
* and the rule.
|
|
||||||
*
|
|
||||||
* Declared rather than passed as a node so the separator keeps ownership of the
|
|
||||||
* things that are easy to get wrong by hand: matching the label's colour, and
|
|
||||||
* staying out of the rule's way when the label is long.
|
|
||||||
*/
|
|
||||||
export interface SeparatorAction {
|
|
||||||
icon: IconProps["icon"];
|
|
||||||
/** Tooltip and accessible name. Required — the control is icon-only. */
|
|
||||||
title: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
dashed?: boolean;
|
dashed?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
color?: Color;
|
color?: Color;
|
||||||
action?: SeparatorAction;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Separator({
|
export function Separator({
|
||||||
@@ -34,31 +16,15 @@ export function Separator({
|
|||||||
dashed,
|
dashed,
|
||||||
orientation = "horizontal",
|
orientation = "horizontal",
|
||||||
children,
|
children,
|
||||||
action,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
||||||
{children && (
|
{children && (
|
||||||
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
||||||
)}
|
)}
|
||||||
{action && (
|
|
||||||
<IconButton
|
|
||||||
size="2xs"
|
|
||||||
iconSize="xs"
|
|
||||||
className="shrink-0 mr-2 -ml-1"
|
|
||||||
// Forced, because the button itself sets `text-text` at full strength.
|
|
||||||
iconClassName="text-text-subtlest!"
|
|
||||||
icon={action.icon}
|
|
||||||
title={action.title}
|
|
||||||
onClick={action.onClick}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"opacity-60",
|
"opacity-60",
|
||||||
// Keep a stub of the line visible no matter how long the label is —
|
|
||||||
// `w-full` alone gets squeezed to nothing by a wide label.
|
|
||||||
orientation === "horizontal" && "min-w-8",
|
|
||||||
color == null && "border-border",
|
color == null && "border-border",
|
||||||
color === "primary" && "border-primary",
|
color === "primary" && "border-primary",
|
||||||
color === "secondary" && "border-secondary",
|
color === "secondary" && "border-secondary",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
@@ -12,7 +11,6 @@ import type { DropdownItem } from "../core/Dropdown";
|
|||||||
import { Dropdown } from "../core/Dropdown";
|
import { Dropdown } from "../core/Dropdown";
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
import type { EditorProps } from "../core/Editor/Editor";
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
import { Editor } from "../core/Editor/LazyEditor";
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
||||||
import { RadioDropdown } from "../core/RadioDropdown";
|
import { RadioDropdown } from "../core/RadioDropdown";
|
||||||
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
||||||
@@ -20,7 +18,6 @@ import { Separator } from "../core/Separator";
|
|||||||
import { tryFormatGraphql } from "../../lib/formatters";
|
import { tryFormatGraphql } from "../../lib/formatters";
|
||||||
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
||||||
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
||||||
import { revealInFinderText } from "../../lib/reveal";
|
|
||||||
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
||||||
|
|
||||||
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
||||||
@@ -31,10 +28,6 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
|
|||||||
|
|
||||||
const OPERATION_NAME_NOT_SPECIFIED = "";
|
const OPERATION_NAME_NOT_SPECIFIED = "";
|
||||||
|
|
||||||
// How much of the end of a schema filename is pinned when middle-truncating it.
|
|
||||||
// Enough to keep the extension and a little of the name before it.
|
|
||||||
const FILE_NAME_TAIL_CHARS = 12;
|
|
||||||
|
|
||||||
export function GraphQLEditor(props: Props) {
|
export function GraphQLEditor(props: Props) {
|
||||||
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
||||||
// key on the request ID as a workaround for now.
|
// key on the request ID as a workaround for now.
|
||||||
@@ -45,41 +38,9 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>("graphQLAutoIntrospectDisabled", {});
|
>("graphQLAutoIntrospectDisabled", {});
|
||||||
const {
|
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {
|
||||||
schema,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
clear,
|
|
||||||
loadFromFile,
|
|
||||||
reloadFromFile,
|
|
||||||
removeSchemaFile,
|
|
||||||
filePath,
|
|
||||||
} = useIntrospectGraphQL(baseRequest, {
|
|
||||||
disabled: autoIntrospectDisabled?.[baseRequest.id],
|
disabled: autoIntrospectDisabled?.[baseRequest.id],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Last path segment, for display only. The host owns real path semantics; this
|
|
||||||
// just needs something short enough to label the divider with.
|
|
||||||
const fileName = useMemo(() => filePath?.split(/[/\\]/).pop() || filePath, [filePath]);
|
|
||||||
|
|
||||||
// Selecting a file is all it takes — the request's source becomes that file,
|
|
||||||
// which is what keeps automatic introspection from overwriting it.
|
|
||||||
const handleLoadFromFile = useCallback(async () => {
|
|
||||||
const selected = await platform.dialog.open({
|
|
||||||
title: "Load GraphQL Schema",
|
|
||||||
multiple: false,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: "GraphQL Schema",
|
|
||||||
extensions: ["graphql", "graphqls", "gql", "json"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (selected == null) return;
|
|
||||||
|
|
||||||
await loadFromFile(selected);
|
|
||||||
}, [loadFromFile]);
|
|
||||||
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
||||||
query: string;
|
query: string;
|
||||||
variables: string | undefined;
|
variables: string | undefined;
|
||||||
@@ -199,37 +160,14 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
...((schema != null
|
...((schema != null
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: "Clear Schema",
|
label: "Clear",
|
||||||
onSelect: clear,
|
onSelect: clear,
|
||||||
color: "danger",
|
color: "danger",
|
||||||
leftSlot: <Icon icon="trash" />,
|
leftSlot: <Icon icon="trash" />,
|
||||||
},
|
},
|
||||||
|
{ type: "separator" },
|
||||||
]
|
]
|
||||||
: []) satisfies DropdownItem[]),
|
: []) satisfies DropdownItem[]),
|
||||||
{
|
|
||||||
// Labels the source actions below it, so the menu says where the
|
|
||||||
// schema came from without spending a row on it.
|
|
||||||
type: "separator",
|
|
||||||
hidden: schema == null && filePath == null,
|
|
||||||
label:
|
|
||||||
fileName == null || filePath == null ? undefined : (
|
|
||||||
// Middle truncation: the head shrinks and ellipsizes while the
|
|
||||||
// tail is pinned, so the extension always survives. Full path
|
|
||||||
// on hover.
|
|
||||||
<div className="flex min-w-0 max-w-[16rem] font-mono text-xs" title={filePath}>
|
|
||||||
<span className="truncate">{fileName.slice(0, -FILE_NAME_TAIL_CHARS)}</span>
|
|
||||||
<span className="shrink-0">{fileName.slice(-FILE_NAME_TAIL_CHARS)}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
action:
|
|
||||||
filePath == null
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
icon: "folder_symlink",
|
|
||||||
title: revealInFinderText,
|
|
||||||
onClick: () => platform.revealItemInDir(filePath),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
hidden: !error,
|
hidden: !error,
|
||||||
label: (
|
label: (
|
||||||
@@ -272,33 +210,25 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
type: "content",
|
type: "content",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// One refresh action for either source: re-read the file, or
|
hidden: schema == null,
|
||||||
// re-introspect the server.
|
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
||||||
label: "Reload Schema",
|
leftSlot: <Icon icon="book_open_text" />,
|
||||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
onSelect: () => {
|
||||||
keepOpenOnSelect: true,
|
setGraphqlDocStateAtomValue((v) => ({
|
||||||
// Failures surface through the hook's error state either way.
|
...v,
|
||||||
onSelect: async () => {
|
[request.id]: isDocOpen ? undefined : null,
|
||||||
if (filePath != null) await reloadFromFile();
|
}));
|
||||||
else await refetch();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
|
label: "Introspect Schema",
|
||||||
leftSlot: <Icon icon="import" />,
|
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||||
onSelect: handleLoadFromFile,
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: refetch,
|
||||||
},
|
},
|
||||||
|
{ type: "separator", label: "Setting" },
|
||||||
{
|
{
|
||||||
hidden: filePath == null,
|
label: "Automatic Introspection",
|
||||||
label: "Stop Using File",
|
|
||||||
leftSlot: <Icon icon="x" />,
|
|
||||||
onSelect: removeSchemaFile,
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "Settings" },
|
|
||||||
{
|
|
||||||
// Governs both sources: re-introspecting the server, and
|
|
||||||
// re-reading the file when the request is opened.
|
|
||||||
label: filePath == null ? "Automatic Introspection" : "Automatic Reload",
|
|
||||||
keepOpenOnSelect: true,
|
keepOpenOnSelect: true,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
setAutoIntrospectDisabled({
|
setAutoIntrospectDisabled({
|
||||||
@@ -331,29 +261,6 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
// Sits after the schema control it depends on. Always rendered, disabled
|
|
||||||
// without a schema, so the row never changes shape.
|
|
||||||
<div key="documentation" className="opacity-100!">
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
icon="book_open_text"
|
|
||||||
disabled={schema == null}
|
|
||||||
title={
|
|
||||||
schema == null
|
|
||||||
? "Documentation unavailable without a schema"
|
|
||||||
: isDocOpen
|
|
||||||
? "Hide Documentation"
|
|
||||||
: "Show Documentation"
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
setGraphqlDocStateAtomValue((v) => ({
|
|
||||||
...v,
|
|
||||||
[request.id]: isDocOpen ? undefined : null,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
schema,
|
schema,
|
||||||
@@ -365,11 +272,6 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
isLoading,
|
isLoading,
|
||||||
operationNames,
|
operationNames,
|
||||||
refetch,
|
refetch,
|
||||||
handleLoadFromFile,
|
|
||||||
reloadFromFile,
|
|
||||||
removeSchemaFile,
|
|
||||||
filePath,
|
|
||||||
fileName,
|
|
||||||
autoIntrospectDisabled,
|
autoIntrospectDisabled,
|
||||||
baseRequest.id,
|
baseRequest.id,
|
||||||
setGraphqlDocStateAtomValue,
|
setGraphqlDocStateAtomValue,
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** A URL for the body the host already stored. */
|
/** A URL the host resolved, for a body it already stored. */
|
||||||
bodyUrl?: string;
|
url?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
|
export function AudioViewer({ url, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyUrl) {
|
if (url) {
|
||||||
setSrc(bodyUrl);
|
setSrc(url);
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
// The type matters here in a way it doesn't for an image: a media element goes by what
|
// The type matters here in a way it doesn't for an image: a media element goes by what
|
||||||
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
|
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
|
||||||
@@ -23,7 +23,7 @@ export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
|
|||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyUrl, data, mimeType]);
|
}, [url, data, mimeType]);
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||||
return <audio className="w-full" controls src={src} />;
|
return <audio className="w-full" controls src={src} />;
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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,33 +26,27 @@ export function CsvViewer({ text, className }: Props) {
|
|||||||
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
||||||
const parsed = useMemo(() => {
|
const parsed = useMemo(() => {
|
||||||
if (text == null) return null;
|
if (text == null) return null;
|
||||||
return Papa.parse<string[]>(text, { skipEmptyLines: true });
|
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
if (parsed === null) return null;
|
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 (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="overflow-auto h-full">
|
||||||
<Table className={classNames(className, "text-sm")}>
|
<Table className={classNames(className, "text-sm")}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{columnIndexes.map((columnIndex) => (
|
{parsed.meta.fields?.map((field) => (
|
||||||
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
|
<TableHeaderCell key={field}>{field}</TableHeaderCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((row, i) => (
|
{parsed.data.map((row, i) => (
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
// oxlint-disable-next-line react/no-array-index-key
|
||||||
<TableRow key={i}>
|
<TableRow key={i}>
|
||||||
{row.map((cell, columnIndex) => (
|
{parsed.meta.fields?.map((key) => (
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
<TableCell key={key}>{row[key] ?? ""}</TableCell>
|
||||||
<TableCell key={columnIndex}>{cell}</TableCell>
|
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useMemo, useState } from "react";
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
||||||
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
|
import { useResponseBodyText } from "../../hooks/useResponseBodyText";
|
||||||
import { useResponseFilter } from "../../hooks/useResponseFilter";
|
|
||||||
import { useSaveResponse } from "../../hooks/useSaveResponse";
|
import { useSaveResponse } from "../../hooks/useSaveResponse";
|
||||||
import { languageFromContentType } from "../../lib/contentType";
|
import { languageFromContentType } from "../../lib/contentType";
|
||||||
import { getContentTypeFromHeaders } from "../../lib/model_util";
|
import { getContentTypeFromHeaders } from "../../lib/model_util";
|
||||||
@@ -54,25 +52,30 @@ interface HttpTextViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
||||||
const queryClient = useQueryClient();
|
const [currentFilter, setCurrentFilter] = useState<string | null>(null);
|
||||||
const filter = useResponseFilter({
|
const filteredBody = useResponseBodyText({ response, filter: currentFilter });
|
||||||
stateKey: `response.body.${response.requestId}`,
|
|
||||||
// Shares the display query's cache entry, so the verdict costs no extra RPC
|
|
||||||
runFilter: useCallback(
|
|
||||||
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
|
|
||||||
[queryClient, response],
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const filteredBody = useResponseBodyText({ response, filter: filter.appliedFilter });
|
|
||||||
const saveResponse = useSaveResponse(response);
|
const saveResponse = useSaveResponse(response);
|
||||||
const copyResponse = useCopyHttpResponse(response);
|
const copyResponse = useCopyHttpResponse(response);
|
||||||
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
||||||
|
|
||||||
|
const filterCallback = useMemo(
|
||||||
|
() => (filter: string) => {
|
||||||
|
setCurrentFilter(filter);
|
||||||
|
return {
|
||||||
|
data: filteredBody.data,
|
||||||
|
isPending: filteredBody.isPending,
|
||||||
|
error: !!filteredBody.error,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[filteredBody],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextViewer
|
<TextViewer
|
||||||
text={text}
|
text={text}
|
||||||
language={language}
|
language={language}
|
||||||
stateKey={`response.body.${response.id}`}
|
stateKey={`response.body.${response.id}`}
|
||||||
|
filterStateKey={`response.body.${response.requestId}`}
|
||||||
pretty={pretty}
|
pretty={pretty}
|
||||||
className={className}
|
className={className}
|
||||||
footerActions={[
|
footerActions={[
|
||||||
@@ -95,12 +98,7 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
|
|||||||
className="border !border-border-subtle"
|
className="border !border-border-subtle"
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
filter={filter}
|
onFilter={filterCallback}
|
||||||
filterResult={{
|
|
||||||
data: filteredBody.data,
|
|
||||||
isPending: filteredBody.isPending,
|
|
||||||
error: !!filteredBody.error,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useEffect, useState } from "react";
|
|||||||
|
|
||||||
type Props = { className?: string; mimeType?: string } & (
|
type Props = { className?: string; mimeType?: string } & (
|
||||||
| {
|
| {
|
||||||
/** A URL for the body the host already stored. */
|
/** A URL the host resolved, for a body it already stored. */
|
||||||
bodyUrl: string;
|
url: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
data: ArrayBuffer;
|
data: ArrayBuffer;
|
||||||
@@ -13,12 +13,12 @@ type Props = { className?: string; mimeType?: string } & (
|
|||||||
|
|
||||||
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
const bodyUrl = "bodyUrl" in props ? props.bodyUrl : null;
|
const url = "url" in props ? props.url : null;
|
||||||
const data = "data" in props ? props.data : null;
|
const data = "data" in props ? props.data : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyUrl != null) {
|
if (url != null) {
|
||||||
setSrc(bodyUrl);
|
setSrc(url);
|
||||||
} else if (data != null) {
|
} else if (data != null) {
|
||||||
const blob = new Blob([data], { type: mimeType ?? "image/png" });
|
const blob = new Blob([data], { type: mimeType ?? "image/png" });
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
@@ -27,7 +27,7 @@ export function ImageViewer({ className, mimeType, ...props }: Props) {
|
|||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyUrl, data, mimeType]);
|
}, [url, data, mimeType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ fireAndForget(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** A URL for the body the host already stored. */
|
/** A URL the host resolved, for a body it already stored. */
|
||||||
bodyUrl?: string;
|
url?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ const options = {
|
|||||||
standardFontDataUrl: "/standard_fonts/",
|
standardFontDataUrl: "/standard_fonts/",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PdfViewer({ bodyUrl, data }: Props) {
|
export function PdfViewer({ url, data }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [numPages, setNumPages] = useState<number>();
|
const [numPages, setNumPages] = useState<number>();
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@ export function PdfViewer({ bodyUrl, data }: Props) {
|
|||||||
// During render, not in an effect: an effect leaves the first paint with no file, and
|
// During render, not in an effect: an effect leaves the first paint with no file, and
|
||||||
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
|
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
|
||||||
const src = useMemo(() => {
|
const src = useMemo(() => {
|
||||||
if (bodyUrl) {
|
if (url) {
|
||||||
return bodyUrl;
|
return url;
|
||||||
}
|
}
|
||||||
if (data) {
|
if (data) {
|
||||||
// Create a copy to avoid "Buffer is already detached" errors
|
// Create a copy to avoid "Buffer is already detached" errors
|
||||||
@@ -45,7 +45,7 @@ export function PdfViewer({ bodyUrl, data }: Props) {
|
|||||||
return { data: new Uint8Array(data) };
|
return { data: new Uint8Array(data) };
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [bodyUrl, data]);
|
}, [url, data]);
|
||||||
|
|
||||||
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
||||||
setNumPages(nextNumPages);
|
setNumPages(nextNumPages);
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
import type { RecentFilter } from "../../hooks/useRecentFilters";
|
|
||||||
import { Dropdown, type DropdownItem } from "../core/Dropdown";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
recentFilters: RecentFilter[];
|
|
||||||
activeFilter: string | null;
|
|
||||||
onSelect: (value: string) => void;
|
|
||||||
onRemove: (value: string) => void;
|
|
||||||
onTogglePin: (value: string) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecentFiltersDropdown({
|
|
||||||
recentFilters,
|
|
||||||
activeFilter,
|
|
||||||
onSelect,
|
|
||||||
onRemove,
|
|
||||||
onTogglePin,
|
|
||||||
onClear,
|
|
||||||
}: Props) {
|
|
||||||
const pinned = recentFilters.filter((f) => f.pinned);
|
|
||||||
const unpinned = recentFilters.filter((f) => !f.pinned);
|
|
||||||
|
|
||||||
const toItem = (filter: RecentFilter): DropdownItem => ({
|
|
||||||
label: (
|
|
||||||
<div className="font-mono text-sm truncate max-w-sm" title={filter.value}>
|
|
||||||
{filter.value}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
leftSlot: <Icon icon={filter.value === activeFilter ? "check" : "empty"} />,
|
|
||||||
onSelect: () => onSelect(filter.value),
|
|
||||||
submenuTrigger: "button",
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: filter.pinned ? "Unpin" : "Pin",
|
|
||||||
icon: filter.pinned ? "unpin" : "pin",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: () => onTogglePin(filter.value),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Remove",
|
|
||||||
icon: "trash",
|
|
||||||
color: "danger",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: () => onRemove(filter.value),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const items: DropdownItem[] = [];
|
|
||||||
|
|
||||||
if (recentFilters.length === 0) {
|
|
||||||
items.push({
|
|
||||||
type: "content",
|
|
||||||
label: (
|
|
||||||
<span className="block px-4 py-1 text-sm text-text-subtle">
|
|
||||||
Filters you use are remembered here
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pinned.length > 0) {
|
|
||||||
items.push({ type: "separator", label: "Pinned" }, ...pinned.map(toItem));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unpinned.length > 0) {
|
|
||||||
items.push({ type: "separator", label: "Recent" }, ...unpinned.map(toItem));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recentFilters.length > 0) {
|
|
||||||
items.push(
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Clear All",
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
color: "danger",
|
|
||||||
onSelect: onClear,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown items={items}>
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
icon="filter"
|
|
||||||
title="Recent filters"
|
|
||||||
iconColor="secondary"
|
|
||||||
className="w-8 ml-0.5 mr-1 h-auto!"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Children, useCallback, useMemo } from "react";
|
import { Children, useCallback, useMemo } from "react";
|
||||||
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
import { createGlobalState } from "react-use";
|
||||||
|
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
||||||
import { useFormatText } from "../../hooks/useFormatText";
|
import { useFormatText } from "../../hooks/useFormatText";
|
||||||
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
import type { EditorProps } from "../core/Editor/Editor";
|
||||||
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
import { Editor } from "../core/Editor/LazyEditor";
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import { Input } from "../core/Input";
|
import { Input } from "../core/Input";
|
||||||
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
|
||||||
|
|
||||||
const extraExtensions = [hyperlink];
|
const extraExtensions = [hyperlink];
|
||||||
|
|
||||||
@@ -17,45 +16,57 @@ interface Props {
|
|||||||
text: string;
|
text: string;
|
||||||
language: EditorProps["language"];
|
language: EditorProps["language"];
|
||||||
stateKey: string | null;
|
stateKey: string | null;
|
||||||
|
filterStateKey?: string | null;
|
||||||
pretty?: boolean;
|
pretty?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
footerActions?: ReactNode;
|
footerActions?: ReactNode;
|
||||||
filter?: ResponseFilterApi;
|
onFilter?: (filter: string) => {
|
||||||
filterResult?: {
|
|
||||||
data: string | null | undefined;
|
data: string | null | undefined;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useFilterText = createGlobalState<Record<string, string | null>>({});
|
||||||
|
|
||||||
export function TextViewer({
|
export function TextViewer({
|
||||||
language,
|
language,
|
||||||
text,
|
text,
|
||||||
stateKey,
|
stateKey,
|
||||||
|
filterStateKey,
|
||||||
pretty,
|
pretty,
|
||||||
className,
|
className,
|
||||||
footerActions,
|
footerActions,
|
||||||
filter,
|
onFilter,
|
||||||
filterResult,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const canFilter =
|
const filterKey = filterStateKey ?? stateKey;
|
||||||
filter != null && (language === "json" || language === "xml" || language === "html");
|
const [filterTextMap, setFilterTextMap] = useFilterText();
|
||||||
const isSearching = filter?.isSearching ?? false;
|
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
|
||||||
const appliedFilter = filter?.appliedFilter ?? null;
|
const debouncedFilterText = useDebouncedValue(filterText);
|
||||||
const resultError = filterResult?.error ?? false;
|
const setFilterText = useCallback(
|
||||||
|
(v: string | null) => {
|
||||||
const handleFilterKeyDown = useCallback(
|
if (!filterKey) return;
|
||||||
(e: KeyboardEvent) => {
|
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
|
||||||
if (filter == null) return;
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
filter.toggleSearch();
|
|
||||||
} else if (e.key === "Enter" && filter.filterText != null) {
|
|
||||||
filter.applyFilter(filter.filterText);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[filter],
|
[filterKey, setFilterTextMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isSearching = filterText != null;
|
||||||
|
const filteredResponse =
|
||||||
|
onFilter && debouncedFilterText
|
||||||
|
? onFilter(debouncedFilterText)
|
||||||
|
: { data: null, isPending: false, error: false };
|
||||||
|
|
||||||
|
const toggleSearch = useCallback(() => {
|
||||||
|
if (isSearching) {
|
||||||
|
setFilterText(null);
|
||||||
|
} else {
|
||||||
|
setFilterText("");
|
||||||
|
}
|
||||||
|
}, [isSearching, setFilterText]);
|
||||||
|
|
||||||
|
const canFilter = onFilter && (language === "json" || language === "xml" || language === "html");
|
||||||
|
|
||||||
const actions = useMemo<ReactNode[]>(() => {
|
const actions = useMemo<ReactNode[]>(() => {
|
||||||
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
||||||
|
|
||||||
@@ -65,8 +76,8 @@ export function TextViewer({
|
|||||||
nodes.push(
|
nodes.push(
|
||||||
<div key="input" className="w-full opacity-100!">
|
<div key="input" className="w-full opacity-100!">
|
||||||
<Input
|
<Input
|
||||||
key={filter.stateKey ?? "filter"}
|
key={filterKey ?? "filter"}
|
||||||
validate={!resultError}
|
validate={!filteredResponse.error}
|
||||||
hideLabel
|
hideLabel
|
||||||
autoFocus
|
autoFocus
|
||||||
containerClassName="bg-surface"
|
containerClassName="bg-surface"
|
||||||
@@ -74,62 +85,39 @@ export function TextViewer({
|
|||||||
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
||||||
label="Filter expression"
|
label="Filter expression"
|
||||||
name="filter"
|
name="filter"
|
||||||
defaultValue={filter.filterText}
|
defaultValue={filterText}
|
||||||
forceUpdateKey={filter.filterUpdateKey}
|
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
|
||||||
onKeyDown={handleFilterKeyDown}
|
onChange={setFilterText}
|
||||||
onChange={filter.setFilterText}
|
stateKey={filterKey ? `filter.${filterKey}` : null}
|
||||||
stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
|
|
||||||
leftSlot={
|
|
||||||
<div className="py-0.5 flex">
|
|
||||||
<RecentFiltersDropdown
|
|
||||||
recentFilters={filter.recentFilters}
|
|
||||||
activeFilter={filter.appliedFilter}
|
|
||||||
onSelect={filter.replaceFilter}
|
|
||||||
onRemove={filter.removeRecentFilter}
|
|
||||||
onTogglePin={filter.togglePinRecentFilter}
|
|
||||||
onClear={filter.clearRecentFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
rightSlot={
|
|
||||||
<div className="py-0.5 flex">
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
icon="x"
|
|
||||||
title="Close filter"
|
|
||||||
iconColor="secondary"
|
|
||||||
onClick={filter.toggleSearch}
|
|
||||||
className="w-8 mr-0.5 h-auto!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
nodes.push(
|
|
||||||
<IconButton
|
|
||||||
key="icon"
|
|
||||||
size="sm"
|
|
||||||
isLoading={filterResult?.isPending ?? false}
|
|
||||||
icon="filter"
|
|
||||||
title="Filter response"
|
|
||||||
onClick={filter.toggleSearch}
|
|
||||||
className="border border-border-subtle!"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nodes.push(
|
||||||
|
<IconButton
|
||||||
|
key="icon"
|
||||||
|
size="sm"
|
||||||
|
isLoading={filteredResponse.isPending}
|
||||||
|
icon={isSearching ? "x" : "filter"}
|
||||||
|
title={isSearching ? "Close filter" : "Filter response"}
|
||||||
|
onClick={toggleSearch}
|
||||||
|
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}, [
|
}, [
|
||||||
canFilter,
|
canFilter,
|
||||||
footerActions,
|
footerActions,
|
||||||
filter,
|
filterKey,
|
||||||
filterResult?.isPending,
|
filterText,
|
||||||
resultError,
|
filteredResponse.error,
|
||||||
|
filteredResponse.isPending,
|
||||||
isSearching,
|
isSearching,
|
||||||
language,
|
language,
|
||||||
handleFilterKeyDown,
|
setFilterText,
|
||||||
|
toggleSearch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
||||||
@@ -138,11 +126,11 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body: string;
|
let body: string;
|
||||||
if (appliedFilter) {
|
if (isSearching && filterText?.length > 0) {
|
||||||
if (resultError) {
|
if (filteredResponse.error) {
|
||||||
body = "";
|
body = "";
|
||||||
} else {
|
} else {
|
||||||
body = filterResult?.data != null ? filterResult.data : "";
|
body = filteredResponse.data != null ? filteredResponse.data : "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
body = formattedBody;
|
body = formattedBody;
|
||||||
@@ -155,61 +143,15 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
<Editor
|
||||||
{appliedFilter && filter != null ? (
|
readOnly
|
||||||
<AppliedFilterBar
|
className={className}
|
||||||
filter={appliedFilter}
|
defaultValue={body}
|
||||||
error={resultError}
|
language={language}
|
||||||
onClear={() => filter.replaceFilter("")}
|
actions={actions}
|
||||||
/>
|
extraExtensions={extraExtensions}
|
||||||
) : (
|
stateKey={stateKey}
|
||||||
<span />
|
/>
|
||||||
)}
|
|
||||||
<Editor
|
|
||||||
readOnly
|
|
||||||
className={className}
|
|
||||||
defaultValue={body}
|
|
||||||
language={language}
|
|
||||||
actions={actions}
|
|
||||||
extraExtensions={extraExtensions}
|
|
||||||
stateKey={stateKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows what's actually filtering the body, which the filter box below can't convey
|
|
||||||
* once it holds an edited expression that hasn't been applied yet.
|
|
||||||
*/
|
|
||||||
function AppliedFilterBar({
|
|
||||||
filter,
|
|
||||||
error,
|
|
||||||
onClear,
|
|
||||||
}: {
|
|
||||||
filter: string;
|
|
||||||
error: boolean;
|
|
||||||
onClear: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
|
|
||||||
<HStack space={2} className="min-w-0">
|
|
||||||
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
|
|
||||||
<span className="truncate min-w-0" title={filter}>
|
|
||||||
Response filtered by <InlineCode>{filter}</InlineCode>
|
|
||||||
{error && " (invalid expression)"}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
color={error ? "danger" : "info"}
|
|
||||||
className="ml-auto shrink-0"
|
|
||||||
onClick={onClear}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</Banner>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** A URL for the body the host already stored. */
|
/** A URL the host resolved, for a body it already stored. */
|
||||||
bodyUrl?: string;
|
url?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
|
export function VideoViewer({ url, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyUrl) {
|
if (url) {
|
||||||
setSrc(bodyUrl);
|
setSrc(url);
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
// As in AudioViewer: a media element trusts the declared type instead of sniffing
|
// As in AudioViewer: a media element trusts the declared type instead of sniffing
|
||||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
|
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
|
||||||
@@ -22,7 +22,7 @@ export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
|
|||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyUrl, data, mimeType]);
|
}, [url, data, mimeType]);
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||||
return <video className="w-full" controls src={src} />;
|
return <video className="w-full" controls src={src} />;
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useKeyValue } from "./useKeyValue";
|
|
||||||
|
|
||||||
// The file a request's GraphQL schema is loaded from, or null when the schema
|
|
||||||
// comes from an introspection request.
|
|
||||||
//
|
|
||||||
// This is the *source*, not the schema. The introspection row it produces is a
|
|
||||||
// cache that expires on its own; this outlives it and regenerates it, the same
|
|
||||||
// way gRPC keeps its proto file list separate from a reflection result.
|
|
||||||
export function graphqlSchemaFileArgs(requestId: string | null) {
|
|
||||||
return {
|
|
||||||
namespace: "global" as const,
|
|
||||||
key: ["graphql_schema_file", requestId ?? "n/a"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGraphQLSchemaFile(requestId: string | null) {
|
|
||||||
return useKeyValue<string | null>({ ...graphqlSchemaFileArgs(requestId), fallback: null });
|
|
||||||
}
|
|
||||||
@@ -112,12 +112,9 @@ export const hotkeysAtom = atom((get) => {
|
|||||||
// Merge default hotkeys with custom hotkeys from settings
|
// Merge default hotkeys with custom hotkeys from settings
|
||||||
// Custom hotkeys override defaults for the same action
|
// Custom hotkeys override defaults for the same action
|
||||||
// An empty array means the hotkey is intentionally disabled
|
// An empty array means the hotkey is intentionally disabled
|
||||||
const merged: Partial<Record<HotkeyAction, string[]>> = {};
|
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
|
||||||
for (const action of hotkeyActions) {
|
|
||||||
merged[action] = defaultHotkeys[action];
|
|
||||||
}
|
|
||||||
for (const [action, keys] of Object.entries(customHotkeys)) {
|
for (const [action, keys] of Object.entries(customHotkeys)) {
|
||||||
if (action in merged && Array.isArray(keys)) {
|
if (action in defaultHotkeys && Array.isArray(keys)) {
|
||||||
merged[action as HotkeyAction] = keys;
|
merged[action as HotkeyAction] = keys;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +122,7 @@ export const hotkeysAtom = atom((get) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Helper function to get current hotkeys from the store */
|
/** Helper function to get current hotkeys from the store */
|
||||||
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
|
function getHotkeys(): Record<HotkeyAction, string[]> {
|
||||||
return jotaiStore.get(hotkeysAtom);
|
return jotaiStore.get(hotkeysAtom);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,25 +165,16 @@ const layoutInsensitiveKeys = [
|
|||||||
"Space",
|
"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[] = (
|
export const hotkeyActions: HotkeyAction[] = (
|
||||||
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
||||||
)
|
).sort((a, b) => {
|
||||||
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
|
const scopeA = a.split(".")[0] || "";
|
||||||
.sort((a, b) => {
|
const scopeB = b.split(".")[0] || "";
|
||||||
const scopeA = a.split(".")[0] || "";
|
if (scopeA !== scopeB) {
|
||||||
const scopeB = b.split(".")[0] || "";
|
return scopeA.localeCompare(scopeB);
|
||||||
if (scopeA !== scopeB) {
|
}
|
||||||
return scopeA.localeCompare(scopeB);
|
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||||
}
|
});
|
||||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
|
||||||
});
|
|
||||||
|
|
||||||
export type HotKeyOptions = {
|
export type HotKeyOptions = {
|
||||||
enable?: boolean | (() => boolean);
|
enable?: boolean | (() => boolean);
|
||||||
@@ -345,9 +333,7 @@ export function formatHotkeyString(trigger: string): string[] {
|
|||||||
} else if (p === "Alt") {
|
} else if (p === "Alt") {
|
||||||
labelParts.push("⌥");
|
labelParts.push("⌥");
|
||||||
} else if (p === "Enter") {
|
} else if (p === "Enter") {
|
||||||
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
|
labelParts.push("↩");
|
||||||
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
|
|
||||||
labelParts.push("↩︎");
|
|
||||||
} else if (p === "Tab") {
|
} else if (p === "Tab") {
|
||||||
labelParts.push("⇥");
|
labelParts.push("⇥");
|
||||||
} else if (p === "Backspace") {
|
} else if (p === "Backspace") {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
|
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
||||||
import { buildClientSchema, getIntrospectionQuery } from "graphql";
|
import { buildClientSchema, getIntrospectionQuery } from "graphql";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { tryBuildIntrospectionFromFile } from "../lib/graphqlSchema";
|
|
||||||
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
||||||
|
import { getResponseBodyText } from "../lib/responseBody";
|
||||||
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
|
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
|
||||||
import { useActiveEnvironment } from "./useActiveEnvironment";
|
import { useActiveEnvironment } from "./useActiveEnvironment";
|
||||||
import { useGraphQLSchemaFile } from "./useGraphQLSchemaFile";
|
|
||||||
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
@@ -32,11 +31,6 @@ export function useIntrospectGraphQL(
|
|||||||
|
|
||||||
const introspection = useIntrospectionResult(baseRequest);
|
const introspection = useIntrospectionResult(baseRequest);
|
||||||
|
|
||||||
// The schema's source. Outlives the introspection row it produces, so a
|
|
||||||
// request configured with a file keeps working after the row is swept.
|
|
||||||
const schemaFile = useGraphQLSchemaFile(baseRequest.id);
|
|
||||||
const filePath = schemaFile.value ?? null;
|
|
||||||
|
|
||||||
const upsertIntrospection = useCallback(
|
const upsertIntrospection = useCallback(
|
||||||
async (content: string | null) => {
|
async (content: string | null) => {
|
||||||
const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", {
|
const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", {
|
||||||
@@ -61,7 +55,7 @@ export function useIntrospectGraphQL(
|
|||||||
bodyType: "application/json",
|
bodyType: "application/json",
|
||||||
body: { text: introspectionRequestBody },
|
body: { text: introspectionRequestBody },
|
||||||
};
|
};
|
||||||
const { response, body } = await minPromiseMillis(
|
const response = await minPromiseMillis(
|
||||||
sendEphemeralRequest(args, activeEnvironment?.id ?? null),
|
sendEphemeralRequest(args, activeEnvironment?.id ?? null),
|
||||||
700,
|
700,
|
||||||
);
|
);
|
||||||
@@ -70,16 +64,14 @@ export function useIntrospectGraphQL(
|
|||||||
return setError(response.error);
|
return setError(response.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The send hands back the only copy of the body — an unsaved response has
|
const bodyText = await getResponseBodyText({ response, filter: null });
|
||||||
// nothing on disk and no row to read it back from
|
|
||||||
const bodyText = new TextDecoder("utf-8").decode(new Uint8Array(body));
|
|
||||||
if (response.status < 200 || response.status >= 300) {
|
if (response.status < 200 || response.status >= 300) {
|
||||||
return setError(
|
return setError(
|
||||||
`Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`,
|
`Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bodyText === "") {
|
if (bodyText === null) {
|
||||||
return setError("Empty body returned in response");
|
return setError("Empty body returned in response");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,109 +91,15 @@ export function useIntrospectGraphQL(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A request pointed at a file gets its schema from that file. Introspecting
|
|
||||||
// here would overwrite it on the next URL edit.
|
|
||||||
if (filePath != null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
refetch().catch(console.error);
|
refetch().catch(console.error);
|
||||||
}, [
|
}, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]);
|
||||||
baseRequest.id,
|
|
||||||
debouncedRequest.url,
|
|
||||||
debouncedRequest.method,
|
|
||||||
activeEnvironment?.id,
|
|
||||||
filePath,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Clears the schema, not the source. Removing a file source is a separate
|
|
||||||
// action, because the source is what would rebuild this a moment later.
|
|
||||||
const clear = useCallback(async () => {
|
const clear = useCallback(async () => {
|
||||||
setError("");
|
setError("");
|
||||||
setSchema(null);
|
setSchema(null);
|
||||||
await upsertIntrospection(null);
|
await upsertIntrospection(null);
|
||||||
}, [upsertIntrospection]);
|
}, [upsertIntrospection]);
|
||||||
|
|
||||||
// Reads a schema file and produces an introspection row from it, the same way
|
|
||||||
// `refetch` produces one from a server. Does not touch the stored source.
|
|
||||||
const introspectFromFile = useCallback(
|
|
||||||
async (path: string): Promise<{ ok: true } | { ok: false; error: string }> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
const fileContent = await platform.files.readText(path);
|
|
||||||
const result = tryBuildIntrospectionFromFile(fileContent);
|
|
||||||
|
|
||||||
if ("error" in result) {
|
|
||||||
setError(result.error);
|
|
||||||
return { ok: false, error: result.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
await upsertIntrospection(result.content);
|
|
||||||
return { ok: true };
|
|
||||||
} catch (err) {
|
|
||||||
// The host rejects with a bare string for a missing or unreadable path,
|
|
||||||
// so this can't assume an Error.
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
setError(message);
|
|
||||||
return { ok: false, error: message };
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[upsertIntrospection],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Points the request at a file and immediately builds its schema from it.
|
|
||||||
const loadFromFile = useCallback(
|
|
||||||
async (path: string) => {
|
|
||||||
const result = await introspectFromFile(path);
|
|
||||||
if (result.ok) await schemaFile.set(path);
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
[introspectFromFile, schemaFile],
|
|
||||||
);
|
|
||||||
|
|
||||||
const reloadFromFile = useCallback(async () => {
|
|
||||||
if (filePath == null) return { ok: false as const, error: "No schema file to reload" };
|
|
||||||
return introspectFromFile(filePath);
|
|
||||||
}, [filePath, introspectFromFile]);
|
|
||||||
|
|
||||||
// The file-source counterpart of automatic introspection: re-read the file
|
|
||||||
// when the request is opened, so an edited schema is picked up without asking.
|
|
||||||
//
|
|
||||||
// A missing row is repaired even with the setting off — that is recovering
|
|
||||||
// from the 7-day sweep, not keeping the schema fresh, and skipping it would
|
|
||||||
// make the schema disappear with no visible cause.
|
|
||||||
const reloadedFor = useRef<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (filePath == null || introspection.isLoading) return;
|
|
||||||
// Only attempt once per path, so an unreadable file doesn't spin.
|
|
||||||
if (reloadedFor.current === filePath) return;
|
|
||||||
|
|
||||||
const hasContent = (introspection.data?.content ?? "") !== "";
|
|
||||||
if (hasContent && options.disabled) return;
|
|
||||||
|
|
||||||
reloadedFor.current = filePath;
|
|
||||||
introspectFromFile(filePath).catch(console.error);
|
|
||||||
}, [
|
|
||||||
filePath,
|
|
||||||
introspection.data?.content,
|
|
||||||
introspection.isLoading,
|
|
||||||
introspectFromFile,
|
|
||||||
options.disabled,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Stops using the file. The schema goes with it, since the file is what
|
|
||||||
// produced it; introspection repopulates if it's set to run automatically.
|
|
||||||
const removeSchemaFile = useCallback(async () => {
|
|
||||||
setError("");
|
|
||||||
setSchema(null);
|
|
||||||
await schemaFile.set(null);
|
|
||||||
await upsertIntrospection(null);
|
|
||||||
}, [schemaFile, upsertIntrospection]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (introspection.data?.content == null || introspection.data.content === "") {
|
if (introspection.data?.content == null || introspection.data.content === "") {
|
||||||
return;
|
return;
|
||||||
@@ -215,17 +113,7 @@ export function useIntrospectGraphQL(
|
|||||||
}
|
}
|
||||||
}, [introspection.data?.content]);
|
}, [introspection.data?.content]);
|
||||||
|
|
||||||
return {
|
return { schema, isLoading, error, refetch, clear };
|
||||||
schema,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
clear,
|
|
||||||
loadFromFile,
|
|
||||||
reloadFromFile,
|
|
||||||
removeSchemaFile,
|
|
||||||
filePath,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useIntrospectionResult(request: HttpRequest) {
|
function useIntrospectionResult(request: HttpRequest) {
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useKeyValue } from "./useKeyValue";
|
|
||||||
|
|
||||||
export interface RecentFilter {
|
|
||||||
value: string;
|
|
||||||
pinned?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_RECENT_FILTERS = 20;
|
|
||||||
const kvKey = (filterStateKey: string) => `recent_filters::${filterStateKey}`;
|
|
||||||
const namespace = "global";
|
|
||||||
const fallback: RecentFilter[] = [];
|
|
||||||
|
|
||||||
export function useRecentFilters(filterStateKey: string | null) {
|
|
||||||
const { value, set } = useKeyValue<RecentFilter[]>({
|
|
||||||
key: kvKey(filterStateKey ?? "n/a"),
|
|
||||||
namespace,
|
|
||||||
fallback,
|
|
||||||
});
|
|
||||||
|
|
||||||
const addFilter = useCallback(
|
|
||||||
async (rawValue: string) => {
|
|
||||||
const value = rawValue.trim();
|
|
||||||
if (filterStateKey == null || value === "") return;
|
|
||||||
await set((prev) => {
|
|
||||||
// Returning the same reference skips the write, so re-committing the
|
|
||||||
// expression already at the top (on every blur) costs nothing
|
|
||||||
if (prev[0]?.value === value) return prev;
|
|
||||||
const existing = prev.find((f) => f.value === value);
|
|
||||||
const rest = prev.filter((f) => f.value !== value);
|
|
||||||
return trim([{ value, pinned: existing?.pinned }, ...rest]);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[filterStateKey, set],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeFilter = useCallback(
|
|
||||||
async (value: string) => set((prev) => prev.filter((f) => f.value !== value)),
|
|
||||||
[set],
|
|
||||||
);
|
|
||||||
|
|
||||||
const togglePin = useCallback(
|
|
||||||
async (value: string) =>
|
|
||||||
set((prev) => prev.map((f) => (f.value === value ? { ...f, pinned: !f.pinned } : f))),
|
|
||||||
[set],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearFilters = useCallback(async () => set([]), [set]);
|
|
||||||
|
|
||||||
return { recentFilters: value ?? fallback, addFilter, removeFilter, togglePin, clearFilters };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bound the list, evicting the oldest unpinned entries before any pinned ones */
|
|
||||||
function trim(filters: RecentFilter[]): RecentFilter[] {
|
|
||||||
const excess = filters.length - MAX_RECENT_FILTERS;
|
|
||||||
if (excess <= 0) return filters;
|
|
||||||
|
|
||||||
const evicted = new Set<number>();
|
|
||||||
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
|
|
||||||
if (!filters[i]?.pinned) evicted.add(i);
|
|
||||||
}
|
|
||||||
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
|
|
||||||
evicted.add(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filters.filter((_, i) => !evicted.has(i));
|
|
||||||
}
|
|
||||||
@@ -2,25 +2,6 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { getResponseBodyBytes, getResponseBodyText } from "../lib/responseBody";
|
import { getResponseBodyBytes, getResponseBodyText } from "../lib/responseBody";
|
||||||
|
|
||||||
export function responseBodyTextQuery({
|
|
||||||
response,
|
|
||||||
filter,
|
|
||||||
}: {
|
|
||||||
response: HttpResponse;
|
|
||||||
filter: string | null;
|
|
||||||
}) {
|
|
||||||
return {
|
|
||||||
queryKey: [
|
|
||||||
"response_body_text",
|
|
||||||
response.id,
|
|
||||||
response.updatedAt,
|
|
||||||
response.contentLength,
|
|
||||||
filter ?? "",
|
|
||||||
],
|
|
||||||
queryFn: () => getResponseBodyText({ response, filter }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useResponseBodyText({
|
export function useResponseBodyText({
|
||||||
response,
|
response,
|
||||||
filter,
|
filter,
|
||||||
@@ -30,7 +11,14 @@ export function useResponseBodyText({
|
|||||||
}) {
|
}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||||
...responseBodyTextQuery({ response, filter }),
|
queryKey: [
|
||||||
|
"response_body_text",
|
||||||
|
response.id,
|
||||||
|
response.updatedAt,
|
||||||
|
response.contentLength,
|
||||||
|
filter ?? "",
|
||||||
|
],
|
||||||
|
queryFn: () => getResponseBodyText({ response, filter }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export function useResponseBodyUrl(response: HttpResponse | null) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["response_body_url", responseId, response?.updatedAt ?? ""],
|
queryKey: ["response_body_url", responseId, response?.updatedAt ?? ""],
|
||||||
enabled: responseId != null,
|
enabled: responseId != null,
|
||||||
// A response body is stored under the response's own id
|
queryFn: () => (responseId == null ? null : platform.files.responseBodyUrl(responseId)),
|
||||||
queryFn: () => (responseId == null ? null : platform.blobs.url(responseId)),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { createGlobalState } from "react-use";
|
|
||||||
import type { RecentFilter } from "./useRecentFilters";
|
|
||||||
import { useRecentFilters } from "./useRecentFilters";
|
|
||||||
|
|
||||||
/** What's typed in the filter box. `null` means the filter box is closed */
|
|
||||||
const useFilterTextMap = createGlobalState<Record<string, string | null>>({});
|
|
||||||
|
|
||||||
/** What's actually applied to the response. Only changes on an explicit apply */
|
|
||||||
const useAppliedFilterMap = createGlobalState<Record<string, string | null>>({});
|
|
||||||
|
|
||||||
export interface ResponseFilterApi {
|
|
||||||
stateKey: string | null;
|
|
||||||
/** Draft text in the filter box, or `null` when the box is closed */
|
|
||||||
filterText: string | null;
|
|
||||||
/** The expression currently filtering the response */
|
|
||||||
appliedFilter: string | null;
|
|
||||||
isSearching: boolean;
|
|
||||||
/** The box holds an expression that isn't the one currently applied */
|
|
||||||
isDirty: boolean;
|
|
||||||
/** Bumped when the (uncontrolled) filter input must re-read its defaultValue */
|
|
||||||
filterUpdateKey: number;
|
|
||||||
setFilterText: (value: string | null) => void;
|
|
||||||
/** Apply the expression to the response, recording it if the filter accepts it */
|
|
||||||
applyFilter: (value: string) => void;
|
|
||||||
/** Like applyFilter, but also replaces what's shown in the filter box */
|
|
||||||
replaceFilter: (value: string) => void;
|
|
||||||
toggleSearch: () => void;
|
|
||||||
recentFilters: RecentFilter[];
|
|
||||||
removeRecentFilter: (value: string) => void;
|
|
||||||
togglePinRecentFilter: (value: string) => void;
|
|
||||||
clearRecentFilters: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draft/applied state and history for a response filter (JSONPath/XPath).
|
|
||||||
*
|
|
||||||
* History records at most one entry per apply gesture, and only after `runFilter`
|
|
||||||
* confirms the plugin accepts the expression — the plugin is the sole judge of
|
|
||||||
* validity. Because nothing but the gesture ever writes, refetches can't resurrect
|
|
||||||
* deleted entries and a gesture can't record into another request's history.
|
|
||||||
*/
|
|
||||||
export function useResponseFilter({
|
|
||||||
stateKey,
|
|
||||||
runFilter,
|
|
||||||
}: {
|
|
||||||
stateKey: string | null;
|
|
||||||
/** Evaluate an expression, rejecting if the filter plugin reports an error */
|
|
||||||
runFilter: (filter: string) => Promise<unknown>;
|
|
||||||
}): ResponseFilterApi {
|
|
||||||
const [filterTextMap, setFilterTextMap] = useFilterTextMap();
|
|
||||||
const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap();
|
|
||||||
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
|
|
||||||
const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null;
|
|
||||||
|
|
||||||
const setFilterText = useCallback(
|
|
||||||
(v: string | null) => {
|
|
||||||
if (!stateKey) return;
|
|
||||||
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
|
|
||||||
},
|
|
||||||
[stateKey, setFilterTextMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setAppliedFilter = useCallback(
|
|
||||||
(v: string | null) => {
|
|
||||||
if (!stateKey) return;
|
|
||||||
setAppliedFilterMap((m) => ({ ...m, [stateKey]: v }));
|
|
||||||
},
|
|
||||||
[stateKey, setAppliedFilterMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
recentFilters,
|
|
||||||
addFilter,
|
|
||||||
removeFilter: removeRecentFilter,
|
|
||||||
togglePin: togglePinRecentFilter,
|
|
||||||
clearFilters: clearRecentFilters,
|
|
||||||
} = useRecentFilters(stateKey);
|
|
||||||
|
|
||||||
const applyFilter = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setFilterText(value);
|
|
||||||
const applied = value.trim() === "" ? null : value.trim();
|
|
||||||
setAppliedFilter(applied);
|
|
||||||
if (applied == null) return;
|
|
||||||
runFilter(applied).then(
|
|
||||||
() => addFilter(applied),
|
|
||||||
() => {}, // Rejected by the filter plugin — don't record
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[setFilterText, setAppliedFilter, runFilter, addFilter],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
|
|
||||||
const replaceFilter = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
applyFilter(value);
|
|
||||||
setFilterUpdateKey((k) => k + 1);
|
|
||||||
},
|
|
||||||
[applyFilter],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isSearching = filterText != null;
|
|
||||||
const toggleSearch = useCallback(() => {
|
|
||||||
if (isSearching) {
|
|
||||||
setFilterText(null);
|
|
||||||
setAppliedFilter(null);
|
|
||||||
} else {
|
|
||||||
setFilterText("");
|
|
||||||
}
|
|
||||||
}, [isSearching, setFilterText, setAppliedFilter]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
stateKey,
|
|
||||||
filterText,
|
|
||||||
appliedFilter,
|
|
||||||
isSearching,
|
|
||||||
isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""),
|
|
||||||
filterUpdateKey,
|
|
||||||
setFilterText,
|
|
||||||
applyFilter,
|
|
||||||
replaceFilter,
|
|
||||||
toggleSearch,
|
|
||||||
recentFilters,
|
|
||||||
removeRecentFilter,
|
|
||||||
togglePinRecentFilter,
|
|
||||||
clearRecentFilters,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -25,10 +25,6 @@ export function useSaveResponse(response: HttpResponse | null) {
|
|||||||
defaultPath: ext ? `${slug}.${ext}` : slug,
|
defaultPath: ext ? `${slug}.${ext}` : slug,
|
||||||
title: "Save Response",
|
title: "Save Response",
|
||||||
});
|
});
|
||||||
if (filepath == null) {
|
|
||||||
return; // Cancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
await rpc("cmd_save_response", { responseId: response.id, filepath });
|
await rpc("cmd_save_response", { responseId: response.id, filepath });
|
||||||
showToast({
|
showToast({
|
||||||
message: (
|
message: (
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { buildSchema, introspectionFromSchema } from "graphql";
|
|
||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import { tryBuildIntrospectionFromFile } from "./graphqlSchema";
|
|
||||||
|
|
||||||
const sdl = `
|
|
||||||
type Query {
|
|
||||||
hello: String!
|
|
||||||
user(id: ID!): User
|
|
||||||
}
|
|
||||||
|
|
||||||
type User {
|
|
||||||
id: ID!
|
|
||||||
name: String
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const introspection = introspectionFromSchema(buildSchema(sdl));
|
|
||||||
|
|
||||||
describe("tryBuildIntrospectionFromFile", () => {
|
|
||||||
test("accepts introspection JSON wrapped in { data: ... }", () => {
|
|
||||||
const input = JSON.stringify({ data: introspection });
|
|
||||||
const result = tryBuildIntrospectionFromFile(input);
|
|
||||||
|
|
||||||
expect("schema" in result).toBe(true);
|
|
||||||
if ("schema" in result) {
|
|
||||||
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("hello");
|
|
||||||
// Output content is the normalized, persistable shape.
|
|
||||||
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts bare introspection JSON without a data wrapper", () => {
|
|
||||||
const input = JSON.stringify(introspection);
|
|
||||||
const result = tryBuildIntrospectionFromFile(input);
|
|
||||||
|
|
||||||
expect("schema" in result).toBe(true);
|
|
||||||
if ("schema" in result) {
|
|
||||||
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("user");
|
|
||||||
// Bare input is wrapped on the way out.
|
|
||||||
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts a GraphQL SDL string", () => {
|
|
||||||
const result = tryBuildIntrospectionFromFile(sdl);
|
|
||||||
|
|
||||||
expect("schema" in result).toBe(true);
|
|
||||||
if ("schema" in result) {
|
|
||||||
const fields = result.schema.getQueryType()?.getFields() ?? {};
|
|
||||||
expect(fields).toHaveProperty("hello");
|
|
||||||
expect(fields).toHaveProperty("user");
|
|
||||||
// SDL is converted to introspection JSON for storage.
|
|
||||||
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns an error for JSON that is neither introspection nor SDL", () => {
|
|
||||||
const result = tryBuildIntrospectionFromFile('{"unrelated":"value"}');
|
|
||||||
|
|
||||||
expect("error" in result).toBe(true);
|
|
||||||
if ("error" in result) {
|
|
||||||
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns an error for content that is neither valid JSON nor valid SDL", () => {
|
|
||||||
const result = tryBuildIntrospectionFromFile("not a schema!@#$");
|
|
||||||
|
|
||||||
expect("error" in result).toBe(true);
|
|
||||||
if ("error" in result) {
|
|
||||||
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns an error when introspection JSON has a malformed __schema", () => {
|
|
||||||
// Has the data.__schema shape but the contents are invalid for buildClientSchema.
|
|
||||||
const input = JSON.stringify({ data: { __schema: { broken: true } } });
|
|
||||||
const result = tryBuildIntrospectionFromFile(input);
|
|
||||||
|
|
||||||
expect("error" in result).toBe(true);
|
|
||||||
if ("error" in result) {
|
|
||||||
expect(result.error).toMatch(/Failed to build schema from introspection JSON/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
|
||||||
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
|
|
||||||
|
|
||||||
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
|
|
||||||
// { __schema }) or an SDL string and normalizes both into the wrapped
|
|
||||||
// { data: <introspection> } JSON shape used by the introspection store.
|
|
||||||
export function tryBuildIntrospectionFromFile(
|
|
||||||
fileContent: string,
|
|
||||||
): { schema: GraphQLSchema; content: string } | { error: string } {
|
|
||||||
let parsedJson: unknown;
|
|
||||||
try {
|
|
||||||
parsedJson = JSON.parse(fileContent);
|
|
||||||
} catch {
|
|
||||||
parsedJson = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedJson != null && typeof parsedJson === "object") {
|
|
||||||
const candidates: unknown[] = [(parsedJson as { data?: unknown }).data, parsedJson];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (
|
|
||||||
candidate != null &&
|
|
||||||
typeof candidate === "object" &&
|
|
||||||
"__schema" in (candidate as Record<string, unknown>)
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const schema = buildClientSchema(candidate as IntrospectionQuery, {});
|
|
||||||
return { schema, content: JSON.stringify({ data: candidate }) };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
error: `Failed to build schema from introspection JSON: ${errorMessage(e)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const schema = buildSchema(fileContent);
|
|
||||||
const introspection = introspectionFromSchema(schema);
|
|
||||||
return { schema, content: JSON.stringify({ data: introspection }) };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
error: `Could not parse file as introspection JSON or GraphQL SDL: ${errorMessage(e)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(e: unknown): string {
|
|
||||||
return e instanceof Error ? e.message : String(e);
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,11 @@ import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
|||||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||||
import { Button } from "../components/core/Button";
|
import { Button } from "../components/core/Button";
|
||||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||||
|
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { showAlert } from "./alert";
|
import { showAlert } from "./alert";
|
||||||
import { showDialog } from "./dialog";
|
import { showDialog } from "./dialog";
|
||||||
|
import { jotaiStore } from "./jotai";
|
||||||
import { pluralizeCount } from "./pluralize";
|
import { pluralizeCount } from "./pluralize";
|
||||||
import { router } from "./router";
|
import { router } from "./router";
|
||||||
import { rpc } from "./rpc";
|
import { rpc } from "./rpc";
|
||||||
@@ -26,9 +28,12 @@ export const importData = createFastMutation({
|
|||||||
title: "Import Data",
|
title: "Import Data",
|
||||||
size: "sm",
|
size: "sm",
|
||||||
render: ({ hide }) => {
|
render: ({ hide }) => {
|
||||||
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
|
const importAndHide = async (filePath: string) => {
|
||||||
try {
|
try {
|
||||||
await finishImport(await runImport());
|
const didImport = await performImport(filePath);
|
||||||
|
if (!didImport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
resolve();
|
resolve();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
@@ -36,23 +41,20 @@ export const importData = createFastMutation({
|
|||||||
hide();
|
hide();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return <ImportDataDialog importData={importAndHide} />;
|
||||||
<ImportDataDialog
|
|
||||||
importFile={(filePath) =>
|
|
||||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
|
|
||||||
}
|
|
||||||
importUrl={(url) =>
|
|
||||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function finishImport(imported: BatchUpsertResult): Promise<void> {
|
async function performImport(filePath: string): Promise<boolean> {
|
||||||
|
const activeWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||||
|
const imported = await rpc<BatchUpsertResult>("cmd_import_data", {
|
||||||
|
filePath,
|
||||||
|
workspaceId: activeWorkspace?.id,
|
||||||
|
});
|
||||||
|
|
||||||
const importedWorkspace = imported.workspaces[0];
|
const importedWorkspace = imported.workspaces[0];
|
||||||
|
|
||||||
showDialog({
|
showDialog({
|
||||||
@@ -101,4 +103,6 @@ async function finishImport(imported: BatchUpsertResult): Promise<void> {
|
|||||||
search: { environment_id: environmentId },
|
search: { environment_id: environmentId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
UpdateResponse,
|
UpdateResponse,
|
||||||
YaakNotification,
|
YaakNotification,
|
||||||
} from "@yaakapp-internal/tauri-client";
|
} from "@yaakapp-internal/tauri-client";
|
||||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||||
import { openSettings } from "../commands/openSettings";
|
import { openSettings } from "../commands/openSettings";
|
||||||
import { Button } from "../components/core/Button";
|
import { Button } from "../components/core/Button";
|
||||||
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
||||||
@@ -180,65 +180,9 @@ function showUpdateInstalledToast(version: string) {
|
|||||||
|
|
||||||
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||||
const UPDATE_TOAST_ID = "update-info";
|
const UPDATE_TOAST_ID = "update-info";
|
||||||
const { version, replyEventId, downloaded, install } = updateInfo;
|
const { version, replyEventId, downloaded } = updateInfo;
|
||||||
|
|
||||||
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
|
jotaiStore.set(updateAvailableAtom, { version, downloaded });
|
||||||
|
|
||||||
const whatsNewButton = (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="info"
|
|
||||||
variant="border"
|
|
||||||
rightSlot={<Icon icon="external_link" />}
|
|
||||||
onClick={async () => {
|
|
||||||
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
What's New
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (install !== "integrated") {
|
|
||||||
// Nothing to reply to here; the backend only told us so we can say how to update
|
|
||||||
const flatpak = install === "flatpak";
|
|
||||||
showToast({
|
|
||||||
id: UPDATE_TOAST_ID,
|
|
||||||
color: "info",
|
|
||||||
timeout: null,
|
|
||||||
message: (
|
|
||||||
<VStack>
|
|
||||||
<h2 className="font-semibold">Yaak {version} is available</h2>
|
|
||||||
<p className="text-text-subtle text-sm">
|
|
||||||
{flatpak ? (
|
|
||||||
<>
|
|
||||||
Update with <InlineCode>flatpak update</InlineCode> or your software center.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Download the new version to upgrade."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</VStack>
|
|
||||||
),
|
|
||||||
action: () => (
|
|
||||||
<HStack space={1.5}>
|
|
||||||
{!flatpak && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="info"
|
|
||||||
rightSlot={<Icon icon="external_link" />}
|
|
||||||
onClick={async () => {
|
|
||||||
await platform.openUrl("https://yaak.app/download");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{whatsNewButton}
|
|
||||||
</HStack>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acknowledge the event, so we don't time out and try the fallback update logic
|
// Acknowledge the event, so we don't time out and try the fallback update logic
|
||||||
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
||||||
@@ -271,7 +215,17 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
|||||||
>
|
>
|
||||||
{downloaded ? "Install Now" : "Download and Install"}
|
{downloaded ? "Install Now" : "Download and Install"}
|
||||||
</ButtonInfiniteLoading>
|
</ButtonInfiniteLoading>
|
||||||
{whatsNewButton}
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="info"
|
||||||
|
variant="border"
|
||||||
|
rightSlot={<Icon icon="external_link" />}
|
||||||
|
onClick={async () => {
|
||||||
|
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
What's New
|
||||||
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ type ModelType = AnyModel["model"];
|
|||||||
type WorkspaceRequestSettings = Pick<
|
type WorkspaceRequestSettings = Pick<
|
||||||
Workspace,
|
Workspace,
|
||||||
| "settingFollowRedirects"
|
| "settingFollowRedirects"
|
||||||
| "settingHttpVersion"
|
|
||||||
| "settingRequestMessageSize"
|
| "settingRequestMessageSize"
|
||||||
| "settingRequestTimeout"
|
| "settingRequestTimeout"
|
||||||
| "settingSendCookies"
|
| "settingSendCookies"
|
||||||
@@ -19,7 +18,9 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
|
|||||||
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
||||||
}[ModelType];
|
}[ModelType];
|
||||||
|
|
||||||
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
|
export type RequestSettingDefinition<
|
||||||
|
K extends RequestSettingKey = RequestSettingKey,
|
||||||
|
> = {
|
||||||
defaultValue: WorkspaceRequestSettings[K];
|
defaultValue: WorkspaceRequestSettings[K];
|
||||||
description: string;
|
description: string;
|
||||||
modelKey: K;
|
modelKey: K;
|
||||||
@@ -45,7 +46,8 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
|
|||||||
|
|
||||||
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
|
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
|
||||||
defaultValue: 64 * 1024 * 1024,
|
defaultValue: 64 * 1024 * 1024,
|
||||||
description: "Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
description:
|
||||||
|
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
||||||
modelKey: "settingRequestMessageSize",
|
modelKey: "settingRequestMessageSize",
|
||||||
models: ["workspace", "folder", "websocket_request", "grpc_request"],
|
models: ["workspace", "folder", "websocket_request", "grpc_request"],
|
||||||
title: "Message Size Limit",
|
title: "Message Size Limit",
|
||||||
@@ -55,7 +57,13 @@ export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
description: "When disabled, skip validation of server certificates.",
|
description: "When disabled, skip validation of server certificates.",
|
||||||
modelKey: "settingValidateCertificates",
|
modelKey: "settingValidateCertificates",
|
||||||
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
|
models: [
|
||||||
|
"workspace",
|
||||||
|
"folder",
|
||||||
|
"http_request",
|
||||||
|
"websocket_request",
|
||||||
|
"grpc_request",
|
||||||
|
],
|
||||||
title: "Validate TLS certificates",
|
title: "Validate TLS certificates",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,17 +75,10 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
|
|||||||
title: "Follow redirects",
|
title: "Follow redirects",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SETTING_HTTP_VERSION = defineRequestSetting({
|
|
||||||
defaultValue: "auto",
|
|
||||||
description: "Force HTTP/1.1 or HTTP/2 for servers that don't negotiate the version correctly.",
|
|
||||||
modelKey: "settingHttpVersion",
|
|
||||||
models: ["workspace", "folder", "http_request"],
|
|
||||||
title: "HTTP version",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SETTING_SEND_COOKIES = defineRequestSetting({
|
export const SETTING_SEND_COOKIES = defineRequestSetting({
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
|
description:
|
||||||
|
"Attach matching cookies from the active cookie jar to outgoing requests.",
|
||||||
modelKey: "settingSendCookies",
|
modelKey: "settingSendCookies",
|
||||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||||
title: "Automatically send cookies",
|
title: "Automatically send cookies",
|
||||||
@@ -85,7 +86,8 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
|
|||||||
|
|
||||||
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
|
description:
|
||||||
|
"Save cookies from Set-Cookie response headers to the active cookie jar.",
|
||||||
modelKey: "settingStoreCookies",
|
modelKey: "settingStoreCookies",
|
||||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||||
title: "Automatically store cookies",
|
title: "Automatically store cookies",
|
||||||
|
|||||||
@@ -68,8 +68,7 @@ export async function getResponseBodySseSummary(
|
|||||||
export async function getResponseBodyBytes(
|
export async function getResponseBodyBytes(
|
||||||
response: HttpResponse,
|
response: HttpResponse,
|
||||||
): Promise<Uint8Array<ArrayBuffer> | null> {
|
): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||||
// A response body is stored under the response's own id
|
return platform.files.readResponseBody(response.id);
|
||||||
return platform.blobs.read(response.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getResponseBodyDecoded(response: HttpResponse): Promise<string | null> {
|
async function getResponseBodyDecoded(response: HttpResponse): Promise<string | null> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { RpcPayload } from "@yaakapp-internal/platform";
|
import type { RpcPayload } from "@yaakapp-internal/platform";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
import type { RpcSchema } from "@yaakapp-internal/tauri-client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every backend command the app can call: the generated wire schema, one field
|
* Every backend command the app can call: the generated wire schema, one field
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
import type { HttpRequest, HttpResponse } from "@yaakapp-internal/models";
|
||||||
import type { EphemeralHttpResponse } from "@yaakapp-internal/rpc-schema";
|
|
||||||
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||||
import { rpc } from "./rpc";
|
import { rpc } from "./rpc";
|
||||||
|
|
||||||
export async function sendEphemeralRequest(
|
export async function sendEphemeralRequest(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
environmentId: string | null,
|
environmentId: string | null,
|
||||||
): Promise<EphemeralHttpResponse> {
|
): Promise<HttpResponse> {
|
||||||
// Remove some things that we don't want to associate
|
// Remove some things that we don't want to associate
|
||||||
const newRequest = { ...request };
|
const newRequest = { ...request };
|
||||||
return rpc("cmd_send_ephemeral_request", {
|
return rpc("cmd_send_ephemeral_request", {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@lezer/highlight": "^1.1.3",
|
"@lezer/highlight": "^1.1.3",
|
||||||
"@lezer/lr": "^1.3.3",
|
"@lezer/lr": "^1.3.3",
|
||||||
"@mjackson/multipart-parser": "^0.10.1",
|
"@mjackson/multipart-parser": "^0.10.1",
|
||||||
|
"@prantlf/jsonlint": "^16.0.0",
|
||||||
"@replit/codemirror-emacs": "^6.1.0",
|
"@replit/codemirror-emacs": "^6.1.0",
|
||||||
"@replit/codemirror-vim": "^6.3.0",
|
"@replit/codemirror-vim": "^6.3.0",
|
||||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||||
@@ -53,7 +54,6 @@
|
|||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
"jotai-family": "^1.0.1",
|
"jotai-family": "^1.0.1",
|
||||||
"js-md5": "^0.8.3",
|
"js-md5": "^0.8.3",
|
||||||
"jsonc-parser": "^3.3.1",
|
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"mime": "^4.0.4",
|
"mime": "^4.0.4",
|
||||||
"motion": "^12.4.7",
|
"motion": "^12.4.7",
|
||||||
@@ -93,12 +93,14 @@
|
|||||||
"@yaakapp-internal/theme": "^1.0.0",
|
"@yaakapp-internal/theme": "^1.0.0",
|
||||||
"@yaakapp-internal/ui": "^1.0.0",
|
"@yaakapp-internal/ui": "^1.0.0",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"decompress": "^4.2.1",
|
||||||
"internal-ip": "^8.0.0",
|
"internal-ip": "^8.0.0",
|
||||||
"rollup": "^4.60.3",
|
"rollup": "^4.60.3",
|
||||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||||
"vite-plugin-static-copy": "^3.3.0",
|
"vite-plugin-static-copy": "^3.3.0",
|
||||||
"vite-plugin-svgr": "^4.5.0",
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
|
"vite-plugin-top-level-await": "^1.5.0",
|
||||||
"vite-plugin-wasm": "^3.5.0",
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
"vite-plus": "^0.2.9"
|
"vite-plus": "^0.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ const WorkspacesWorkspaceIdRequestsRequestIdRoute =
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/workspaces/': typeof WorkspacesIndexRoute
|
'/workspaces': typeof WorkspacesIndexRoute
|
||||||
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
|
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
|
||||||
'/workspaces/$workspaceId/': typeof WorkspacesWorkspaceIdIndexRoute
|
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
|
||||||
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
|
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
@@ -70,9 +70,9 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/workspaces/'
|
| '/workspaces'
|
||||||
| '/workspaces/$workspaceId/settings'
|
| '/workspaces/$workspaceId/settings'
|
||||||
| '/workspaces/$workspaceId/'
|
| '/workspaces/$workspaceId'
|
||||||
| '/workspaces/$workspaceId/requests/$requestId'
|
| '/workspaces/$workspaceId/requests/$requestId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
@@ -110,14 +110,14 @@ declare module '@tanstack/react-router' {
|
|||||||
'/workspaces/': {
|
'/workspaces/': {
|
||||||
id: '/workspaces/'
|
id: '/workspaces/'
|
||||||
path: '/workspaces'
|
path: '/workspaces'
|
||||||
fullPath: '/workspaces/'
|
fullPath: '/workspaces'
|
||||||
preLoaderRoute: typeof WorkspacesIndexRouteImport
|
preLoaderRoute: typeof WorkspacesIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/workspaces/$workspaceId/': {
|
'/workspaces/$workspaceId/': {
|
||||||
id: '/workspaces/$workspaceId/'
|
id: '/workspaces/$workspaceId/'
|
||||||
path: '/workspaces/$workspaceId'
|
path: '/workspaces/$workspaceId'
|
||||||
fullPath: '/workspaces/$workspaceId/'
|
fullPath: '/workspaces/$workspaceId'
|
||||||
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport
|
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,5 @@ export const Route = createFileRoute("/workspaces/$workspaceId/settings")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { tab } = Route.useSearch();
|
return <Settings />;
|
||||||
return <Settings tab={tab} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
|||||||
import { defineConfig, normalizePath } from "vite-plus";
|
import { defineConfig, normalizePath } from "vite-plus";
|
||||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||||
import svgr from "vite-plugin-svgr";
|
import svgr from "vite-plugin-svgr";
|
||||||
|
import topLevelAwait from "vite-plugin-top-level-await";
|
||||||
import wasm from "vite-plugin-wasm";
|
import wasm from "vite-plugin-wasm";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
@@ -16,38 +17,9 @@ const standardFontsDir = normalizePath(
|
|||||||
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"),
|
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/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(async () => {
|
export default defineConfig(async () => {
|
||||||
return {
|
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: [
|
plugins: [
|
||||||
wasm(),
|
wasm(),
|
||||||
tanstackRouter({
|
tanstackRouter({
|
||||||
@@ -58,6 +30,7 @@ export default defineConfig(async () => {
|
|||||||
}),
|
}),
|
||||||
svgr(),
|
svgr(),
|
||||||
react(),
|
react(),
|
||||||
|
topLevelAwait(),
|
||||||
viteStaticCopy({
|
viteStaticCopy({
|
||||||
targets: [
|
targets: [
|
||||||
{ src: cMapsDir, dest: "" },
|
{ src: cMapsDir, dest: "" },
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||||
"vite-plus": "^0.2.9"
|
"vite-plus": "^0.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ yaak-api = { workspace = true }
|
|||||||
yaak-core = { workspace = true }
|
yaak-core = { workspace = true }
|
||||||
yaak-crypto = { workspace = true }
|
yaak-crypto = { workspace = true }
|
||||||
yaak-http = { workspace = true }
|
yaak-http = { workspace = true }
|
||||||
yaak-lifecycle = { workspace = true }
|
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
yaak-plugins = { workspace = true }
|
yaak-plugins = { workspace = true }
|
||||||
yaak-templates = { workspace = true }
|
yaak-templates = { workspace = true }
|
||||||
|
|||||||
@@ -181,11 +181,7 @@ async fn dev(args: PluginPathArg) -> CommandResult {
|
|||||||
ui::info(&format!("Rebuilding plugin {display_path}"));
|
ui::info(&format!("Rebuilding plugin {display_path}"));
|
||||||
}
|
}
|
||||||
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
|
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
|
||||||
// Assets are staged on every rebuild, so a changed asset or
|
match generate_plugin_metadata(&watch_root) {
|
||||||
// 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!(
|
Ok(()) => ui::success(&format!(
|
||||||
"Generated plugin metadata at {}",
|
"Generated plugin metadata at {}",
|
||||||
watch_root.join("build/metadata.json").display()
|
watch_root.join("build/metadata.json").display()
|
||||||
@@ -412,7 +408,6 @@ struct PublishResponse {
|
|||||||
|
|
||||||
async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
|
async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
|
||||||
prepare_build_output_dir(plugin_dir)?;
|
prepare_build_output_dir(plugin_dir)?;
|
||||||
copy_build_assets(plugin_dir)?;
|
|
||||||
let mut bundler = Bundler::new(bundler_options(plugin_dir, false))
|
let mut bundler = Bundler::new(bundler_options(plugin_dir, false))
|
||||||
.map_err(|err| format!("Failed to initialize Rolldown: {err}"))?;
|
.map_err(|err| format!("Failed to initialize Rolldown: {err}"))?;
|
||||||
let output = bundler.write().await.map_err(|err| format!("Plugin build failed:\n{err}"))?;
|
let output = bundler.write().await.map_err(|err| format!("Plugin build failed:\n{err}"))?;
|
||||||
@@ -503,63 +498,6 @@ fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
|
|||||||
.map_err(|e| format!("Failed to create build directory {}: {e}", build_dir.display()))
|
.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 {
|
fn bundler_options(plugin_dir: &Path, watch: bool) -> BundlerOptions {
|
||||||
BundlerOptions {
|
BundlerOptions {
|
||||||
input: Some(vec![InputItem { import: "./src/index.ts".to_string(), ..Default::default() }]),
|
input: Some(vec![InputItem { import: "./src/index.ts".to_string(), ..Default::default() }]),
|
||||||
@@ -812,10 +750,7 @@ describe("Example Plugin", () => {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{create_publish_archive, generate_plugin_metadata};
|
||||||
copy_build_assets, create_publish_archive, generate_plugin_metadata,
|
|
||||||
prepare_build_output_dir,
|
|
||||||
};
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -860,100 +795,6 @@ mod tests {
|
|||||||
assert!(!names.contains("ignored/secret.txt"));
|
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]
|
#[test]
|
||||||
fn generate_plugin_metadata_detects_api_types() {
|
fn generate_plugin_metadata_detects_api_types() {
|
||||||
let dir = TempDir::new().expect("temp dir");
|
let dir = TempDir::new().expect("temp dir");
|
||||||
|
|||||||
@@ -435,12 +435,15 @@ fn create(
|
|||||||
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
|
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
|
||||||
let name = name.unwrap_or_default();
|
let name = name.unwrap_or_default();
|
||||||
let url = url.unwrap_or_default();
|
let url = url.unwrap_or_default();
|
||||||
let mut request = HttpRequest { workspace_id, name, url, ..Default::default() };
|
let method = method.unwrap_or_else(|| "GET".to_string());
|
||||||
// Only override the method when one was given; `HttpRequest::default()` is the
|
|
||||||
// single place the fallback ("GET") is defined.
|
let request = HttpRequest {
|
||||||
if let Some(method) = method {
|
workspace_id,
|
||||||
request.method = method.to_uppercase();
|
name,
|
||||||
}
|
method: method.to_uppercase(),
|
||||||
|
url,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
let created = ctx
|
let created = ctx
|
||||||
.db()
|
.db()
|
||||||
|
|||||||
@@ -49,14 +49,6 @@ impl CliContext {
|
|||||||
std::process::exit(1);
|
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));
|
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use crate::context::CliExecutionContext;
|
use crate::context::CliExecutionContext;
|
||||||
use arboard::Clipboard;
|
use arboard::Clipboard;
|
||||||
use base64::Engine;
|
|
||||||
use base64::prelude::BASE64_STANDARD;
|
|
||||||
use console::Term;
|
use console::Term;
|
||||||
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
|
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -13,8 +11,7 @@ use tokio::task::JoinHandle;
|
|||||||
use yaak::plugin_events::{
|
use yaak::plugin_events::{
|
||||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||||
};
|
};
|
||||||
use yaak_models::render::{render_grpc_request, render_http_request};
|
use yaak::render::{render_grpc_request, render_http_request};
|
||||||
use yaak::response_body::FileResponseBodyStore;
|
|
||||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||||
@@ -134,7 +131,6 @@ async fn build_plugin_reply(
|
|||||||
|
|
||||||
match handle_shared_plugin_event(
|
match handle_shared_plugin_event(
|
||||||
&host_context.query_manager,
|
&host_context.query_manager,
|
||||||
&FileResponseBodyStore::new(&host_context.query_manager),
|
|
||||||
&event.payload,
|
&event.payload,
|
||||||
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
|
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
|
||||||
) {
|
) {
|
||||||
@@ -227,15 +223,7 @@ async fn build_plugin_reply(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
|
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
|
||||||
SendHttpRequestResponse {
|
SendHttpRequestResponse { http_response: result.response },
|
||||||
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 {
|
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
error: format!("Failed to send HTTP request in CLI: {err}"),
|
error: format!("Failed to send HTTP request in CLI: {err}"),
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ chrono = { workspace = true, features = ["serde"] }
|
|||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
r2d2 = "0.8.10"
|
r2d2 = "0.8.10"
|
||||||
r2d2_sqlite = "0.32"
|
r2d2_sqlite = "0.25.0"
|
||||||
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
|
||||||
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
|
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
ts-rs = { workspace = true, features = ["chrono-impl"] }
|
ts-rs = { workspace = true, features = ["chrono-impl"] }
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
[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 }
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Yaak Bridge
|
||||||
|
|
||||||
|
A headless binary that runs the real Yaak engine for a browser tab.
|
||||||
|
|
||||||
|
The tab is the unmodified Yaak UI. Everything a page cannot do — send an HTTP
|
||||||
|
request and see every response header, follow redirects, keep a cookie jar, run
|
||||||
|
the plugin runtime, read a response body off disk — happens in this process,
|
||||||
|
reached over local HTTP and a WebSocket.
|
||||||
|
|
||||||
|
This is the reason a browser Yaak can be credible at all. An in-page `fetch`
|
||||||
|
sender only ever sees the CORS-safelisted response headers: measured against
|
||||||
|
httpbin, a server that sent 8 headers yielded 2. Through the bridge the same
|
||||||
|
request yields all 8, plus the redirect chain, `Set-Cookie`, connection timings
|
||||||
|
and client certificates.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
Start the bridge:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p yaak-server -- --port 9444
|
||||||
|
```
|
||||||
|
|
||||||
|
It binds `127.0.0.1` only and prints a bearer token that every route requires.
|
||||||
|
|
||||||
|
Then point a frontend at it. In dev, run Vite separately and tell it where the
|
||||||
|
bridge is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
YAAK_CLIENT_DEV_PORT=1472 VITE_YAAK_BRIDGE_URL=http://127.0.0.1:9444 npm run dev --workspace apps/yaak-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:1472/?bridgeToken=<token>`. The token is consumed from
|
||||||
|
the query, kept for the session, and stripped from the address bar. Without one
|
||||||
|
you get a small connect form.
|
||||||
|
|
||||||
|
To serve the built frontend from the bridge itself instead, so there is only one
|
||||||
|
process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build --workspace apps/yaak-client
|
||||||
|
cargo run -p yaak-server -- --web-dir dist/apps/yaak-client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
| Route | What it carries |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /rpc` | The yaak-rpc envelope, the same one Tauri's `invoke` wraps on the desktop |
|
||||||
|
| `GET /events` | WebSocket. Server to client: `model_writes`, `stream_{id}`, toasts, plugin events. Client to server: the tab's location, and replies to prompts |
|
||||||
|
| `GET /responses/:id/body` | Response bodies, with Range support. Replaces reading `bodyPath` off disk |
|
||||||
|
| `GET /bridge/info` | Capabilities and the implemented command list |
|
||||||
|
|
||||||
|
Auth is a bearer token in the `Authorization` header, or a `token` query
|
||||||
|
parameter for the two requests the browser issues itself (the WebSocket, and
|
||||||
|
`<img src>`-style body loads). It is dev-grade and deliberately minimal: OTP
|
||||||
|
pairing and request encryption replace it, and `require_token` in `http.rs` is
|
||||||
|
where they go.
|
||||||
|
|
||||||
|
## Relationship to the other hosts
|
||||||
|
|
||||||
|
The engine crates under `crates/` are Tauri-free, and `crates-cli/yaak-cli`
|
||||||
|
already proved they run headless. This crate is structurally the CLI's
|
||||||
|
`CliContext` with an event hub attached — same `init_standalone` database, same
|
||||||
|
`PluginManager` over the same Node sidecar.
|
||||||
|
|
||||||
|
Two things are ported deliberately rather than invented:
|
||||||
|
|
||||||
|
- **Model writes** (`model_writes.rs`) keep the desktop's two paths: an
|
||||||
|
in-memory channel for writes this process made, and a poll of the
|
||||||
|
`model_changes` table so external writers — the CLI, the desktop app open on
|
||||||
|
the same database — show up live in the browser.
|
||||||
|
- **Plugin host requests** (`plugin_events.rs`) let `yaak::plugin_events`
|
||||||
|
answer everything that is only a database question, exactly as the CLI and the
|
||||||
|
desktop do. Only the host-specific arms differ, and where the CLI answers a
|
||||||
|
prompt from a TTY, the bridge round-trips it to the tab the way the desktop
|
||||||
|
round-trips it to a window.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **Settings is unreachable.** The desktop opens it via `cmd_new_child_window`.
|
||||||
|
A tab is one window, `multiWindow` is false, and this task did not add in-page
|
||||||
|
routing for it.
|
||||||
|
- **One tab at a time.** Model writes broadcast correctly to every connected
|
||||||
|
tab, so two tabs stay in sync for reads. What breaks is the session: the
|
||||||
|
tab's reported URL lives in a single slot, so with two tabs in different
|
||||||
|
workspaces a plugin's template render resolves against whichever attached
|
||||||
|
last. Prompts also broadcast, so a dialog raised by one tab appears in both.
|
||||||
|
- **No local files.** There is no file dialog, so request bodies from disk,
|
||||||
|
export, and save-response are unsupported. `cmd_import_data` is registered and
|
||||||
|
works, but only for a path typed by hand on the bridge's machine.
|
||||||
|
- **Command subset.** Roughly 40 of the desktop's 107 commands are implemented.
|
||||||
|
The rest return a structured "not supported on this host" error naming the
|
||||||
|
command; `UNSUPPORTED_COMMANDS` in `rpc/mod.rs` lists them.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//! The events channel: everything the browser tab would have received as a
|
||||||
|
//! Tauri window event.
|
||||||
|
//!
|
||||||
|
//! Two directions ride the same WebSocket. Server to client is a broadcast, so
|
||||||
|
//! `model_writes`, `stream_{id}` messages, toasts and plugin events all reach
|
||||||
|
//! the tab through one pipe. Client to server exists because some plugin host
|
||||||
|
//! requests are questions — a prompt round-trips through the UI and comes back
|
||||||
|
//! keyed by the originating event's id, exactly as the desktop app's
|
||||||
|
//! `call_frontend` does with window events.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
|
/// One frame in either direction: a name and a JSON payload.
|
||||||
|
///
|
||||||
|
/// Deliberately the same shape both ways, and the same shape as the desktop's
|
||||||
|
/// event payloads, so `platform.listen` on the browser side hands the payload
|
||||||
|
/// to callers unwrapped.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EventFrame {
|
||||||
|
pub event: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct EventHub {
|
||||||
|
outbound: broadcast::Sender<EventFrame>,
|
||||||
|
/// Listeners waiting on a named event from the client, keyed by event name.
|
||||||
|
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A subscription to one named client-sent event. Deregisters on drop, so a
|
||||||
|
/// prompt that is never answered doesn't leak a listener for the process's life.
|
||||||
|
pub struct InboundSubscription {
|
||||||
|
event: String,
|
||||||
|
rx: mpsc::UnboundedReceiver<serde_json::Value>,
|
||||||
|
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InboundSubscription {
|
||||||
|
pub async fn recv(&mut self) -> Option<serde_json::Value> {
|
||||||
|
self.rx.recv().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InboundSubscription {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut inbound = match self.inbound.lock() {
|
||||||
|
Ok(inbound) => inbound,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
if let Some(senders) = inbound.get_mut(&self.event) {
|
||||||
|
senders.retain(|tx| !tx.is_closed());
|
||||||
|
if senders.is_empty() {
|
||||||
|
inbound.remove(&self.event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHub {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
// Bounded: a tab that stops reading gets dropped frames rather than
|
||||||
|
// growing the server's memory without limit. Model writes are the
|
||||||
|
// high-volume case (imports, bulk deletes) and they arrive in batches.
|
||||||
|
let (outbound, _) = broadcast::channel(1024);
|
||||||
|
Self { outbound, inbound: Arc::new(Mutex::new(HashMap::new())) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an event to every connected tab. Fails silently when none is
|
||||||
|
/// connected, which is the normal state before a browser attaches.
|
||||||
|
pub fn emit<T: Serialize>(&self, event: impl Into<String>, payload: &T) {
|
||||||
|
let payload = match serde_json::to_value(payload) {
|
||||||
|
Ok(payload) => payload,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to serialize event payload: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = self.outbound.send(EventFrame { event: event.into(), payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(&self) -> broadcast::Receiver<EventFrame> {
|
||||||
|
self.outbound.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listen for a named event sent *by* the client.
|
||||||
|
pub fn subscribe_inbound(&self, event: impl Into<String>) -> InboundSubscription {
|
||||||
|
let event = event.into();
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
let mut inbound = match self.inbound.lock() {
|
||||||
|
Ok(inbound) => inbound,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
inbound.entry(event.clone()).or_default().push(tx);
|
||||||
|
drop(inbound);
|
||||||
|
InboundSubscription { event, rx, inbound: Arc::clone(&self.inbound) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route a frame that arrived from a tab to whoever is waiting on it.
|
||||||
|
pub fn dispatch_inbound(&self, frame: EventFrame) {
|
||||||
|
let mut inbound = match self.inbound.lock() {
|
||||||
|
Ok(inbound) => inbound,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
let Some(senders) = inbound.get_mut(&frame.event) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
senders.retain(|tx| tx.send(frame.payload.clone()).is_ok());
|
||||||
|
if senders.is_empty() {
|
||||||
|
inbound.remove(&frame.event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
//! 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 response = match app.state.db().get_http_response(&id) {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(_) => return (StatusCode::NOT_FOUND, "No such response").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(body_path) = response.body_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 = response
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
|
||||||
|
.map(|h| h.value.clone())
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
|
|
||||||
|
let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok()).and_then(parse_range);
|
||||||
|
|
||||||
|
let (start, end, status) = match range {
|
||||||
|
Some((start, end)) => {
|
||||||
|
let end = end.unwrap_or(total.saturating_sub(1)).min(total.saturating_sub(1));
|
||||||
|
if total == 0 || start > end {
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||||
|
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
(start, end, StatusCode::PARTIAL_CONTENT)
|
||||||
|
}
|
||||||
|
None => (0, total.saturating_sub(1), StatusCode::OK),
|
||||||
|
};
|
||||||
|
|
||||||
|
let length = if total == 0 { 0 } else { end - start + 1 };
|
||||||
|
|
||||||
|
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to seek body").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; length as usize];
|
||||||
|
if let Err(e) = file.read_exact(&mut buf).await {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read body: {e}"))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut builder = Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header(header::CONTENT_TYPE, content_type)
|
||||||
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
|
.header(header::CONTENT_LENGTH, length);
|
||||||
|
|
||||||
|
if status == StatusCode::PARTIAL_CONTENT {
|
||||||
|
builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.body(Body::from(buf)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a single `bytes=start-end` range. Multi-range requests are not
|
||||||
|
/// answered as multipart; the first range is used, which browsers accept.
|
||||||
|
fn parse_range(value: &str) -> Option<(u64, Option<u64>)> {
|
||||||
|
let spec = value.strip_prefix("bytes=")?.split(',').next()?.trim();
|
||||||
|
let (start, end) = spec.split_once('-')?;
|
||||||
|
if start.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let start: u64 = start.parse().ok()?;
|
||||||
|
let end = if end.is_empty() { None } else { Some(end.parse().ok()?) };
|
||||||
|
Some((start, end))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ranges() {
|
||||||
|
assert_eq!(parse_range("bytes=0-499"), Some((0, Some(499))));
|
||||||
|
assert_eq!(parse_range("bytes=500-"), Some((500, None)));
|
||||||
|
assert_eq!(parse_range("bytes=0-99,200-299"), Some((0, Some(99))));
|
||||||
|
// Suffix ranges ("last 500 bytes") aren't supported; callers get the
|
||||||
|
// whole body, which is correct if wasteful.
|
||||||
|
assert_eq!(parse_range("bytes=-500"), None);
|
||||||
|
assert_eq!(parse_range("nonsense"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_comparison_requires_exact_match() {
|
||||||
|
assert!(constant_time_eq("abc", "abc"));
|
||||||
|
assert!(!constant_time_eq("abc", "abd"));
|
||||||
|
assert!(!constant_time_eq("abc", "abcd"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! Yaak Bridge — the local companion that runs the real Yaak engine for a
|
||||||
|
//! browser tab.
|
||||||
|
//!
|
||||||
|
//! The tab is the Yaak UI, unchanged. Everything it cannot do in a page —
|
||||||
|
//! sending an HTTP request and seeing every response header, following
|
||||||
|
//! redirects, keeping a cookie jar, running plugins, reading a response body
|
||||||
|
//! off disk — happens in this process, over a local HTTP and WebSocket
|
||||||
|
//! connection.
|
||||||
|
//!
|
||||||
|
//! Loopback only, and every route needs the token printed at startup.
|
||||||
|
|
||||||
|
mod events;
|
||||||
|
mod http;
|
||||||
|
mod model_writes;
|
||||||
|
mod plugin_events;
|
||||||
|
mod rpc;
|
||||||
|
mod session;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use rand::Rng;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const APP_ID: &str = "app.yaak.bridge";
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "yaak-bridge", about = "Run the Yaak engine for a browser tab")]
|
||||||
|
struct Args {
|
||||||
|
/// Port to listen on. Loopback only, always.
|
||||||
|
#[arg(long, default_value_t = 9444, env = "YAAK_BRIDGE_PORT")]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// Where the database, plugins and response bodies live.
|
||||||
|
#[arg(long, env = "YAAK_BRIDGE_DATA_DIR")]
|
||||||
|
data_dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Use a fixed token instead of generating one. For scripted dev loops.
|
||||||
|
#[arg(long, env = "YAAK_BRIDGE_TOKEN")]
|
||||||
|
token: Option<String>,
|
||||||
|
|
||||||
|
/// Where the frontend was built to. Serving it makes this the only process
|
||||||
|
/// to run; without it, point a Vite dev server at this bridge instead.
|
||||||
|
#[arg(long, env = "YAAK_BRIDGE_WEB_DIR")]
|
||||||
|
web_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let data_dir = args.data_dir.unwrap_or_else(default_data_dir);
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
||||||
|
eprintln!("Error: failed to create data dir {}: {e}", data_dir.display());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(web_dir) = &args.web_dir {
|
||||||
|
// Read back by the router; keeping it in the environment avoids
|
||||||
|
// threading an option through every layer for a dev-mode convenience.
|
||||||
|
unsafe { std::env::set_var("YAAK_BRIDGE_WEB_DIR", web_dir) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = args.token.unwrap_or_else(generate_token);
|
||||||
|
let is_dev = cfg!(debug_assertions);
|
||||||
|
|
||||||
|
let mut state = state::BridgeState::new(data_dir.clone(), APP_ID, token.clone(), is_dev);
|
||||||
|
state.init_plugins().await;
|
||||||
|
let state = Arc::new(state);
|
||||||
|
|
||||||
|
let router = Arc::new(rpc::build_router());
|
||||||
|
let app = http::build_app(state.clone(), router);
|
||||||
|
|
||||||
|
let addr = SocketAddr::from(([127, 0, 0, 1], args.port));
|
||||||
|
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error: failed to bind {addr}: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let base = format!("http://127.0.0.1:{}", args.port);
|
||||||
|
println!();
|
||||||
|
println!(" Yaak Bridge listening on {base}");
|
||||||
|
println!(" Data dir: {}", data_dir.display());
|
||||||
|
println!(" Plugins: {}", if state.capabilities.plugins { "running" } else { "unavailable" });
|
||||||
|
println!();
|
||||||
|
if std::env::var("YAAK_BRIDGE_WEB_DIR").is_ok() {
|
||||||
|
println!(" Open: {base}/?bridgeToken={token}");
|
||||||
|
} else {
|
||||||
|
println!(" Token: {token}");
|
||||||
|
println!(" Open your dev server with ?bridgeToken={token}");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let shutdown_state = state.clone();
|
||||||
|
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
log::info!("Shutting down");
|
||||||
|
shutdown_state.shutdown().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(e) = server.await {
|
||||||
|
eprintln!("Error: server failed: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_data_dir() -> PathBuf {
|
||||||
|
dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")).join("yaak-bridge")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 256-bit random token, hex encoded. Per process, never written to disk.
|
||||||
|
fn generate_token() -> String {
|
||||||
|
let bytes: [u8; 32] = rand::thread_rng().r#gen();
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! Pushing model writes to the connected tab.
|
||||||
|
//!
|
||||||
|
//! A direct port of the desktop's two paths (see
|
||||||
|
//! crates-tauri/yaak-app-client/src/models_ext.rs), and for the same reason:
|
||||||
|
//! the in-memory channel is the fast path for writes this process made on a
|
||||||
|
//! client's behalf, while polling the `model_changes` table is what makes an
|
||||||
|
//! external writer — the CLI, a second bridge, the desktop app open on the same
|
||||||
|
//! database — show up live in the browser. Keeping both means the browser
|
||||||
|
//! behaves like the desktop rather than like a cache.
|
||||||
|
|
||||||
|
use crate::events::EventHub;
|
||||||
|
use chrono::Utc;
|
||||||
|
use log::error;
|
||||||
|
use std::sync::mpsc::Receiver;
|
||||||
|
use std::time::Duration;
|
||||||
|
use yaak_models::query_manager::QueryManager;
|
||||||
|
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||||
|
|
||||||
|
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||||
|
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
||||||
|
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
|
||||||
|
|
||||||
|
struct ModelChangeCursor {
|
||||||
|
created_at: String,
|
||||||
|
id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelChangeCursor {
|
||||||
|
fn from_launch_time() -> Self {
|
||||||
|
Self {
|
||||||
|
created_at: Utc::now().naive_utc().format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
|
||||||
|
id: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(query_manager: &QueryManager, rx: Receiver<ModelPayload>, events: EventHub) {
|
||||||
|
if let Err(err) =
|
||||||
|
query_manager.connect().prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
|
||||||
|
{
|
||||||
|
error!("Failed to prune model_changes rows on startup: {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only stream writes that happen after this process started.
|
||||||
|
let cursor = ModelChangeCursor::from_launch_time();
|
||||||
|
let poll_query_manager = query_manager.clone();
|
||||||
|
let poll_events = events.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
run_model_change_poller(poll_query_manager, poll_events, cursor).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// `init_standalone` hands back a std (blocking) receiver, so it gets a
|
||||||
|
// thread rather than a task.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
while let Ok(payload) = rx.recv() {
|
||||||
|
let mut batch: Vec<ModelPayload> = Vec::new();
|
||||||
|
if matches!(payload.update_source, UpdateSource::Window { .. }) {
|
||||||
|
batch.push(payload);
|
||||||
|
}
|
||||||
|
// Coalesce anything already queued into the same frame.
|
||||||
|
while let Ok(next) = rx.try_recv() {
|
||||||
|
if matches!(next.update_source, UpdateSource::Window { .. }) {
|
||||||
|
batch.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if batch.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
events.emit("model_writes", &batch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_model_change_poller(
|
||||||
|
query_manager: QueryManager,
|
||||||
|
events: EventHub,
|
||||||
|
mut cursor: ModelChangeCursor,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
while drain_model_changes_batch(&query_manager, &events, &mut cursor) {}
|
||||||
|
tokio::time::sleep(Duration::from_millis(MODEL_CHANGES_POLL_INTERVAL_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_model_changes_batch(
|
||||||
|
query_manager: &QueryManager,
|
||||||
|
events: &EventHub,
|
||||||
|
cursor: &mut ModelChangeCursor,
|
||||||
|
) -> bool {
|
||||||
|
let changes = match query_manager.connect().list_model_changes_since(
|
||||||
|
&cursor.created_at,
|
||||||
|
cursor.id,
|
||||||
|
MODEL_CHANGES_POLL_BATCH_SIZE,
|
||||||
|
) {
|
||||||
|
Ok(changes) => changes,
|
||||||
|
Err(err) => {
|
||||||
|
error!("Failed to poll model_changes rows: {err:?}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if changes.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetched_count = changes.len();
|
||||||
|
let mut batch: Vec<ModelPayload> = Vec::with_capacity(fetched_count);
|
||||||
|
for change in changes {
|
||||||
|
cursor.created_at = change.created_at;
|
||||||
|
cursor.id = change.id;
|
||||||
|
|
||||||
|
// Window-sourced writes already went out on the in-memory fast path.
|
||||||
|
if matches!(change.payload.update_source, UpdateSource::Window { .. }) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
batch.push(change.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One batch per drain so bulk writes don't flood the tab.
|
||||||
|
if !batch.is_empty() {
|
||||||
|
events.emit("model_writes", &batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetched_count == MODEL_CHANGES_POLL_BATCH_SIZE
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
//! The bridge's plugin host.
|
||||||
|
//!
|
||||||
|
//! Same shape as the CLI's bridge (crates-cli/yaak-cli/src/plugin_events.rs):
|
||||||
|
//! subscribe to the plugin manager, let `handle_shared_plugin_event` answer
|
||||||
|
//! everything that is only a database question, and implement the rest here.
|
||||||
|
//!
|
||||||
|
//! Where it differs is that a UI is attached. The CLI answers a prompt from a
|
||||||
|
//! TTY and refuses when there isn't one; the bridge does what the desktop does
|
||||||
|
//! instead — pushes the event to the tab and waits for the reply keyed by the
|
||||||
|
//! event's id. Toasts, clipboard writes and external URLs go the same way,
|
||||||
|
//! because the browser is the only thing here that can show or do them.
|
||||||
|
|
||||||
|
use crate::events::EventHub;
|
||||||
|
use crate::session::SessionStore;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use yaak::plugin_events::{
|
||||||
|
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||||
|
};
|
||||||
|
use yaak::render::{render_grpc_request, render_http_request};
|
||||||
|
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||||
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
|
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||||
|
use yaak_http::manager::HttpConnectionManager;
|
||||||
|
use yaak_models::blob_manager::BlobManager;
|
||||||
|
use yaak_models::models::Environment;
|
||||||
|
use yaak_models::queries::any_request::AnyRequest;
|
||||||
|
use yaak_models::query_manager::QueryManager;
|
||||||
|
use yaak_models::render::make_vars_hashmap;
|
||||||
|
use yaak_models::util::UpdateSource;
|
||||||
|
use yaak_plugins::events::{
|
||||||
|
EmptyPayload, ErrorResponse, GetCookieValueResponse, InternalEvent, InternalEventPayload,
|
||||||
|
ListCookieNamesResponse, ListOpenWorkspacesResponse, PluginContext, PromptTextResponse,
|
||||||
|
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
|
||||||
|
TemplateRenderResponse, WindowInfoResponse, WorkspaceInfo,
|
||||||
|
};
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
use yaak_plugins::plugin_handle::PluginHandle;
|
||||||
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
|
use yaak_templates::{RenderOptions, TemplateCallback, render_json_value_raw};
|
||||||
|
|
||||||
|
pub struct BridgePluginEventBridge {
|
||||||
|
rx_id: String,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BridgeHostContext {
|
||||||
|
query_manager: QueryManager,
|
||||||
|
blob_manager: BlobManager,
|
||||||
|
plugin_manager: Arc<PluginManager>,
|
||||||
|
encryption_manager: Arc<EncryptionManager>,
|
||||||
|
connection_manager: Arc<HttpConnectionManager>,
|
||||||
|
response_dir: PathBuf,
|
||||||
|
events: EventHub,
|
||||||
|
session: SessionStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgePluginEventBridge {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn start(
|
||||||
|
plugin_manager: Arc<PluginManager>,
|
||||||
|
query_manager: QueryManager,
|
||||||
|
blob_manager: BlobManager,
|
||||||
|
encryption_manager: Arc<EncryptionManager>,
|
||||||
|
connection_manager: Arc<HttpConnectionManager>,
|
||||||
|
data_dir: PathBuf,
|
||||||
|
events: EventHub,
|
||||||
|
session: SessionStore,
|
||||||
|
) -> Self {
|
||||||
|
let (rx_id, mut rx) = plugin_manager.subscribe("bridge").await;
|
||||||
|
let rx_id_for_task = rx_id.clone();
|
||||||
|
let pm = plugin_manager.clone();
|
||||||
|
let host_context = Arc::new(BridgeHostContext {
|
||||||
|
query_manager,
|
||||||
|
blob_manager,
|
||||||
|
plugin_manager,
|
||||||
|
encryption_manager,
|
||||||
|
connection_manager,
|
||||||
|
response_dir: data_dir.join("responses"),
|
||||||
|
events,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
while let Some(event) = rx.recv().await {
|
||||||
|
// Events with reply IDs are replies to app-originated requests.
|
||||||
|
if event.reply_id.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(plugin_handle) = pm.get_plugin_by_ref_id(&event.plugin_ref_id).await
|
||||||
|
else {
|
||||||
|
log::warn!(
|
||||||
|
"Ignoring plugin event with unknown plugin ref '{}'",
|
||||||
|
event.plugin_ref_id
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pm = pm.clone();
|
||||||
|
let host_context = host_context.clone();
|
||||||
|
|
||||||
|
// Avoid deadlocks for nested plugin-host requests (for example, template functions
|
||||||
|
// that trigger additional host requests during render) by handling each event in
|
||||||
|
// its own task.
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let plugin_name = plugin_handle.info().name;
|
||||||
|
let Some(reply_payload) = build_plugin_reply(
|
||||||
|
host_context.as_ref(),
|
||||||
|
&event,
|
||||||
|
&plugin_name,
|
||||||
|
&plugin_handle,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = pm.reply(&event, &reply_payload).await {
|
||||||
|
log::warn!("Failed replying to plugin event: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.unsubscribe(&rx_id_for_task).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { rx_id, task }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn shutdown(self, plugin_manager: &PluginManager) {
|
||||||
|
plugin_manager.unsubscribe(&self.rx_id).await;
|
||||||
|
self.task.abort();
|
||||||
|
let _ = self.task.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_plugin_reply(
|
||||||
|
host_context: &BridgeHostContext,
|
||||||
|
event: &InternalEvent,
|
||||||
|
plugin_name: &str,
|
||||||
|
plugin_handle: &PluginHandle,
|
||||||
|
) -> Option<InternalEventPayload> {
|
||||||
|
let session = host_context.session.get();
|
||||||
|
let shared_workspace_id =
|
||||||
|
event.context.workspace_id.clone().or_else(|| session.workspace_id());
|
||||||
|
|
||||||
|
match handle_shared_plugin_event(
|
||||||
|
&host_context.query_manager,
|
||||||
|
&event.payload,
|
||||||
|
SharedPluginEventContext {
|
||||||
|
plugin_name,
|
||||||
|
workspace_id: shared_workspace_id.as_deref(),
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
GroupedPluginEvent::Handled(payload) => payload,
|
||||||
|
GroupedPluginEvent::ToHandle(host_request) => match host_request {
|
||||||
|
HostRequest::ErrorResponse(resp) => {
|
||||||
|
log::warn!("[plugin:{plugin_name}] error: {}", resp.error);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
HostRequest::ReloadResponse(_) => None,
|
||||||
|
|
||||||
|
// The tab owns everything the user can see or the OS can do. These
|
||||||
|
// are fire-and-forget: the plugin gets its acknowledgement as soon
|
||||||
|
// as the frame is queued, matching the desktop, which also does not
|
||||||
|
// wait for the webview to paint.
|
||||||
|
HostRequest::ShowToast(req) => {
|
||||||
|
host_context.events.emit("show_toast", &req);
|
||||||
|
Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))
|
||||||
|
}
|
||||||
|
HostRequest::CopyText(req) => {
|
||||||
|
host_context.events.emit("bridge_copy_text", &req);
|
||||||
|
Some(InternalEventPayload::CopyTextResponse(EmptyPayload {}))
|
||||||
|
}
|
||||||
|
HostRequest::OpenExternalUrl(req) => {
|
||||||
|
host_context.events.emit("bridge_open_url", &req);
|
||||||
|
Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompts are questions, so they round-trip: the tab renders the
|
||||||
|
// dialog and emits the answer back under the event's own id.
|
||||||
|
HostRequest::PromptText(_) => {
|
||||||
|
let reply = call_frontend(host_context, event).await;
|
||||||
|
Some(reply.unwrap_or(InternalEventPayload::PromptTextResponse(
|
||||||
|
PromptTextResponse { value: None },
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A form streams: the tab sends a response per interaction and the
|
||||||
|
// plugin re-renders, until one comes back marked done.
|
||||||
|
HostRequest::PromptForm(_) => {
|
||||||
|
host_context.events.emit("plugin_event", event);
|
||||||
|
if event.reply_id.is_none() {
|
||||||
|
spawn_form_reply_pump(host_context, event, plugin_handle);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
HostRequest::ListOpenWorkspaces(_) => {
|
||||||
|
let workspaces = match host_context.query_manager.connect().list_workspaces() {
|
||||||
|
Ok(workspaces) => workspaces
|
||||||
|
.into_iter()
|
||||||
|
.map(|w| WorkspaceInfo {
|
||||||
|
id: w.id.clone(),
|
||||||
|
name: w.name,
|
||||||
|
label: session.label.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
Err(err) => {
|
||||||
|
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Failed to list workspaces in bridge: {err}"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(InternalEventPayload::ListOpenWorkspacesResponse(ListOpenWorkspacesResponse {
|
||||||
|
workspaces,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
HostRequest::SendHttpRequest(req) => {
|
||||||
|
let mut http_request = req.http_request.clone();
|
||||||
|
if http_request.workspace_id.is_empty() {
|
||||||
|
let Some(workspace_id) = shared_workspace_id.clone() else {
|
||||||
|
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: "workspace_id is required to send HTTP requests in bridge"
|
||||||
|
.to_string(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
http_request.workspace_id = workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookie_jar_id = match session.cookie_jar_id() {
|
||||||
|
Some(id) => Some(id),
|
||||||
|
None => match host_context
|
||||||
|
.query_manager
|
||||||
|
.connect()
|
||||||
|
.list_cookie_jars(http_request.workspace_id.as_str())
|
||||||
|
{
|
||||||
|
Ok(jars) => {
|
||||||
|
jars.into_iter().min_by_key(|jar| jar.created_at).map(|jar| jar.id)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Failed to list cookie jars in bridge: {err}"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let plugin_context = PluginContext {
|
||||||
|
workspace_id: Some(http_request.workspace_id.clone()),
|
||||||
|
..event.context.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
match send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
|
||||||
|
query_manager: &host_context.query_manager,
|
||||||
|
blob_manager: &host_context.blob_manager,
|
||||||
|
request: http_request,
|
||||||
|
environment_id: session.environment_id().as_deref(),
|
||||||
|
update_source: UpdateSource::Plugin,
|
||||||
|
cookie_jar_id,
|
||||||
|
response_dir: &host_context.response_dir,
|
||||||
|
emit_events_to: None,
|
||||||
|
emit_response_body_chunks_to: None,
|
||||||
|
existing_response: None,
|
||||||
|
plugin_manager: host_context.plugin_manager.clone(),
|
||||||
|
encryption_manager: host_context.encryption_manager.clone(),
|
||||||
|
plugin_context: &plugin_context,
|
||||||
|
cancelled_rx: None,
|
||||||
|
connection_manager: &host_context.connection_manager,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
|
||||||
|
SendHttpRequestResponse { http_response: result.response },
|
||||||
|
)),
|
||||||
|
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Failed to send HTTP request in bridge: {err}"),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HostRequest::RenderHttpRequest(req) => {
|
||||||
|
let mut http_request = req.http_request.clone();
|
||||||
|
if http_request.workspace_id.is_empty() {
|
||||||
|
let Some(workspace_id) = shared_workspace_id.clone() else {
|
||||||
|
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: "workspace_id is required to render HTTP requests in bridge"
|
||||||
|
.to_string(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
http_request.workspace_id = workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugin_context = PluginContext {
|
||||||
|
workspace_id: Some(http_request.workspace_id.clone()),
|
||||||
|
..event.context.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let environment_chain = match host_context.query_manager.connect().resolve_environments(
|
||||||
|
&http_request.workspace_id,
|
||||||
|
http_request.folder_id.as_deref(),
|
||||||
|
session.environment_id().as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(chain) => chain,
|
||||||
|
Err(err) => {
|
||||||
|
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Failed to resolve environments in bridge: {err}"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let template_callback = PluginTemplateCallback::new(
|
||||||
|
host_context.plugin_manager.clone(),
|
||||||
|
host_context.encryption_manager.clone(),
|
||||||
|
&plugin_context,
|
||||||
|
req.purpose.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
match render_http_request(
|
||||||
|
&http_request,
|
||||||
|
environment_chain,
|
||||||
|
&template_callback,
|
||||||
|
&RenderOptions::throw(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(http_request) => Some(InternalEventPayload::RenderHttpRequestResponse(
|
||||||
|
RenderHttpRequestResponse { http_request },
|
||||||
|
)),
|
||||||
|
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Failed to render HTTP request in bridge: {err}"),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HostRequest::RenderGrpcRequest(req) => {
|
||||||
|
let mut grpc_request = req.grpc_request.clone();
|
||||||
|
if grpc_request.workspace_id.is_empty() {
|
||||||
|
let Some(workspace_id) = shared_workspace_id.clone() else {
|
||||||
|
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: "workspace_id is required to render gRPC requests in bridge"
|
||||||
|
.to_string(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
grpc_request.workspace_id = workspace_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugin_context = PluginContext {
|
||||||
|
workspace_id: Some(grpc_request.workspace_id.clone()),
|
||||||
|
..event.context.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let environment_chain = match host_context.query_manager.connect().resolve_environments(
|
||||||
|
&grpc_request.workspace_id,
|
||||||
|
grpc_request.folder_id.as_deref(),
|
||||||
|
session.environment_id().as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(chain) => chain,
|
||||||
|
Err(err) => {
|
||||||
|
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Failed to resolve environments in bridge: {err}"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let template_callback = PluginTemplateCallback::new(
|
||||||
|
host_context.plugin_manager.clone(),
|
||||||
|
host_context.encryption_manager.clone(),
|
||||||
|
&plugin_context,
|
||||||
|
req.purpose.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
match render_grpc_request(
|
||||||
|
&grpc_request,
|
||||||
|
environment_chain,
|
||||||
|
&template_callback,
|
||||||
|
&RenderOptions::throw(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(grpc_request) => Some(InternalEventPayload::RenderGrpcRequestResponse(
|
||||||
|
RenderGrpcRequestResponse { grpc_request },
|
||||||
|
)),
|
||||||
|
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Failed to render gRPC request in bridge: {err}"),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HostRequest::TemplateRender(req) => {
|
||||||
|
let Some(workspace_id) = shared_workspace_id.clone() else {
|
||||||
|
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: "workspace_id is required to render templates in bridge".to_string(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
let plugin_context =
|
||||||
|
PluginContext { workspace_id: Some(workspace_id.clone()), ..event.context.clone() };
|
||||||
|
|
||||||
|
let folder_id = session.request_id().and_then(|rid| {
|
||||||
|
match host_context.query_manager.connect().get_any_request(&rid) {
|
||||||
|
Ok(AnyRequest::HttpRequest(r)) => r.folder_id,
|
||||||
|
Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,
|
||||||
|
Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let environment_chain = match host_context.query_manager.connect().resolve_environments(
|
||||||
|
&workspace_id,
|
||||||
|
folder_id.as_deref(),
|
||||||
|
session.environment_id().as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(chain) => chain,
|
||||||
|
Err(err) => {
|
||||||
|
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Failed to resolve environments in bridge: {err}"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let template_callback = PluginTemplateCallback::new(
|
||||||
|
host_context.plugin_manager.clone(),
|
||||||
|
host_context.encryption_manager.clone(),
|
||||||
|
&plugin_context,
|
||||||
|
req.purpose.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
match render_json_value(
|
||||||
|
req.data.clone(),
|
||||||
|
environment_chain,
|
||||||
|
&template_callback,
|
||||||
|
&RenderOptions::throw(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(data) => {
|
||||||
|
Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse {
|
||||||
|
data,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Failed to render template data in bridge: {err}"),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HostRequest::ListCookieNames(_) => {
|
||||||
|
let Some(cookie_jar_id) = session.cookie_jar_id() else {
|
||||||
|
return Some(InternalEventPayload::ListCookieNamesResponse(
|
||||||
|
ListCookieNamesResponse { names: Vec::new() },
|
||||||
|
));
|
||||||
|
};
|
||||||
|
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
|
||||||
|
Ok(jar) => Some(InternalEventPayload::ListCookieNamesResponse(
|
||||||
|
ListCookieNamesResponse {
|
||||||
|
names: jar.cookies.into_iter().map(|c| c.name).collect(),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Failed to load cookie jar in bridge: {err}"),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HostRequest::GetCookieValue(req) => {
|
||||||
|
let Some(cookie_jar_id) = session.cookie_jar_id() else {
|
||||||
|
return Some(InternalEventPayload::GetCookieValueResponse(
|
||||||
|
GetCookieValueResponse { value: None },
|
||||||
|
));
|
||||||
|
};
|
||||||
|
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
|
||||||
|
Ok(jar) => {
|
||||||
|
let value =
|
||||||
|
get_cookie_value_from_jar(jar.cookies, &req.name, req.domain.as_deref());
|
||||||
|
Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse {
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Failed to load cookie jar in bridge: {err}"),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HostRequest::WindowInfo(req) => {
|
||||||
|
Some(InternalEventPayload::WindowInfoResponse(WindowInfoResponse {
|
||||||
|
label: req.label.clone(),
|
||||||
|
request_id: session.request_id(),
|
||||||
|
workspace_id: shared_workspace_id.clone(),
|
||||||
|
environment_id: session.environment_id(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tab is one window. Opening and closing them needs the
|
||||||
|
// multiWindow capability the bridge reports false.
|
||||||
|
HostRequest::OpenWindow(_) => Some(unsupported("open_window_request")),
|
||||||
|
HostRequest::CloseWindow(_) => Some(unsupported("close_window_request")),
|
||||||
|
HostRequest::OtherRequest(payload) => Some(unsupported(&payload.type_name())),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsupported(type_name: &str) -> InternalEventPayload {
|
||||||
|
InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||||
|
error: format!("Unsupported plugin request in bridge: {type_name}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the tab and wait for its answer, keyed by the event's id — the same
|
||||||
|
/// contract as the desktop's `call_frontend`.
|
||||||
|
async fn call_frontend(
|
||||||
|
host_context: &BridgeHostContext,
|
||||||
|
event: &InternalEvent,
|
||||||
|
) -> Option<InternalEventPayload> {
|
||||||
|
// Subscribe before emitting: the tab can answer faster than this task is
|
||||||
|
// rescheduled, and a reply that arrives before the listener exists is lost.
|
||||||
|
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
|
||||||
|
host_context.events.emit("plugin_event", event);
|
||||||
|
|
||||||
|
let value = replies.recv().await?;
|
||||||
|
match serde_json::from_value::<InternalEvent>(value) {
|
||||||
|
Ok(reply) => Some(reply.payload),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to parse plugin reply from browser: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward every form response the tab sends back to the plugin, until one is
|
||||||
|
/// marked done.
|
||||||
|
fn spawn_form_reply_pump(
|
||||||
|
host_context: &BridgeHostContext,
|
||||||
|
event: &InternalEvent,
|
||||||
|
plugin_handle: &PluginHandle,
|
||||||
|
) {
|
||||||
|
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
|
||||||
|
let plugin_handle = plugin_handle.clone();
|
||||||
|
let plugin_context = event.context.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(value) = replies.recv().await {
|
||||||
|
let Ok(resp) = serde_json::from_value::<InternalEvent>(value) else {
|
||||||
|
log::warn!("Failed to parse form response from browser");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_done = matches!(
|
||||||
|
&resp.payload,
|
||||||
|
InternalEventPayload::PromptFormResponse(r) if r.done.unwrap_or(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
let event_to_send = plugin_handle.build_event_to_send(
|
||||||
|
&plugin_context,
|
||||||
|
&resp.payload,
|
||||||
|
Some(resp.reply_id.unwrap_or_default()),
|
||||||
|
);
|
||||||
|
if let Err(e) = plugin_handle.send(&event_to_send).await {
|
||||||
|
log::warn!("Failed to forward form response to plugin: {e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_done {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn render_json_value<T: TemplateCallback>(
|
||||||
|
value: Value,
|
||||||
|
environment_chain: Vec<Environment>,
|
||||||
|
cb: &T,
|
||||||
|
opt: &RenderOptions,
|
||||||
|
) -> yaak_templates::error::Result<Value> {
|
||||||
|
let vars = &make_vars_hashmap(environment_chain);
|
||||||
|
render_json_value_raw(value, vars, cb, opt).await
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
|||||||
|
//! 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_http_response_body_path` is how the desktop host turns a response id
|
||||||
|
// into a file it can open; a tab has nothing to do with the answer and
|
||||||
|
// fetches `/responses/:id/body` instead.
|
||||||
|
"cmd_http_response_body_path",
|
||||||
|
"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)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! What the connected tab is currently looking at.
|
||||||
|
//!
|
||||||
|
//! The desktop reads workspace, environment, cookie jar and request straight off
|
||||||
|
//! the window's URL (crates-tauri/yaak-tauri-utils/src/window.rs). A browser tab
|
||||||
|
//! runs the same router and so has the same URL, but the server cannot see it —
|
||||||
|
//! so the tab reports it, on connect and whenever it changes, and the same
|
||||||
|
//! parsing happens here.
|
||||||
|
//!
|
||||||
|
//! One session for the whole process: this slice serves a single tab. A second
|
||||||
|
//! tab overwrites the first's context rather than getting its own.
|
||||||
|
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SessionContext {
|
||||||
|
/// Identifies the tab, and lands in `UpdateSource::Window { label }` so
|
||||||
|
/// model-write echo suppression works exactly as it does on the desktop.
|
||||||
|
pub label: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionContext {
|
||||||
|
pub fn workspace_id(&self) -> Option<String> {
|
||||||
|
let rest = self.url.split("/workspaces/").nth(1)?;
|
||||||
|
let id: String =
|
||||||
|
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
|
||||||
|
if id.is_empty() { None } else { Some(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_id(&self) -> Option<String> {
|
||||||
|
let rest = self.url.split("/requests/").nth(1)?;
|
||||||
|
let id: String =
|
||||||
|
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
|
||||||
|
if id.is_empty() { None } else { Some(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn environment_id(&self) -> Option<String> {
|
||||||
|
self.query_param("environment_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cookie_jar_id(&self) -> Option<String> {
|
||||||
|
self.query_param("cookie_jar_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_param(&self, key: &str) -> Option<String> {
|
||||||
|
let query = self.url.split('?').nth(1)?;
|
||||||
|
let value = query.split('&').find_map(|pair| {
|
||||||
|
let (k, v) = pair.split_once('=')?;
|
||||||
|
if k != key {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(percent_decode(v))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// The router writes `environment_id=null` when nothing is selected.
|
||||||
|
// Neither of these is an id, and treating them as one sends a lookup
|
||||||
|
// for a model that cannot exist.
|
||||||
|
if value.is_empty() || value == "null" || value == "undefined" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(input: &str) -> String {
|
||||||
|
let bytes = input.replace('+', " ").into_bytes();
|
||||||
|
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||||
|
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
|
||||||
|
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||||
|
out.push(byte);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct SessionStore {
|
||||||
|
inner: Arc<RwLock<SessionContext>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStore {
|
||||||
|
pub fn get(&self) -> SessionContext {
|
||||||
|
match self.inner.read() {
|
||||||
|
Ok(guard) => guard.clone(),
|
||||||
|
Err(poisoned) => poisoned.into_inner().clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, context: SessionContext) {
|
||||||
|
let mut guard = match self.inner.write() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
*guard = context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ctx(url: &str) -> SessionContext {
|
||||||
|
SessionContext { label: "tab".into(), url: url.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_ids_from_a_router_url() {
|
||||||
|
let c = ctx(
|
||||||
|
"http://localhost:1472/workspaces/wk_abc123/requests/rq_def456?environment_id=ev_1&cookie_jar_id=cj_2",
|
||||||
|
);
|
||||||
|
assert_eq!(c.workspace_id().as_deref(), Some("wk_abc123"));
|
||||||
|
assert_eq!(c.request_id().as_deref(), Some("rq_def456"));
|
||||||
|
assert_eq!(c.environment_id().as_deref(), Some("ev_1"));
|
||||||
|
assert_eq!(c.cookie_jar_id().as_deref(), Some("cj_2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn placeholder_query_values_are_not_ids() {
|
||||||
|
let c = ctx("http://localhost:1472/workspaces/wk_a?environment_id=null&cookie_jar_id=");
|
||||||
|
assert_eq!(c.environment_id(), None);
|
||||||
|
assert_eq!(c.cookie_jar_id(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_parts_are_none() {
|
||||||
|
let c = ctx("http://localhost:1472/");
|
||||||
|
assert_eq!(c.workspace_id(), None);
|
||||||
|
assert_eq!(c.request_id(), None);
|
||||||
|
assert_eq!(c.environment_id(), None);
|
||||||
|
assert_eq!(c.cookie_jar_id(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn db(&self) -> ClientDb<'_> {
|
||||||
|
self.query_manager.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_manager(&self) -> &QueryManager {
|
||||||
|
&self.query_manager
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blob_manager(&self) -> &BlobManager {
|
||||||
|
&self.blob_manager
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connection_manager(&self) -> &HttpConnectionManager {
|
||||||
|
&self.connection_manager
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plugin_manager(&self) -> Option<Arc<PluginManager>> {
|
||||||
|
self.plugin_manager.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn shutdown(&self) {
|
||||||
|
if let Some(plugin_manager) = &self.plugin_manager {
|
||||||
|
if let Some(plugin_event_bridge) = self.plugin_event_bridge.lock().await.take() {
|
||||||
|
plugin_event_bridge.shutdown(plugin_manager).await;
|
||||||
|
}
|
||||||
|
plugin_manager.terminate().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_embedded_plugin_runtime(data_dir: &Path) -> std::io::Result<PathBuf> {
|
||||||
|
let runtime_dir = data_dir.join("vendored").join("plugin-runtime");
|
||||||
|
fs::create_dir_all(&runtime_dir)?;
|
||||||
|
let runtime_main = runtime_dir.join("index.cjs");
|
||||||
|
fs::write(&runtime_main, EMBEDDED_PLUGIN_RUNTIME)?;
|
||||||
|
Ok(runtime_main)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_embedded_vendored_plugins(vendored_plugin_dir: &Path) -> std::io::Result<()> {
|
||||||
|
fs::create_dir_all(vendored_plugin_dir)?;
|
||||||
|
EMBEDDED_VENDORED_PLUGINS.extract(vendored_plugin_dir)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
[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 }
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
# 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.
|
|
||||||
-135
@@ -1,135 +0,0 @@
|
|||||||
// 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;
|
|
||||||
settingHttpVersion: InheritedHttpVersionSetting;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
httpVersion: HttpVersion;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 HttpVersion = "auto" | "http1" | "http2";
|
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
|
||||||
|
|
||||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
|
||||||
|
|
||||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
|
||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
// 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, };
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// 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";
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@yaakapp-internal/web",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"main": "index.ts"
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
//! 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
//! 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
//! 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))
|
|
||||||
}
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
//! 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,
|
|
||||||
http_version: self.settings.http_version,
|
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
//! 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>>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,7 @@ md5 = "0.8.0"
|
|||||||
notify = "8.0.0"
|
notify = "8.0.0"
|
||||||
pretty_graphql = "0.2"
|
pretty_graphql = "0.2"
|
||||||
r2d2 = "0.8.10"
|
r2d2 = "0.8.10"
|
||||||
r2d2_sqlite = "0.32"
|
r2d2_sqlite = "0.25.0"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
rand = "0.9.0"
|
rand = "0.9.0"
|
||||||
reqwest = { workspace = true, features = [
|
reqwest = { workspace = true, features = [
|
||||||
@@ -73,14 +73,12 @@ url = "2"
|
|||||||
tokio-util = { version = "0.7", features = ["codec"] }
|
tokio-util = { version = "0.7", features = ["codec"] }
|
||||||
ts-rs = { workspace = true }
|
ts-rs = { workspace = true }
|
||||||
yaak-rpc = { workspace = true }
|
yaak-rpc = { workspace = true }
|
||||||
yaak-rpc-schema = { workspace = true }
|
|
||||||
uuid = "1.12.1"
|
uuid = "1.12.1"
|
||||||
yaak-api = { workspace = true }
|
yaak-api = { workspace = true }
|
||||||
yaak-common = { workspace = true }
|
yaak-common = { workspace = true }
|
||||||
yaak-tauri-utils = { workspace = true }
|
yaak-tauri-utils = { workspace = true }
|
||||||
yaak-core = { workspace = true }
|
yaak-core = { workspace = true }
|
||||||
yaak = { workspace = true }
|
yaak = { workspace = true }
|
||||||
yaak-commands = { workspace = true }
|
|
||||||
yaak-crypto = { workspace = true }
|
yaak-crypto = { workspace = true }
|
||||||
yaak-fonts = { workspace = true }
|
yaak-fonts = { workspace = true }
|
||||||
yaak-git = { workspace = true }
|
yaak-git = { workspace = true }
|
||||||
@@ -88,7 +86,6 @@ yaak-grpc = { workspace = true }
|
|||||||
yaak-http = { workspace = true }
|
yaak-http = { workspace = true }
|
||||||
yaak-license = { workspace = true, optional = true }
|
yaak-license = { workspace = true, optional = true }
|
||||||
yaak-mac-window = { workspace = true }
|
yaak-mac-window = { workspace = true }
|
||||||
yaak-lifecycle = { workspace = true }
|
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
yaak-plugins = { workspace = true }
|
yaak-plugins = { workspace = true }
|
||||||
yaak-sse = { workspace = true }
|
yaak-sse = { workspace = true }
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
|
||||||
|
|
||||||
|
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
|
||||||
|
|
||||||
|
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 CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
||||||
|
|
||||||
|
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||||
|
|
||||||
|
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
||||||
|
|
||||||
|
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||||
|
|
||||||
|
export type EncryptedKey = { encryptedKey: string, };
|
||||||
|
|
||||||
|
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
||||||
|
/**
|
||||||
|
* Variables defined in this environment scope.
|
||||||
|
* Child environments override parent variables by name.
|
||||||
|
*/
|
||||||
|
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||||
|
|
||||||
|
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||||
|
|
||||||
|
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||||
|
|
||||||
|
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
|
||||||
|
|
||||||
|
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
|
||||||
|
|
||||||
|
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||||
|
|
||||||
|
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
|
||||||
|
|
||||||
|
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
|
||||||
|
|
||||||
|
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
|
||||||
|
/**
|
||||||
|
* Server URL (http for plaintext or https for secure)
|
||||||
|
*/
|
||||||
|
url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
||||||
|
|
||||||
|
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, };
|
||||||
|
|
||||||
|
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||||
|
|
||||||
|
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, };
|
||||||
|
|
||||||
|
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||||
|
|
||||||
|
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, };
|
||||||
|
|
||||||
|
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
|
||||||
|
|
||||||
|
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
|
||||||
|
|
||||||
|
export type PluginSource = "bundled" | "filesystem" | "registry";
|
||||||
|
|
||||||
|
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
|
||||||
|
|
||||||
|
export type ProxySettingAuth = { user: string, password: string, };
|
||||||
|
|
||||||
|
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, promptFeedback: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };
|
||||||
|
|
||||||
|
export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
|
||||||
|
|
||||||
|
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
|
||||||
|
|
||||||
|
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
|
||||||
|
|
||||||
|
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||||
|
|
||||||
|
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
|
||||||
|
|
||||||
|
export type WebsocketEventType = "binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
||||||
|
|
||||||
|
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: 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, settingRequestMessageSize: InheritedIntSetting, };
|
||||||
|
|
||||||
|
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, settingSendCookies: boolean, settingStoreCookies: boolean, };
|
||||||
|
|
||||||
|
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
||||||
crates/common/yaak-rpc-schema/bindings/gen_rpc.ts → crates-tauri/yaak-app-client/bindings/gen_rpc.ts
Generated
+6
-23
File diff suppressed because one or more lines are too long
+9
-27
@@ -1,37 +1,19 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type PluginUpdateInfo = { name: string; currentVersion: string; latestVersion: string };
|
export type GitWatchResult = { unlistenEvent: string, };
|
||||||
|
|
||||||
export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
|
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
|
||||||
|
|
||||||
export type UpdateInfo = {
|
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
|
||||||
replyEventId: string;
|
|
||||||
version: string;
|
|
||||||
downloaded: boolean;
|
|
||||||
/**
|
|
||||||
* How this update gets applied. Anything but `Integrated` means the app can't do it
|
|
||||||
* itself and the user is told how to update instead.
|
|
||||||
*/
|
|
||||||
install: UpdateInstall;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
export type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, };
|
||||||
* How an update can be applied to this install.
|
|
||||||
*/
|
|
||||||
export type UpdateInstall = "integrated" | "flatpak" | "manual";
|
|
||||||
|
|
||||||
export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction };
|
export type UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, };
|
||||||
|
|
||||||
export type UpdateResponseAction = "install" | "skip";
|
export type UpdateResponseAction = "install" | "skip";
|
||||||
|
|
||||||
export type YaakNotification = {
|
export type WatchResult = { unlistenEvent: string, };
|
||||||
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 };
|
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, };
|
||||||
|
|||||||
Generated
Generated
@@ -1,4 +1,4 @@
|
|||||||
// ts-rs owns bindings/index.ts and rewrites it on export. What remains here
|
// ts-rs owns bindings/index.ts and rewrites it on export, so this hand-written
|
||||||
// after the RPC schema moved to @yaakapp-internal/rpc-schema is the
|
// entry point is where the generated files come together.
|
||||||
// desktop-only surface: updater and notification types.
|
export * from "./bindings/gen_rpc";
|
||||||
export * from "./bindings/index";
|
export * from "./bindings/index";
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use crate::PluginContextExt;
|
||||||
|
use crate::error::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
|
||||||
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
|
use yaak_models::models::HttpRequestHeader;
|
||||||
|
use yaak_models::queries::workspaces::default_headers;
|
||||||
|
use yaak_plugins::events::GetThemesResponse;
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
use yaak_plugins::native_template_functions::{
|
||||||
|
decrypt_secure_template_function, encrypt_secure_template_function,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Extension trait for accessing the EncryptionManager from Tauri Manager types.
|
||||||
|
pub trait EncryptionManagerExt<'a, R> {
|
||||||
|
fn crypto(&'a self) -> State<'a, EncryptionManager>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R: Runtime, M: Manager<R>> EncryptionManagerExt<'a, R> for M {
|
||||||
|
fn crypto(&'a self) -> State<'a, EncryptionManager> {
|
||||||
|
self.state::<EncryptionManager>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cmd_decrypt_template<R: Runtime>(
|
||||||
|
window: WebviewWindow<R>,
|
||||||
|
template: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let encryption_manager = window.app_handle().state::<EncryptionManager>();
|
||||||
|
let plugin_context = window.plugin_context();
|
||||||
|
Ok(decrypt_secure_template_function(&encryption_manager, &plugin_context, template)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cmd_secure_template<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
window: WebviewWindow<R>,
|
||||||
|
template: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
|
let plugin_context = window.plugin_context();
|
||||||
|
Ok(encrypt_secure_template_function(
|
||||||
|
plugin_manager,
|
||||||
|
encryption_manager,
|
||||||
|
&plugin_context,
|
||||||
|
template,
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cmd_get_themes<R: Runtime>(
|
||||||
|
window: WebviewWindow<R>,
|
||||||
|
plugin_manager: State<'_, PluginManager>,
|
||||||
|
) -> Result<Vec<GetThemesResponse>> {
|
||||||
|
Ok(plugin_manager.get_themes(&window.plugin_context()).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cmd_enable_encryption<R: Runtime>(
|
||||||
|
window: WebviewWindow<R>,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
window.crypto().ensure_workspace_key(workspace_id)?;
|
||||||
|
window.crypto().reveal_workspace_key(workspace_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cmd_reveal_workspace_key<R: Runtime>(
|
||||||
|
window: WebviewWindow<R>,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
Ok(window.crypto().reveal_workspace_key(workspace_id)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
|
||||||
|
window: WebviewWindow<R>,
|
||||||
|
workspace_id: &str,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
window.crypto().set_human_key(workspace_id, key)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
|
||||||
|
window: WebviewWindow<R>,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
window.crypto().disable_encryption(workspace_id)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
|
||||||
|
default_headers()
|
||||||
|
}
|
||||||
@@ -41,9 +41,6 @@ pub enum Error {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
YaakError(#[from] yaak::Error),
|
YaakError(#[from] yaak::Error),
|
||||||
|
|
||||||
#[error(transparent)]
|
|
||||||
CommandError(#[from] yaak_commands::Error),
|
|
||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
|
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::error::{Error, Result};
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use log::{debug, error, warn};
|
use log::{debug, error, warn};
|
||||||
use notify::Watcher;
|
use notify::Watcher;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -9,11 +10,18 @@ use tauri::{AppHandle, Listener, Runtime};
|
|||||||
use tokio::select;
|
use tokio::select;
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
use ts_rs::TS;
|
||||||
use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
|
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);
|
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>(
|
pub(crate) async fn watch_git_worktree_status<R, F>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use crate::PluginContextExt;
|
use crate::PluginContextExt;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use crate::models_ext::QueryManagerExt;
|
||||||
use KeyAndValueRef::{Ascii, Binary};
|
use KeyAndValueRef::{Ascii, Binary};
|
||||||
use tauri::{Manager, Runtime, WebviewWindow};
|
use tauri::{Manager, Runtime, WebviewWindow};
|
||||||
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
||||||
@@ -20,6 +21,22 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String>
|
|||||||
entries
|
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>(
|
pub(crate) async fn build_metadata<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
|
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
|
||||||
use tokio::sync::watch::Receiver;
|
use tokio::sync::watch::Receiver;
|
||||||
use yaak::send::{ResponseBody, SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_http::manager::HttpConnectionManager;
|
use yaak_http::manager::HttpConnectionManager;
|
||||||
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
|
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
|
||||||
@@ -62,12 +62,6 @@ impl<R: Runtime> ResponseContext<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a send produced: the response, and where its body went.
|
|
||||||
pub struct SentHttpRequest {
|
|
||||||
pub response: HttpResponse,
|
|
||||||
pub body: ResponseBody,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_http_request<R: Runtime>(
|
pub async fn send_http_request<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
unrendered_request: &HttpRequest,
|
unrendered_request: &HttpRequest,
|
||||||
@@ -75,7 +69,7 @@ pub async fn send_http_request<R: Runtime>(
|
|||||||
environment: Option<Environment>,
|
environment: Option<Environment>,
|
||||||
cookie_jar: Option<CookieJar>,
|
cookie_jar: Option<CookieJar>,
|
||||||
cancelled_rx: &mut Receiver<bool>,
|
cancelled_rx: &mut Receiver<bool>,
|
||||||
) -> Result<SentHttpRequest> {
|
) -> Result<HttpResponse> {
|
||||||
send_http_request_with_context(
|
send_http_request_with_context(
|
||||||
window,
|
window,
|
||||||
unrendered_request,
|
unrendered_request,
|
||||||
@@ -96,7 +90,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
|||||||
cookie_jar: Option<CookieJar>,
|
cookie_jar: Option<CookieJar>,
|
||||||
cancelled_rx: &Receiver<bool>,
|
cancelled_rx: &Receiver<bool>,
|
||||||
plugin_context: &PluginContext,
|
plugin_context: &PluginContext,
|
||||||
) -> Result<SentHttpRequest> {
|
) -> Result<HttpResponse> {
|
||||||
let app_handle = window.app_handle().clone();
|
let app_handle = window.app_handle().clone();
|
||||||
let update_source = UpdateSource::from_window_label(window.label());
|
let update_source = UpdateSource::from_window_label(window.label());
|
||||||
let mut response_ctx =
|
let mut response_ctx =
|
||||||
@@ -116,7 +110,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(sent) => Ok(sent),
|
Ok(response) => Ok(response),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error = e.to_string();
|
let error = e.to_string();
|
||||||
let elapsed = start.elapsed().as_millis() as i32;
|
let elapsed = start.elapsed().as_millis() as i32;
|
||||||
@@ -129,12 +123,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
|||||||
}
|
}
|
||||||
r.error = Some(error);
|
r.error = Some(error);
|
||||||
});
|
});
|
||||||
// The send failed, so whatever body exists is the partial one
|
Ok(response_ctx.response().clone())
|
||||||
// already on disk under the response's id.
|
|
||||||
Ok(SentHttpRequest {
|
|
||||||
response: response_ctx.response().clone(),
|
|
||||||
body: ResponseBody::Stored,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +136,7 @@ async fn send_http_request_inner<R: Runtime>(
|
|||||||
cancelled_rx: &Receiver<bool>,
|
cancelled_rx: &Receiver<bool>,
|
||||||
plugin_context: &PluginContext,
|
plugin_context: &PluginContext,
|
||||||
response_ctx: &mut ResponseContext<R>,
|
response_ctx: &mut ResponseContext<R>,
|
||||||
) -> Result<SentHttpRequest> {
|
) -> Result<HttpResponse> {
|
||||||
let app_handle = window.app_handle().clone();
|
let app_handle = window.app_handle().clone();
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
@@ -176,6 +165,22 @@ async fn send_http_request_inner<R: Runtime>(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| GenericError(e.to_string()))?;
|
.map_err(|e| GenericError(e.to_string()))?;
|
||||||
|
|
||||||
Ok(SentHttpRequest { response: result.response, body: result.response_body })
|
Ok(result.response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use std::fs::read_to_string;
|
|||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use tauri::{Manager, Runtime, WebviewWindow};
|
use tauri::{Manager, Runtime, WebviewWindow};
|
||||||
use yaak::import::{self, ImportDataParams};
|
use yaak::import::{self, ImportDataParams};
|
||||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
|
||||||
use yaak_core::WorkspaceContext;
|
use yaak_core::WorkspaceContext;
|
||||||
use yaak_models::util::BatchUpsertResult;
|
use yaak_models::util::BatchUpsertResult;
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
@@ -14,25 +13,10 @@ use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
|||||||
pub(crate) async fn import_data<R: Runtime>(
|
pub(crate) async fn import_data<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
file_path: &str,
|
file_path: &str,
|
||||||
) -> Result<BatchUpsertResult> {
|
|
||||||
let contents = read_import_file(file_path)?;
|
|
||||||
import_contents(window, &contents).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn import_url<R: Runtime>(
|
|
||||||
window: &WebviewWindow<R>,
|
|
||||||
url: &str,
|
|
||||||
) -> Result<BatchUpsertResult> {
|
|
||||||
let contents = fetch_import_url(window, url).await?;
|
|
||||||
import_contents(window, &contents).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn import_contents<R: Runtime>(
|
|
||||||
window: &WebviewWindow<R>,
|
|
||||||
contents: &str,
|
|
||||||
) -> Result<BatchUpsertResult> {
|
) -> Result<BatchUpsertResult> {
|
||||||
let plugin_manager = window.state::<PluginManager>();
|
let plugin_manager = window.state::<PluginManager>();
|
||||||
let query_manager = window.db_manager();
|
let query_manager = window.db_manager();
|
||||||
|
let file = read_import_file(file_path)?;
|
||||||
let plugin_context = window.plugin_context();
|
let plugin_context = window.plugin_context();
|
||||||
let workspace_context = WorkspaceContext {
|
let workspace_context = WorkspaceContext {
|
||||||
workspace_id: window.workspace_id(),
|
workspace_id: window.workspace_id(),
|
||||||
@@ -46,57 +30,11 @@ async fn import_contents<R: Runtime>(
|
|||||||
plugin_manager: &plugin_manager,
|
plugin_manager: &plugin_manager,
|
||||||
plugin_context: &plugin_context,
|
plugin_context: &plugin_context,
|
||||||
workspace_context,
|
workspace_context,
|
||||||
contents,
|
contents: &file,
|
||||||
})
|
})
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
|
|
||||||
/// pipeline as a file on disk.
|
|
||||||
///
|
|
||||||
/// This uses Yaak's own API client, which follows the OS proxy but not the workspace's proxy,
|
|
||||||
/// client certificate, or certificate-validation settings. Requests are unauthenticated, so
|
|
||||||
/// specs behind auth must still be downloaded manually and imported as a file.
|
|
||||||
async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> Result<String> {
|
|
||||||
let url = normalize_import_url(url)?;
|
|
||||||
let app_version = window.app_handle().package_info().version.to_string();
|
|
||||||
let response = yaak_api_client(ApiClientKind::App, &app_version)?
|
|
||||||
.get(&url)
|
|
||||||
// The API client defaults to JSON, but specs are just as often YAML
|
|
||||||
.header("Accept", "*/*")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|err| Error::GenericError(format!("Failed to fetch {url}: {err}")))?;
|
|
||||||
|
|
||||||
let status = response.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(Error::GenericError(format!("Failed to fetch {url}: responded with {status}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
response
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_import_url(url: &str) -> Result<String> {
|
|
||||||
let url = url.trim();
|
|
||||||
if url.is_empty() {
|
|
||||||
return Err(Error::GenericError("Import URL must not be empty".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if url.starts_with("http://") || url.starts_with("https://") {
|
|
||||||
return Ok(url.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
match url.split_once("://") {
|
|
||||||
Some((scheme, _)) => {
|
|
||||||
Err(Error::GenericError(format!("Import URL must be http or https, but got {scheme}")))
|
|
||||||
}
|
|
||||||
None => Ok(format!("https://{url}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_import_file(file_path: &str) -> Result<String> {
|
fn read_import_file(file_path: &str) -> Result<String> {
|
||||||
read_to_string(file_path).map_err(|err| {
|
read_to_string(file_path).map_err(|err| {
|
||||||
if err.kind() == ErrorKind::InvalidData {
|
if err.kind() == ErrorKind::InvalidData {
|
||||||
@@ -133,22 +71,4 @@ mod tests {
|
|||||||
|
|
||||||
remove_file(path).expect("remove binary fixture");
|
remove_file(path).expect("remove binary fixture");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_import_url_defaults_to_https() {
|
|
||||||
assert_eq!(
|
|
||||||
normalize_import_url(" example.com/openapi.yaml ").unwrap(),
|
|
||||||
"https://example.com/openapi.yaml"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalize_import_url("http://example.com/openapi.yaml").unwrap(),
|
|
||||||
"http://example.com/openapi.yaml"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_import_url_rejects_other_schemes() {
|
|
||||||
assert!(normalize_import_url("file:///tmp/openapi.yaml").is_err());
|
|
||||||
assert!(normalize_import_url(" ").is_err());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user