mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-02 08:37:18 +02:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50cccf1d25 | ||
|
|
0aae516f3a | ||
|
|
b7b8ae5f94 | ||
|
|
6777003b70 | ||
|
|
426c6d5eb6 | ||
|
|
068afe325e | ||
|
|
cef1129d4b | ||
|
|
9a7bcf73bb | ||
|
|
aa76d501f0 | ||
|
|
ce8cb5e7bc | ||
|
|
66c13d7e13 | ||
|
|
388dd8815f | ||
|
|
cb295b7102 | ||
|
|
4e835cf7e8 | ||
|
|
2350689d7a | ||
|
|
4eebd606ef | ||
|
|
c4f96f3f11 | ||
|
|
71c217d3f0 | ||
|
|
d89831a84d | ||
|
|
3332ae263f | ||
|
|
df1fd864b2 | ||
|
|
a2d54ca774 | ||
|
|
36fec8b005 | ||
|
|
115615d994 | ||
|
|
538f782068 | ||
|
|
3f202ff664 | ||
|
|
131b7e5ab1 | ||
|
|
69083918f9 | ||
|
|
4b2dcf9a1a | ||
|
|
569f552d79 | ||
|
|
33f32cccf6 | ||
|
|
7ca772347f | ||
|
|
b89c448345 | ||
|
|
2021df112a | ||
|
|
2d2a390bfd | ||
|
|
5cce23566a | ||
|
|
e99f6d2bc7 | ||
|
|
bea58b16b4 | ||
|
|
93fba4d9b4 | ||
|
|
b9071eafe0 | ||
|
|
778c74c635 | ||
|
|
0f434361a7 | ||
|
|
d27d11af7c | ||
|
|
1a19a06a23 | ||
|
|
07a9a6c6c0 | ||
|
|
e54240d579 | ||
|
|
8bca013ab4 | ||
|
|
10e962a0e6 | ||
|
|
78954e10c8 | ||
|
|
9eb7a001da | ||
|
|
6a02cbe525 | ||
|
|
6f91f76064 | ||
|
|
32e92d484b | ||
|
|
cdbbef34f8 | ||
|
|
4838353585 | ||
|
|
93001e3da7 | ||
|
|
b31c066717 | ||
|
|
5d1d24870a | ||
|
|
6f0d0ef275 | ||
|
|
85a9b2a908 | ||
|
|
f3f05502d1 | ||
|
|
2e0f7d1818 | ||
|
|
dc793181bb | ||
|
|
7dfa7e07e3 |
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
target
|
||||||
|
**/target
|
||||||
|
.claude
|
||||||
|
vendored
|
||||||
|
**/vendored
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
@@ -31,3 +31,7 @@ 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,6 +103,18 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
name: Release web image
|
||||||
|
|
||||||
|
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
|
||||||
|
# One image per architecture on its own native runner (emulating a Rust release build is hours),
|
||||||
|
# joined into one multi-arch tag at the end.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: [v*]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: ghcr.io/mountain-loop/yaak-web
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: github.repository == 'mountain-loop/yaak'
|
||||||
|
name: Build ${{ matrix.platform }}
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-22.04
|
||||||
|
arch: amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-22.04-arm
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile.web
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ runner.temp }}/digests"
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digest-${{ matrix.arch }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish manifest
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digest-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# `latest` follows a release tag, and a manual run that names a version — the way to
|
||||||
|
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
|
||||||
|
- name: Tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE }}
|
||||||
|
flavor: latest=false
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
|
||||||
|
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,format=short
|
||||||
|
|
||||||
|
- name: Create and push the manifest
|
||||||
|
working-directory: ${{ runner.temp }}/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect
|
||||||
|
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
||||||
Generated
+415
-153
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -2,9 +2,11 @@
|
|||||||
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",
|
||||||
@@ -12,6 +14,7 @@ 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",
|
||||||
@@ -19,14 +22,15 @@ 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",
|
||||||
@@ -65,15 +69,18 @@ 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,6 +44,25 @@ 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:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
|
||||||
|
# image serving both from one origin.
|
||||||
|
#
|
||||||
|
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
|
||||||
|
#
|
||||||
|
# See crates-server/yaak-web/README.md for the knobs.
|
||||||
|
|
||||||
|
FROM node:22-slim AS web
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY . .
|
||||||
|
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
|
||||||
|
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
|
||||||
|
RUN git init -q && git add -A \
|
||||||
|
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
|
||||||
|
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
|
||||||
|
# only to build a bundle for a deployment whose server lives somewhere else.
|
||||||
|
ARG VITE_YAAK_WEB_URL=""
|
||||||
|
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
|
||||||
|
ENV YAAK_TARGET=web
|
||||||
|
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
|
||||||
|
# backend, which this image has no reason to carry.
|
||||||
|
ENV SKIP_WASM_BUILD=1
|
||||||
|
RUN npm ci
|
||||||
|
RUN node_modules/.bin/vp -C apps/yaak-client build
|
||||||
|
|
||||||
|
FROM rust:1-bookworm AS server
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release -p yaak-web
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
|
||||||
|
COPY --from=web /app/dist/apps/yaak-client /srv
|
||||||
|
ENV YAAK_WEB_BIND=0.0.0.0:8080
|
||||||
|
EXPOSE 8080
|
||||||
|
USER nobody
|
||||||
|
# Overriding the command (dropping --serve) leaves the stateless send executor:
|
||||||
|
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
|
||||||
|
CMD ["yaak-web", "--serve", "/srv"]
|
||||||
@@ -1,19 +1,37 @@
|
|||||||
import type { SettingsTab } from "../components/Settings/Settings";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
import { 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";
|
||||||
|
|
||||||
// Allow tab with optional subtab (e.g., "plugins:installed")
|
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
|
||||||
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,3 +1,4 @@
|
|||||||
|
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";
|
||||||
@@ -24,7 +25,9 @@ export const switchWorkspace = createFastMutation<
|
|||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (inNewWindow) {
|
// A host without windows opens the workspace here instead. Refusing would
|
||||||
|
// strand the user on the workspace they were trying to leave.
|
||||||
|
if (inNewWindow && platform.capabilities.multiWindow) {
|
||||||
const location = router.buildLocation({
|
const location = router.buildLocation({
|
||||||
to: "/workspaces/$workspaceId",
|
to: "/workspaces/$workspaceId",
|
||||||
params: { workspaceId },
|
params: { workspaceId },
|
||||||
|
|||||||
@@ -67,7 +67,14 @@ 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={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
onClick={
|
||||||
|
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<{ url: string }>;
|
Component: ComponentType<{ bodyUrl: 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 url={bodyUrl.data} />;
|
return <Component bodyUrl={bodyUrl.data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||||
|
|||||||
@@ -1,56 +1,138 @@
|
|||||||
import { VStack } from "@yaakapp-internal/ui";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { useState } from "react";
|
import { Icon, VStack } from "@yaakapp-internal/ui";
|
||||||
|
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 { SelectFile } from "./SelectFile";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
importData: (filePath: string) => Promise<void>;
|
importFile: (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);
|
||||||
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
|
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
||||||
|
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 space={5} className="pb-4">
|
<VStack ref={ref} space={4} className="pb-4">
|
||||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||||
|
|
||||||
<VStack space={1}>
|
<button
|
||||||
<ul className="list-disc pl-5">
|
type="button"
|
||||||
<li>OpenAPI 3.0, 3.1</li>
|
onClick={handleSelectFile}
|
||||||
<li>Postman Collection v2, v2.1</li>
|
className={classNames(
|
||||||
<li>Insomnia v4+</li>
|
"w-full rounded-lg border border-dashed px-4 py-6",
|
||||||
<li>Swagger 2.0</li>
|
"flex flex-col items-center gap-1 text-center",
|
||||||
<li>
|
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
|
||||||
Curl commands <em className="text-text-subtle">(or paste into URL)</em>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</VStack>
|
|
||||||
<VStack space={2}>
|
|
||||||
<SelectFile
|
|
||||||
filePath={filePath ?? null}
|
|
||||||
onChange={({ filePath }) => setFilePath(filePath)}
|
|
||||||
/>
|
|
||||||
{filePath && (
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
disabled={!filePath || isLoading}
|
|
||||||
isLoading={isLoading}
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await importData(filePath);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading ? "Importing" : "Import"}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
||||||
|
{/* Fixed height so the region doesn't resize between the empty and selected states */}
|
||||||
|
<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}>
|
||||||
|
<PlainInput
|
||||||
|
label="Or enter a file path or URL"
|
||||||
|
size="sm"
|
||||||
|
placeholder="https://example.com/openapi.json"
|
||||||
|
defaultValue={source ?? ""}
|
||||||
|
forceUpdateKey={String(forceUpdateKey)}
|
||||||
|
onChange={setSource}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
disabled={trimmedSource === "" || isLoading}
|
||||||
|
isLoading={isLoading}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleImport}
|
||||||
|
>
|
||||||
|
{isLoading ? "Importing" : "Import"}
|
||||||
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import type {
|
|||||||
Folder,
|
Folder,
|
||||||
GrpcRequest,
|
GrpcRequest,
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
|
HttpVersion,
|
||||||
InheritedBoolSetting,
|
InheritedBoolSetting,
|
||||||
|
InheritedHttpVersionSetting,
|
||||||
InheritedIntSetting,
|
InheritedIntSetting,
|
||||||
WebsocketRequest,
|
WebsocketRequest,
|
||||||
Workspace,
|
Workspace,
|
||||||
@@ -13,6 +15,7 @@ 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,
|
||||||
@@ -21,6 +24,7 @@ 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,
|
||||||
@@ -38,37 +42,21 @@ interface Props {
|
|||||||
model: ModelWithSettings;
|
model: ModelWithSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelWithSettings =
|
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||||
| Workspace
|
|
||||||
| Folder
|
|
||||||
| HttpRequest
|
|
||||||
| WebsocketRequest
|
|
||||||
| GrpcRequest;
|
|
||||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||||
type ModelWithTlsSettings =
|
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||||
| Workspace
|
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
|
||||||
| Folder
|
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
|
||||||
| 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 = {
|
||||||
@@ -78,10 +66,7 @@ type MessageSizeSettingsPatch = {
|
|||||||
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ModelSettingsEditor({
|
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
||||||
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);
|
||||||
@@ -154,12 +139,26 @@ export function ModelSettingsEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{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
|
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
|
||||||
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
|
|
||||||
>
|
|
||||||
<BooleanSettingRow
|
<BooleanSettingRow
|
||||||
settingDefinition={SETTING_SEND_COOKIES}
|
settingDefinition={SETTING_SEND_COOKIES}
|
||||||
setting={model.settingSendCookies}
|
setting={model.settingSendCookies}
|
||||||
@@ -195,7 +194,7 @@ export function ModelSettingsEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
export function countOverriddenSettings(model: ModelWithSettings) {
|
||||||
const settings: (BooleanSetting | IntegerSetting)[] = [];
|
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
|
||||||
|
|
||||||
if (modelSupportsCookieSettings(model)) {
|
if (modelSupportsCookieSettings(model)) {
|
||||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
||||||
@@ -204,22 +203,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
|
|||||||
settings.push(model.settingValidateCertificates);
|
settings.push(model.settingValidateCertificates);
|
||||||
|
|
||||||
if (modelSupportsHttpSettings(model)) {
|
if (modelSupportsHttpSettings(model)) {
|
||||||
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
settings.push(
|
||||||
|
model.settingFollowRedirects,
|
||||||
|
model.settingRequestTimeout,
|
||||||
|
model.settingHttpVersion,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelSupportsMessageSizeSettings(model)) {
|
if (modelSupportsMessageSizeSettings(model)) {
|
||||||
settings.push(model.settingRequestMessageSize);
|
settings.push(model.settingRequestMessageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
return settings.filter(
|
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
||||||
(setting) => isInheritedSetting(setting) && setting.enabled === true,
|
.length;
|
||||||
).length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchCookieSettings(
|
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
||||||
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>);
|
||||||
@@ -232,10 +231,7 @@ function patchCookieSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchHttpSettings(
|
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
||||||
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>);
|
||||||
@@ -246,10 +242,7 @@ function patchHttpSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchTlsSettings(
|
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
|
||||||
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>);
|
||||||
@@ -280,21 +273,15 @@ function patchMessageSizeSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsHttpSettings(
|
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithHttpSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsCookieSettings(
|
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithCookieSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsTlsSettings(
|
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithTlsSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,11 +304,7 @@ 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
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
? overridden
|
|
||||||
? setting.value
|
|
||||||
: inheritedValue
|
|
||||||
: setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
@@ -352,6 +335,63 @@ 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,
|
||||||
@@ -365,18 +405,11 @@ 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
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
? overridden
|
|
||||||
? setting.value
|
|
||||||
: inheritedValue
|
|
||||||
: setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
<SettingRow
|
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
>
|
|
||||||
<NumberUnitInput
|
<NumberUnitInput
|
||||||
name={settingDefinition.modelKey}
|
name={settingDefinition.modelKey}
|
||||||
label={settingDefinition.title}
|
label={settingDefinition.title}
|
||||||
@@ -429,20 +462,13 @@ 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
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
? 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
|
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
>
|
|
||||||
<MessageSizeInput
|
<MessageSizeInput
|
||||||
name={settingDefinition.modelKey}
|
name={settingDefinition.modelKey}
|
||||||
label={settingDefinition.title}
|
label={settingDefinition.title}
|
||||||
@@ -567,13 +593,18 @@ 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,
|
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
|
||||||
) {
|
) {
|
||||||
for (const ancestor of ancestors) {
|
for (const ancestor of ancestors) {
|
||||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
|
||||||
if (isInheritedSetting(setting)) {
|
if (isInheritedSetting(setting)) {
|
||||||
if (setting.enabled === true) {
|
if (setting.enabled === true) {
|
||||||
return setting.value;
|
return setting.value;
|
||||||
@@ -589,6 +620,7 @@ function resolveInheritedValue(
|
|||||||
type WorkspaceSettings = Pick<
|
type WorkspaceSettings = Pick<
|
||||||
Workspace,
|
Workspace,
|
||||||
| "settingFollowRedirects"
|
| "settingFollowRedirects"
|
||||||
|
| "settingHttpVersion"
|
||||||
| "settingRequestMessageSize"
|
| "settingRequestMessageSize"
|
||||||
| "settingRequestTimeout"
|
| "settingRequestTimeout"
|
||||||
| "settingSendCookies"
|
| "settingSendCookies"
|
||||||
@@ -598,14 +630,12 @@ type WorkspaceSettings = Pick<
|
|||||||
|
|
||||||
type BooleanWorkspaceSettingKey = Exclude<
|
type BooleanWorkspaceSettingKey = Exclude<
|
||||||
keyof WorkspaceSettings,
|
keyof WorkspaceSettings,
|
||||||
"settingRequestTimeout" | "settingRequestMessageSize"
|
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function formatMegabytes(bytes: number) {
|
function formatMegabytes(bytes: number) {
|
||||||
const megabytes = bytes / BYTES_PER_MB;
|
const megabytes = bytes / BYTES_PER_MB;
|
||||||
return Number.isInteger(megabytes)
|
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||||
? `${megabytes}`
|
|
||||||
: megabytes.toFixed(3).replace(/\.?0+$/, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMegabytes(value: string) {
|
function parseMegabytes(value: string) {
|
||||||
@@ -626,9 +656,5 @@ 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 (
|
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
|
||||||
Number.isFinite(megabytes) &&
|
|
||||||
megabytes >= 0 &&
|
|
||||||
megabytes <= MAX_MESSAGE_SIZE_MB
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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";
|
||||||
@@ -20,6 +19,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,25 +43,19 @@ 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({ hide }: Props) {
|
export default function Settings({ tab, 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] = tabFromQuery?.split(":") ?? [];
|
const [mainTab, subtab] = tab?.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
|
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
|
||||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
// 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) {
|
if (hide == null) await platform.window.close();
|
||||||
// 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 (
|
||||||
@@ -90,7 +85,7 @@ export default function Settings({ hide }: Props) {
|
|||||||
)}
|
)}
|
||||||
<Tabs
|
<Tabs
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
defaultValue={mainTab || tabFromQuery}
|
defaultValue={mainTab}
|
||||||
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,12 +36,16 @@ 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 = {
|
||||||
@@ -66,6 +70,12 @@ 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"];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -502,9 +512,15 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.keepOpenOnSelect) handleCloseAll();
|
if (!item.keepOpenOnSelect) {
|
||||||
|
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, setSelectedIndex],
|
[handleCloseAll, handleClose, isSubmenu, setSelectedIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => {
|
useImperativeHandle(ref, () => {
|
||||||
@@ -629,7 +645,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) {
|
if (item.submenu && item.submenuTrigger !== "button") {
|
||||||
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 });
|
||||||
@@ -648,9 +664,11 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
clearTimeout(submenuTimeoutRef.current);
|
clearTimeout(submenuTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.submenu && !item.submenuOpenOnClick) {
|
if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
|
||||||
setActiveSubmenu({ item, parent });
|
setActiveSubmenu({ item, parent });
|
||||||
} else if (activeSubmenu) {
|
} else if (activeSubmenu && activeSubmenu.item !== item) {
|
||||||
|
// 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) {
|
||||||
@@ -776,6 +794,7 @@ 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>
|
||||||
@@ -797,6 +816,7 @@ 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}
|
||||||
@@ -868,6 +888,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -879,6 +900,7 @@ function MenuItem({
|
|||||||
onHover,
|
onHover,
|
||||||
item,
|
item,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onOpenSubmenu,
|
||||||
isParentOfActiveSubmenu,
|
isParentOfActiveSubmenu,
|
||||||
...props
|
...props
|
||||||
}: MenuItemProps) {
|
}: MenuItemProps) {
|
||||||
@@ -914,19 +936,22 @@ function MenuItem({
|
|||||||
e.currentTarget.focus();
|
e.currentTarget.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const rightSlot = item.submenu ? (
|
const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
|
||||||
<Icon icon="chevron_right" color="secondary" />
|
|
||||||
) : (
|
|
||||||
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
const rightSlot =
|
||||||
|
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={handleMouseEnter}
|
onMouseEnter={hasButtonSubmenu ? undefined : handleMouseEnter}
|
||||||
onMouseLeave={(e) => e.currentTarget.blur()}
|
onMouseLeave={hasButtonSubmenu ? undefined : (e) => e.currentTarget.blur()}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -947,6 +972,7 @@ 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!",
|
||||||
@@ -959,6 +985,52 @@ 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,9 +1,29 @@
|
|||||||
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 { parse as jsonLintParse } from "@prantlf/jsonlint";
|
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
||||||
|
|
||||||
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;
|
||||||
@@ -11,34 +31,28 @@ interface JsonLintOptions {
|
|||||||
|
|
||||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||||
return (view: EditorView): Diagnostic[] => {
|
return (view: EditorView): Diagnostic[] => {
|
||||||
try {
|
const doc = view.state.doc.toString();
|
||||||
const doc = view.state.doc.toString();
|
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||||
const 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 line = location?.start?.line;
|
const errors: ParseError[] = [];
|
||||||
// const column = location?.start?.column;
|
parse(escapedDoc, errors, {
|
||||||
if (err.location.start.offset) {
|
allowTrailingComma: options?.allowTrailingCommas ?? false,
|
||||||
return [
|
disallowComments: !(options?.allowComments ?? true),
|
||||||
{
|
});
|
||||||
from: err.location.start.offset,
|
|
||||||
to: err.location.start.offset,
|
// Later errors are mostly consequences of the first one, so only that one is shown
|
||||||
severity: "error",
|
const error = errors[0];
|
||||||
message: err.message,
|
if (error == null) return [];
|
||||||
},
|
|
||||||
];
|
return [
|
||||||
}
|
{
|
||||||
}
|
from: error.offset,
|
||||||
return [];
|
to: error.offset + error.length,
|
||||||
|
severity: "error",
|
||||||
|
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
|
||||||
|
},
|
||||||
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
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({
|
||||||
@@ -16,15 +34,31 @@ 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,4 +1,5 @@
|
|||||||
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";
|
||||||
@@ -11,6 +12,7 @@ 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";
|
||||||
@@ -18,6 +20,7 @@ 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"> & {
|
||||||
@@ -28,6 +31,10 @@ 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.
|
||||||
@@ -38,9 +45,41 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>("graphQLAutoIntrospectDisabled", {});
|
>("graphQLAutoIntrospectDisabled", {});
|
||||||
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {
|
const {
|
||||||
|
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;
|
||||||
@@ -160,14 +199,37 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
...((schema != null
|
...((schema != null
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: "Clear",
|
label: "Clear Schema",
|
||||||
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: (
|
||||||
@@ -210,25 +272,33 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
type: "content",
|
type: "content",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hidden: schema == null,
|
// One refresh action for either source: re-read the file, or
|
||||||
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
// re-introspect the server.
|
||||||
leftSlot: <Icon icon="book_open_text" />,
|
label: "Reload Schema",
|
||||||
onSelect: () => {
|
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||||
setGraphqlDocStateAtomValue((v) => ({
|
keepOpenOnSelect: true,
|
||||||
...v,
|
// Failures surface through the hook's error state either way.
|
||||||
[request.id]: isDocOpen ? undefined : null,
|
onSelect: async () => {
|
||||||
}));
|
if (filePath != null) await reloadFromFile();
|
||||||
|
else await refetch();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Introspect Schema",
|
label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
|
||||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
leftSlot: <Icon icon="import" />,
|
||||||
keepOpenOnSelect: true,
|
onSelect: handleLoadFromFile,
|
||||||
onSelect: refetch,
|
|
||||||
},
|
},
|
||||||
{ type: "separator", label: "Setting" },
|
|
||||||
{
|
{
|
||||||
label: "Automatic Introspection",
|
hidden: filePath == null,
|
||||||
|
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({
|
||||||
@@ -261,6 +331,29 @@ 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,
|
||||||
@@ -272,6 +365,11 @@ 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 the host resolved, for a body it already stored. */
|
/** A URL for the body the host already stored. */
|
||||||
url?: string;
|
bodyUrl?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioViewer({ url, data, mimeType }: Props) {
|
export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (url) {
|
if (bodyUrl) {
|
||||||
setSrc(url);
|
setSrc(bodyUrl);
|
||||||
} 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({ url, data, mimeType }: Props) {
|
|||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [url, data, mimeType]);
|
}, [bodyUrl, 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} />;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test, vi } from "vite-plus/test";
|
||||||
|
import { CsvViewerInner } from "./CsvViewer";
|
||||||
|
|
||||||
|
vi.mock("@yaakapp-internal/ui", () => ({
|
||||||
|
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
|
||||||
|
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
|
||||||
|
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||||
|
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
|
||||||
|
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||||
|
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CsvViewer", () => {
|
||||||
|
test("renders columns that extend beyond the first row", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<CsvViewerInner
|
||||||
|
text={[
|
||||||
|
"startDate,2026-02-03T00:00-03:00",
|
||||||
|
"endDate,2026-02-03T23:59:59-03:00",
|
||||||
|
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
|
||||||
|
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
|
||||||
|
].join("\n")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("ID de usuario");
|
||||||
|
expect(markup).toContain("42041");
|
||||||
|
expect(markup.match(/<td>/g)).toHaveLength(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,27 +26,33 @@ export function CsvViewer({ text, className }: Props) {
|
|||||||
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
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<Record<string, string>>(text, { header: true, skipEmptyLines: true });
|
return Papa.parse<string[]>(text, { 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>
|
||||||
{parsed.meta.fields?.map((field) => (
|
{columnIndexes.map((columnIndex) => (
|
||||||
<TableHeaderCell key={field}>{field}</TableHeaderCell>
|
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{parsed.data.map((row, i) => (
|
{rows.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}>
|
||||||
{parsed.meta.fields?.map((key) => (
|
{row.map((cell, columnIndex) => (
|
||||||
<TableCell key={key}>{row[key] ?? ""}</TableCell>
|
// oxlint-disable-next-line react/no-array-index-key
|
||||||
|
<TableCell key={columnIndex}>{cell}</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { useMemo, useState } from "react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
||||||
import { useResponseBodyText } from "../../hooks/useResponseBodyText";
|
import { responseBodyTextQuery, 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";
|
||||||
@@ -52,30 +54,25 @@ interface HttpTextViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
||||||
const [currentFilter, setCurrentFilter] = useState<string | null>(null);
|
const queryClient = useQueryClient();
|
||||||
const filteredBody = useResponseBodyText({ response, filter: currentFilter });
|
const filter = useResponseFilter({
|
||||||
|
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={[
|
||||||
@@ -98,7 +95,12 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
|
|||||||
className="border !border-border-subtle"
|
className="border !border-border-subtle"
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
onFilter={filterCallback}
|
filter={filter}
|
||||||
|
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 the host resolved, for a body it already stored. */
|
/** A URL for the body the host already stored. */
|
||||||
url: string;
|
bodyUrl: 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 url = "url" in props ? props.url : null;
|
const bodyUrl = "bodyUrl" in props ? props.bodyUrl : null;
|
||||||
const data = "data" in props ? props.data : null;
|
const data = "data" in props ? props.data : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (url != null) {
|
if (bodyUrl != null) {
|
||||||
setSrc(url);
|
setSrc(bodyUrl);
|
||||||
} 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);
|
||||||
}
|
}
|
||||||
}, [url, data, mimeType]);
|
}, [bodyUrl, data, mimeType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ fireAndForget(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** A URL the host resolved, for a body it already stored. */
|
/** A URL for the body the host already stored. */
|
||||||
url?: string;
|
bodyUrl?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ const options = {
|
|||||||
standardFontDataUrl: "/standard_fonts/",
|
standardFontDataUrl: "/standard_fonts/",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PdfViewer({ url, data }: Props) {
|
export function PdfViewer({ bodyUrl, 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({ url, 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 (url) {
|
if (bodyUrl) {
|
||||||
return url;
|
return bodyUrl;
|
||||||
}
|
}
|
||||||
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({ url, data }: Props) {
|
|||||||
return { data: new Uint8Array(data) };
|
return { data: new Uint8Array(data) };
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [url, data]);
|
}, [bodyUrl, data]);
|
||||||
|
|
||||||
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
||||||
setNumPages(nextNumPages);
|
setNumPages(nextNumPages);
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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,14 +1,15 @@
|
|||||||
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 { createGlobalState } from "react-use";
|
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||||
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];
|
||||||
|
|
||||||
@@ -16,57 +17,45 @@ 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;
|
||||||
onFilter?: (filter: string) => {
|
filter?: ResponseFilterApi;
|
||||||
|
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,
|
||||||
onFilter,
|
filter,
|
||||||
|
filterResult,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const filterKey = filterStateKey ?? stateKey;
|
const canFilter =
|
||||||
const [filterTextMap, setFilterTextMap] = useFilterText();
|
filter != null && (language === "json" || language === "xml" || language === "html");
|
||||||
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
|
const isSearching = filter?.isSearching ?? false;
|
||||||
const debouncedFilterText = useDebouncedValue(filterText);
|
const appliedFilter = filter?.appliedFilter ?? null;
|
||||||
const setFilterText = useCallback(
|
const resultError = filterResult?.error ?? false;
|
||||||
(v: string | null) => {
|
|
||||||
if (!filterKey) return;
|
const handleFilterKeyDown = useCallback(
|
||||||
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
|
(e: KeyboardEvent) => {
|
||||||
|
if (filter == null) return;
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
filter.toggleSearch();
|
||||||
|
} else if (e.key === "Enter" && filter.filterText != null) {
|
||||||
|
filter.applyFilter(filter.filterText);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[filterKey, setFilterTextMap],
|
[filter],
|
||||||
);
|
);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -76,8 +65,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={filterKey ?? "filter"}
|
key={filter.stateKey ?? "filter"}
|
||||||
validate={!filteredResponse.error}
|
validate={!resultError}
|
||||||
hideLabel
|
hideLabel
|
||||||
autoFocus
|
autoFocus
|
||||||
containerClassName="bg-surface"
|
containerClassName="bg-surface"
|
||||||
@@ -85,39 +74,62 @@ 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={filterText}
|
defaultValue={filter.filterText}
|
||||||
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
|
forceUpdateKey={filter.filterUpdateKey}
|
||||||
onChange={setFilterText}
|
onKeyDown={handleFilterKeyDown}
|
||||||
stateKey={filterKey ? `filter.${filterKey}` : null}
|
onChange={filter.setFilterText}
|
||||||
|
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,
|
||||||
filterKey,
|
filter,
|
||||||
filterText,
|
filterResult?.isPending,
|
||||||
filteredResponse.error,
|
resultError,
|
||||||
filteredResponse.isPending,
|
|
||||||
isSearching,
|
isSearching,
|
||||||
language,
|
language,
|
||||||
setFilterText,
|
handleFilterKeyDown,
|
||||||
toggleSearch,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
||||||
@@ -126,11 +138,11 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body: string;
|
let body: string;
|
||||||
if (isSearching && filterText?.length > 0) {
|
if (appliedFilter) {
|
||||||
if (filteredResponse.error) {
|
if (resultError) {
|
||||||
body = "";
|
body = "";
|
||||||
} else {
|
} else {
|
||||||
body = filteredResponse.data != null ? filteredResponse.data : "";
|
body = filterResult?.data != null ? filterResult.data : "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
body = formattedBody;
|
body = formattedBody;
|
||||||
@@ -143,15 +155,61 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Editor
|
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||||
readOnly
|
{appliedFilter && filter != null ? (
|
||||||
className={className}
|
<AppliedFilterBar
|
||||||
defaultValue={body}
|
filter={appliedFilter}
|
||||||
language={language}
|
error={resultError}
|
||||||
actions={actions}
|
onClear={() => filter.replaceFilter("")}
|
||||||
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 the host resolved, for a body it already stored. */
|
/** A URL for the body the host already stored. */
|
||||||
url?: string;
|
bodyUrl?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoViewer({ url, data, mimeType }: Props) {
|
export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (url) {
|
if (bodyUrl) {
|
||||||
setSrc(url);
|
setSrc(bodyUrl);
|
||||||
} 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({ url, data, mimeType }: Props) {
|
|||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [url, data, mimeType]);
|
}, [bodyUrl, 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} />;
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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,9 +112,12 @@ 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: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
|
const merged: Partial<Record<HotkeyAction, string[]>> = {};
|
||||||
|
for (const action of hotkeyActions) {
|
||||||
|
merged[action] = defaultHotkeys[action];
|
||||||
|
}
|
||||||
for (const [action, keys] of Object.entries(customHotkeys)) {
|
for (const [action, keys] of Object.entries(customHotkeys)) {
|
||||||
if (action in defaultHotkeys && Array.isArray(keys)) {
|
if (action in merged && Array.isArray(keys)) {
|
||||||
merged[action as HotkeyAction] = keys;
|
merged[action as HotkeyAction] = keys;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +125,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(): Record<HotkeyAction, string[]> {
|
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
|
||||||
return jotaiStore.get(hotkeysAtom);
|
return jotaiStore.get(hotkeysAtom);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,16 +168,25 @@ 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) => {
|
)
|
||||||
const scopeA = a.split(".")[0] || "";
|
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
|
||||||
const scopeB = b.split(".")[0] || "";
|
.sort((a, b) => {
|
||||||
if (scopeA !== scopeB) {
|
const scopeA = a.split(".")[0] || "";
|
||||||
return scopeA.localeCompare(scopeB);
|
const scopeB = b.split(".")[0] || "";
|
||||||
}
|
if (scopeA !== scopeB) {
|
||||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
return scopeA.localeCompare(scopeB);
|
||||||
});
|
}
|
||||||
|
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||||
|
});
|
||||||
|
|
||||||
export type HotKeyOptions = {
|
export type HotKeyOptions = {
|
||||||
enable?: boolean | (() => boolean);
|
enable?: boolean | (() => boolean);
|
||||||
@@ -333,7 +345,9 @@ 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") {
|
||||||
labelParts.push("↩");
|
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
|
||||||
|
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
|
||||||
|
labelParts.push("↩︎");
|
||||||
} else if (p === "Tab") {
|
} else if (p === "Tab") {
|
||||||
labelParts.push("⇥");
|
labelParts.push("⇥");
|
||||||
} else if (p === "Backspace") {
|
} else if (p === "Backspace") {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
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, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, 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";
|
||||||
|
|
||||||
@@ -31,6 +32,11 @@ 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", {
|
||||||
@@ -55,7 +61,7 @@ export function useIntrospectGraphQL(
|
|||||||
bodyType: "application/json",
|
bodyType: "application/json",
|
||||||
body: { text: introspectionRequestBody },
|
body: { text: introspectionRequestBody },
|
||||||
};
|
};
|
||||||
const response = await minPromiseMillis(
|
const { response, body } = await minPromiseMillis(
|
||||||
sendEphemeralRequest(args, activeEnvironment?.id ?? null),
|
sendEphemeralRequest(args, activeEnvironment?.id ?? null),
|
||||||
700,
|
700,
|
||||||
);
|
);
|
||||||
@@ -64,14 +70,16 @@ export function useIntrospectGraphQL(
|
|||||||
return setError(response.error);
|
return setError(response.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyText = await getResponseBodyText({ response, filter: null });
|
// The send hands back the only copy of the body — an unsaved response has
|
||||||
|
// 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 === null) {
|
if (bodyText === "") {
|
||||||
return setError("Empty body returned in response");
|
return setError("Empty body returned in response");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +99,109 @@ export function useIntrospectGraphQL(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
refetch().catch(console.error);
|
// A request pointed at a file gets its schema from that file. Introspecting
|
||||||
}, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]);
|
// here would overwrite it on the next URL edit.
|
||||||
|
if (filePath != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refetch().catch(console.error);
|
||||||
|
}, [
|
||||||
|
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;
|
||||||
@@ -113,7 +215,17 @@ export function useIntrospectGraphQL(
|
|||||||
}
|
}
|
||||||
}, [introspection.data?.content]);
|
}, [introspection.data?.content]);
|
||||||
|
|
||||||
return { schema, isLoading, error, refetch, clear };
|
return {
|
||||||
|
schema,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
clear,
|
||||||
|
loadFromFile,
|
||||||
|
reloadFromFile,
|
||||||
|
removeSchemaFile,
|
||||||
|
filePath,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function useIntrospectionResult(request: HttpRequest) {
|
function useIntrospectionResult(request: HttpRequest) {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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,6 +2,25 @@ 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,
|
||||||
@@ -11,14 +30,7 @@ export function useResponseBodyText({
|
|||||||
}) {
|
}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||||
queryKey: [
|
...responseBodyTextQuery({ response, filter }),
|
||||||
"response_body_text",
|
|
||||||
response.id,
|
|
||||||
response.updatedAt,
|
|
||||||
response.contentLength,
|
|
||||||
filter ?? "",
|
|
||||||
],
|
|
||||||
queryFn: () => getResponseBodyText({ response, filter }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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,
|
||||||
queryFn: () => (responseId == null ? null : platform.files.responseBodyUrl(responseId)),
|
// A response body is stored under the response's own id
|
||||||
|
queryFn: () => (responseId == null ? null : platform.blobs.url(responseId)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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,6 +25,10 @@ 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: (
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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,11 +2,9 @@ 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";
|
||||||
@@ -28,12 +26,9 @@ export const importData = createFastMutation({
|
|||||||
title: "Import Data",
|
title: "Import Data",
|
||||||
size: "sm",
|
size: "sm",
|
||||||
render: ({ hide }) => {
|
render: ({ hide }) => {
|
||||||
const importAndHide = async (filePath: string) => {
|
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
|
||||||
try {
|
try {
|
||||||
const didImport = await performImport(filePath);
|
await finishImport(await runImport());
|
||||||
if (!didImport) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve();
|
resolve();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
@@ -41,20 +36,23 @@ export const importData = createFastMutation({
|
|||||||
hide();
|
hide();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return <ImportDataDialog importData={importAndHide} />;
|
return (
|
||||||
|
<ImportDataDialog
|
||||||
|
importFile={(filePath) =>
|
||||||
|
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
|
||||||
|
}
|
||||||
|
importUrl={(url) =>
|
||||||
|
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function performImport(filePath: string): Promise<boolean> {
|
async function finishImport(imported: BatchUpsertResult): Promise<void> {
|
||||||
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({
|
||||||
@@ -103,6 +101,4 @@ async function performImport(filePath: string): Promise<boolean> {
|
|||||||
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, VStack } from "@yaakapp-internal/ui";
|
import { HStack, Icon, InlineCode, 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,9 +180,65 @@ 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 } = updateInfo;
|
const { version, replyEventId, downloaded, install } = updateInfo;
|
||||||
|
|
||||||
jotaiStore.set(updateAvailableAtom, { version, downloaded });
|
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -215,17 +271,7 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
|||||||
>
|
>
|
||||||
{downloaded ? "Install Now" : "Download and Install"}
|
{downloaded ? "Install Now" : "Download and Install"}
|
||||||
</ButtonInfiniteLoading>
|
</ButtonInfiniteLoading>
|
||||||
<Button
|
{whatsNewButton}
|
||||||
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,6 +5,7 @@ type ModelType = AnyModel["model"];
|
|||||||
type WorkspaceRequestSettings = Pick<
|
type WorkspaceRequestSettings = Pick<
|
||||||
Workspace,
|
Workspace,
|
||||||
| "settingFollowRedirects"
|
| "settingFollowRedirects"
|
||||||
|
| "settingHttpVersion"
|
||||||
| "settingRequestMessageSize"
|
| "settingRequestMessageSize"
|
||||||
| "settingRequestTimeout"
|
| "settingRequestTimeout"
|
||||||
| "settingSendCookies"
|
| "settingSendCookies"
|
||||||
@@ -18,9 +19,7 @@ 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<
|
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
|
||||||
K extends RequestSettingKey = RequestSettingKey,
|
|
||||||
> = {
|
|
||||||
defaultValue: WorkspaceRequestSettings[K];
|
defaultValue: WorkspaceRequestSettings[K];
|
||||||
description: string;
|
description: string;
|
||||||
modelKey: K;
|
modelKey: K;
|
||||||
@@ -46,8 +45,7 @@ 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:
|
description: "Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
||||||
"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",
|
||||||
@@ -57,13 +55,7 @@ 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: [
|
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
|
||||||
"workspace",
|
|
||||||
"folder",
|
|
||||||
"http_request",
|
|
||||||
"websocket_request",
|
|
||||||
"grpc_request",
|
|
||||||
],
|
|
||||||
title: "Validate TLS certificates",
|
title: "Validate TLS certificates",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,10 +67,17 @@ 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:
|
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
|
||||||
"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",
|
||||||
@@ -86,8 +85,7 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
|
|||||||
|
|
||||||
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
description:
|
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
|
||||||
"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,7 +68,8 @@ export async function getResponseBodySseSummary(
|
|||||||
export async function getResponseBodyBytes(
|
export async function getResponseBodyBytes(
|
||||||
response: HttpResponse,
|
response: HttpResponse,
|
||||||
): Promise<Uint8Array<ArrayBuffer> | null> {
|
): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||||
return platform.files.readResponseBody(response.id);
|
// A response body is stored under the response's own 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/tauri-client";
|
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,11 +1,12 @@
|
|||||||
import type { HttpRequest, HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpRequest } 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<HttpResponse> {
|
): Promise<EphemeralHttpResponse> {
|
||||||
// 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,7 +23,6 @@
|
|||||||
"@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",
|
||||||
@@ -54,6 +53,7 @@
|
|||||||
"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,14 +93,12 @@
|
|||||||
"@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.1",
|
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||||
"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.1"
|
"vite-plus": "^0.2.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,5 +14,6 @@ export const Route = createFileRoute("/workspaces/$workspaceId/settings")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
return <Settings />;
|
const { tab } = Route.useSearch();
|
||||||
|
return <Settings tab={tab} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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);
|
||||||
@@ -17,9 +16,38 @@ 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({
|
||||||
@@ -30,7 +58,6 @@ 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.1",
|
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||||
"vite-plus": "^0.2.1"
|
"vite-plus": "^0.2.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ 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,7 +181,11 @@ 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(_)) => {
|
||||||
match generate_plugin_metadata(&watch_root) {
|
// Assets are staged on every rebuild, so a changed asset or
|
||||||
|
// declaration is picked up without restarting.
|
||||||
|
let result = copy_build_assets(&watch_root)
|
||||||
|
.and_then(|()| generate_plugin_metadata(&watch_root));
|
||||||
|
match result {
|
||||||
Ok(()) => ui::success(&format!(
|
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()
|
||||||
@@ -408,6 +412,7 @@ 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}"))?;
|
||||||
@@ -498,6 +503,63 @@ fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
|
|||||||
.map_err(|e| format!("Failed to create build directory {}: {e}", build_dir.display()))
|
.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() }]),
|
||||||
@@ -750,7 +812,10 @@ describe("Example Plugin", () => {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{create_publish_archive, generate_plugin_metadata};
|
use super::{
|
||||||
|
copy_build_assets, create_publish_archive, generate_plugin_metadata,
|
||||||
|
prepare_build_output_dir,
|
||||||
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -795,6 +860,100 @@ 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,15 +435,12 @@ 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 method = method.unwrap_or_else(|| "GET".to_string());
|
let mut request = HttpRequest { workspace_id, name, url, ..Default::default() };
|
||||||
|
// Only override the method when one was given; `HttpRequest::default()` is the
|
||||||
let request = HttpRequest {
|
// single place the fallback ("GET") is defined.
|
||||||
workspace_id,
|
if let Some(method) = method {
|
||||||
name,
|
request.method = method.to_uppercase();
|
||||||
method: method.to_uppercase(),
|
}
|
||||||
url,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let created = ctx
|
let created = ctx
|
||||||
.db()
|
.db()
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ 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,5 +1,7 @@
|
|||||||
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;
|
||||||
@@ -11,7 +13,8 @@ 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::render::{render_grpc_request, render_http_request};
|
use yaak_models::render::{render_grpc_request, render_http_request};
|
||||||
|
use yaak::response_body::FileResponseBodyStore;
|
||||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
use yaak::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;
|
||||||
@@ -131,6 +134,7 @@ 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 },
|
||||||
) {
|
) {
|
||||||
@@ -223,7 +227,15 @@ async fn build_plugin_reply(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
|
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
|
||||||
SendHttpRequestResponse { http_response: result.response },
|
SendHttpRequestResponse {
|
||||||
|
http_response: result.response,
|
||||||
|
// Nothing saved this body, so the reply is the only
|
||||||
|
// place the plugin can get it.
|
||||||
|
body: result
|
||||||
|
.response_body
|
||||||
|
.returned_bytes()
|
||||||
|
.map(|b| BASE64_STANDARD.encode(b)),
|
||||||
|
},
|
||||||
)),
|
)),
|
||||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
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.25.0"
|
r2d2_sqlite = "0.32"
|
||||||
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
|
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
|
||||||
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
|
sea-query = { version = "1.0", 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"] }
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "yaak-server"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
publish = false
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "yaak-bridge"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
axum = { version = "0.7", features = ["ws", "macros"] }
|
|
||||||
charset = "0.1"
|
|
||||||
chrono = { workspace = true }
|
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
|
||||||
dirs = "6"
|
|
||||||
env_logger = "0.11"
|
|
||||||
eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client", version = "0.14.0" }
|
|
||||||
futures = "0.3"
|
|
||||||
include_dir = "0.7"
|
|
||||||
log = { workspace = true }
|
|
||||||
mime_guess = "2"
|
|
||||||
pretty_graphql = "0.2"
|
|
||||||
rand = "0.8"
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
serde_urlencoded = "0.7"
|
|
||||||
tokio = { workspace = true, features = [
|
|
||||||
"rt-multi-thread",
|
|
||||||
"macros",
|
|
||||||
"io-util",
|
|
||||||
"net",
|
|
||||||
"signal",
|
|
||||||
"time",
|
|
||||||
"sync",
|
|
||||||
] }
|
|
||||||
tower-http = { version = "0.6", features = ["cors", "fs", "trace"] }
|
|
||||||
yaak = { workspace = true }
|
|
||||||
yaak-common = { workspace = true }
|
|
||||||
yaak-core = { workspace = true }
|
|
||||||
yaak-crypto = { workspace = true }
|
|
||||||
yaak-http = { workspace = true }
|
|
||||||
yaak-models = { workspace = true }
|
|
||||||
yaak-plugins = { workspace = true }
|
|
||||||
yaak-rpc = { workspace = true }
|
|
||||||
yaak-sse = { workspace = true }
|
|
||||||
yaak-templates = { workspace = true }
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# Yaak Bridge
|
|
||||||
|
|
||||||
A headless binary that runs the real Yaak engine for a browser tab.
|
|
||||||
|
|
||||||
The tab is the unmodified Yaak UI. Everything a page cannot do — send an HTTP
|
|
||||||
request and see every response header, follow redirects, keep a cookie jar, run
|
|
||||||
the plugin runtime, read a response body off disk — happens in this process,
|
|
||||||
reached over local HTTP and a WebSocket.
|
|
||||||
|
|
||||||
This is the reason a browser Yaak can be credible at all. An in-page `fetch`
|
|
||||||
sender only ever sees the CORS-safelisted response headers: measured against
|
|
||||||
httpbin, a server that sent 8 headers yielded 2. Through the bridge the same
|
|
||||||
request yields all 8, plus the redirect chain, `Set-Cookie`, connection timings
|
|
||||||
and client certificates.
|
|
||||||
|
|
||||||
## Running it
|
|
||||||
|
|
||||||
Start the bridge:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p yaak-server -- --port 9444
|
|
||||||
```
|
|
||||||
|
|
||||||
It binds `127.0.0.1` only and prints a bearer token that every route requires.
|
|
||||||
|
|
||||||
Then point a frontend at it. In dev, run Vite separately and tell it where the
|
|
||||||
bridge is:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
YAAK_CLIENT_DEV_PORT=1472 VITE_YAAK_BRIDGE_URL=http://127.0.0.1:9444 npm run dev --workspace apps/yaak-client
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://localhost:1472/?bridgeToken=<token>`. The token is consumed from
|
|
||||||
the query, kept for the session, and stripped from the address bar. Without one
|
|
||||||
you get a small connect form.
|
|
||||||
|
|
||||||
To serve the built frontend from the bridge itself instead, so there is only one
|
|
||||||
process:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build --workspace apps/yaak-client
|
|
||||||
cargo run -p yaak-server -- --web-dir dist/apps/yaak-client
|
|
||||||
```
|
|
||||||
|
|
||||||
## Shape
|
|
||||||
|
|
||||||
| Route | What it carries |
|
|
||||||
| --- | --- |
|
|
||||||
| `POST /rpc` | The yaak-rpc envelope, the same one Tauri's `invoke` wraps on the desktop |
|
|
||||||
| `GET /events` | WebSocket. Server to client: `model_writes`, `stream_{id}`, toasts, plugin events. Client to server: the tab's location, and replies to prompts |
|
|
||||||
| `GET /responses/:id/body` | Response bodies, with Range support. Replaces reading `bodyPath` off disk |
|
|
||||||
| `GET /bridge/info` | Capabilities and the implemented command list |
|
|
||||||
|
|
||||||
Auth is a bearer token in the `Authorization` header, or a `token` query
|
|
||||||
parameter for the two requests the browser issues itself (the WebSocket, and
|
|
||||||
`<img src>`-style body loads). It is dev-grade and deliberately minimal: OTP
|
|
||||||
pairing and request encryption replace it, and `require_token` in `http.rs` is
|
|
||||||
where they go.
|
|
||||||
|
|
||||||
## Relationship to the other hosts
|
|
||||||
|
|
||||||
The engine crates under `crates/` are Tauri-free, and `crates-cli/yaak-cli`
|
|
||||||
already proved they run headless. This crate is structurally the CLI's
|
|
||||||
`CliContext` with an event hub attached — same `init_standalone` database, same
|
|
||||||
`PluginManager` over the same Node sidecar.
|
|
||||||
|
|
||||||
Two things are ported deliberately rather than invented:
|
|
||||||
|
|
||||||
- **Model writes** (`model_writes.rs`) keep the desktop's two paths: an
|
|
||||||
in-memory channel for writes this process made, and a poll of the
|
|
||||||
`model_changes` table so external writers — the CLI, the desktop app open on
|
|
||||||
the same database — show up live in the browser.
|
|
||||||
- **Plugin host requests** (`plugin_events.rs`) let `yaak::plugin_events`
|
|
||||||
answer everything that is only a database question, exactly as the CLI and the
|
|
||||||
desktop do. Only the host-specific arms differ, and where the CLI answers a
|
|
||||||
prompt from a TTY, the bridge round-trips it to the tab the way the desktop
|
|
||||||
round-trips it to a window.
|
|
||||||
|
|
||||||
## Known gaps
|
|
||||||
|
|
||||||
- **Settings is unreachable.** The desktop opens it via `cmd_new_child_window`.
|
|
||||||
A tab is one window, `multiWindow` is false, and this task did not add in-page
|
|
||||||
routing for it.
|
|
||||||
- **One tab at a time.** Model writes broadcast correctly to every connected
|
|
||||||
tab, so two tabs stay in sync for reads. What breaks is the session: the
|
|
||||||
tab's reported URL lives in a single slot, so with two tabs in different
|
|
||||||
workspaces a plugin's template render resolves against whichever attached
|
|
||||||
last. Prompts also broadcast, so a dialog raised by one tab appears in both.
|
|
||||||
- **No local files.** There is no file dialog, so request bodies from disk,
|
|
||||||
export, and save-response are unsupported. `cmd_import_data` is registered and
|
|
||||||
works, but only for a path typed by hand on the bridge's machine.
|
|
||||||
- **Command subset.** Roughly 40 of the desktop's 107 commands are implemented.
|
|
||||||
The rest return a structured "not supported on this host" error naming the
|
|
||||||
command; `UNSUPPORTED_COMMANDS` in `rpc/mod.rs` lists them.
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
//! The events channel: everything the browser tab would have received as a
|
|
||||||
//! Tauri window event.
|
|
||||||
//!
|
|
||||||
//! Two directions ride the same WebSocket. Server to client is a broadcast, so
|
|
||||||
//! `model_writes`, `stream_{id}` messages, toasts and plugin events all reach
|
|
||||||
//! the tab through one pipe. Client to server exists because some plugin host
|
|
||||||
//! requests are questions — a prompt round-trips through the UI and comes back
|
|
||||||
//! keyed by the originating event's id, exactly as the desktop app's
|
|
||||||
//! `call_frontend` does with window events.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use tokio::sync::{broadcast, mpsc};
|
|
||||||
|
|
||||||
/// One frame in either direction: a name and a JSON payload.
|
|
||||||
///
|
|
||||||
/// Deliberately the same shape both ways, and the same shape as the desktop's
|
|
||||||
/// event payloads, so `platform.listen` on the browser side hands the payload
|
|
||||||
/// to callers unwrapped.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct EventFrame {
|
|
||||||
pub event: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub payload: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct EventHub {
|
|
||||||
outbound: broadcast::Sender<EventFrame>,
|
|
||||||
/// Listeners waiting on a named event from the client, keyed by event name.
|
|
||||||
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A subscription to one named client-sent event. Deregisters on drop, so a
|
|
||||||
/// prompt that is never answered doesn't leak a listener for the process's life.
|
|
||||||
pub struct InboundSubscription {
|
|
||||||
event: String,
|
|
||||||
rx: mpsc::UnboundedReceiver<serde_json::Value>,
|
|
||||||
inbound: Arc<Mutex<HashMap<String, Vec<mpsc::UnboundedSender<serde_json::Value>>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InboundSubscription {
|
|
||||||
pub async fn recv(&mut self) -> Option<serde_json::Value> {
|
|
||||||
self.rx.recv().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for InboundSubscription {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let mut inbound = match self.inbound.lock() {
|
|
||||||
Ok(inbound) => inbound,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
if let Some(senders) = inbound.get_mut(&self.event) {
|
|
||||||
senders.retain(|tx| !tx.is_closed());
|
|
||||||
if senders.is_empty() {
|
|
||||||
inbound.remove(&self.event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventHub {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
// Bounded: a tab that stops reading gets dropped frames rather than
|
|
||||||
// growing the server's memory without limit. Model writes are the
|
|
||||||
// high-volume case (imports, bulk deletes) and they arrive in batches.
|
|
||||||
let (outbound, _) = broadcast::channel(1024);
|
|
||||||
Self { outbound, inbound: Arc::new(Mutex::new(HashMap::new())) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send an event to every connected tab. Fails silently when none is
|
|
||||||
/// connected, which is the normal state before a browser attaches.
|
|
||||||
pub fn emit<T: Serialize>(&self, event: impl Into<String>, payload: &T) {
|
|
||||||
let payload = match serde_json::to_value(payload) {
|
|
||||||
Ok(payload) => payload,
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to serialize event payload: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _ = self.outbound.send(EventFrame { event: event.into(), payload });
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<EventFrame> {
|
|
||||||
self.outbound.subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Listen for a named event sent *by* the client.
|
|
||||||
pub fn subscribe_inbound(&self, event: impl Into<String>) -> InboundSubscription {
|
|
||||||
let event = event.into();
|
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
|
||||||
let mut inbound = match self.inbound.lock() {
|
|
||||||
Ok(inbound) => inbound,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
inbound.entry(event.clone()).or_default().push(tx);
|
|
||||||
drop(inbound);
|
|
||||||
InboundSubscription { event, rx, inbound: Arc::clone(&self.inbound) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route a frame that arrived from a tab to whoever is waiting on it.
|
|
||||||
pub fn dispatch_inbound(&self, frame: EventFrame) {
|
|
||||||
let mut inbound = match self.inbound.lock() {
|
|
||||||
Ok(inbound) => inbound,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
let Some(senders) = inbound.get_mut(&frame.event) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
senders.retain(|tx| tx.send(frame.payload.clone()).is_ok());
|
|
||||||
if senders.is_empty() {
|
|
||||||
inbound.remove(&frame.event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
//! The front door: one HTTP surface for the browser tab.
|
|
||||||
//!
|
|
||||||
//! Three routes carry everything. `POST /rpc` is the yaak-rpc envelope, byte for
|
|
||||||
//! byte what the desktop puts inside Tauri's `invoke`. `GET /events` is the
|
|
||||||
//! WebSocket that replaces window events, in both directions. And
|
|
||||||
//! `GET /responses/:id/body` replaces reading `bodyPath` off disk, which a tab
|
|
||||||
//! cannot do.
|
|
||||||
|
|
||||||
use crate::events::EventFrame;
|
|
||||||
use crate::rpc::BridgeCtx;
|
|
||||||
use crate::session::SessionContext;
|
|
||||||
use crate::state::BridgeState;
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
|
||||||
use axum::extract::{Path, Query, Request, State};
|
|
||||||
use axum::http::{HeaderMap, StatusCode, header};
|
|
||||||
use axum::middleware::Next;
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use axum::routing::{get, post};
|
|
||||||
use axum::{Json, Router};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
|
||||||
use tower_http::cors::CorsLayer;
|
|
||||||
use yaak_rpc::{RpcRequest, RpcResponse, RpcRouter};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AppState {
|
|
||||||
pub state: Arc<BridgeState>,
|
|
||||||
pub router: Arc<RpcRouter<BridgeCtx>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_app(state: Arc<BridgeState>, router: Arc<RpcRouter<BridgeCtx>>) -> Router {
|
|
||||||
let app_state = AppState { state: state.clone(), router };
|
|
||||||
|
|
||||||
let api = Router::new()
|
|
||||||
.route("/bridge/info", get(bridge_info))
|
|
||||||
.route("/rpc", post(rpc_handler))
|
|
||||||
.route("/events", get(events_handler))
|
|
||||||
.route("/responses/:id/body", get(response_body))
|
|
||||||
.layer(axum::middleware::from_fn_with_state(state.clone(), require_token))
|
|
||||||
// The dev setup serves the frontend from Vite on another port, so the
|
|
||||||
// tab's origin is not the bridge's. Credentials never ride on cookies
|
|
||||||
// here — the token is explicit — so a permissive CORS layer is safe and
|
|
||||||
// is bounded by the token check that runs before it.
|
|
||||||
.layer(CorsLayer::permissive())
|
|
||||||
.with_state(app_state);
|
|
||||||
|
|
||||||
match std::env::var("YAAK_BRIDGE_WEB_DIR").ok() {
|
|
||||||
// Serving the built frontend makes the bridge a single process to run.
|
|
||||||
// `index.html` is the fallback because the router owns the paths.
|
|
||||||
Some(dir) => api.fallback_service(
|
|
||||||
tower_http::services::ServeDir::new(&dir)
|
|
||||||
.fallback(tower_http::services::ServeFile::new(format!("{dir}/index.html"))),
|
|
||||||
),
|
|
||||||
None => api,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Auth --
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct TokenQuery {
|
|
||||||
token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dev-grade bearer check on every route.
|
|
||||||
///
|
|
||||||
/// The header is the normal path. The query parameter exists because two of
|
|
||||||
/// these are opened by the browser itself — the WebSocket and the `<img src>`
|
|
||||||
/// pointing at a response body — and neither lets the page set headers.
|
|
||||||
///
|
|
||||||
/// This is the seam where OTP pairing and per-session keys go. It is not one
|
|
||||||
/// today: the token is a process-lifetime shared secret, and anything that can
|
|
||||||
/// read the tab's URL can read it.
|
|
||||||
async fn require_token(
|
|
||||||
State(state): State<Arc<BridgeState>>,
|
|
||||||
request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
let from_header = request
|
|
||||||
.headers()
|
|
||||||
.get(header::AUTHORIZATION)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
|
||||||
.map(|v| v.to_string());
|
|
||||||
|
|
||||||
let from_query = request
|
|
||||||
.uri()
|
|
||||||
.query()
|
|
||||||
.and_then(|q| serde_urlencoded::from_str::<TokenQuery>(q).ok())
|
|
||||||
.and_then(|q| q.token);
|
|
||||||
|
|
||||||
let presented = from_header.or(from_query);
|
|
||||||
|
|
||||||
match presented {
|
|
||||||
Some(token) if constant_time_eq(&token, &state.token) => next.run(request).await,
|
|
||||||
_ => (StatusCode::UNAUTHORIZED, "Invalid or missing bridge token").into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compares without returning early on the first differing byte, so a caller
|
|
||||||
/// can't learn the token one character at a time.
|
|
||||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
|
||||||
if a.len() != b.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Routes --
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct BridgeInfo {
|
|
||||||
name: String,
|
|
||||||
version: String,
|
|
||||||
capabilities: crate::state::BridgeCapabilities,
|
|
||||||
/// Commands this build implements. The browser host uses it to fail fast
|
|
||||||
/// with a clear message instead of waiting for a round trip.
|
|
||||||
commands: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn bridge_info(State(app): State<AppState>) -> Json<BridgeInfo> {
|
|
||||||
Json(BridgeInfo {
|
|
||||||
name: "Yaak Bridge".to_string(),
|
|
||||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
capabilities: app.state.capabilities.clone(),
|
|
||||||
commands: crate::rpc::implemented_commands(&app.router),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One envelope in, one out. Errors are carried inside the envelope, not as an
|
|
||||||
/// HTTP status, so the browser host can reject the caller's promise with the
|
|
||||||
/// backend's own message.
|
|
||||||
async fn rpc_handler(
|
|
||||||
State(app): State<AppState>,
|
|
||||||
Json(req): Json<RpcRequest>,
|
|
||||||
) -> Json<RpcResponse> {
|
|
||||||
let ctx = BridgeCtx { state: app.state.clone(), session: app.state.session.get() };
|
|
||||||
log::debug!("RPC {}", req.cmd);
|
|
||||||
let response = app.router.handle(req, &ctx).await;
|
|
||||||
if let RpcResponse::Error { error, .. } = &response {
|
|
||||||
log::warn!("RPC failed: {error}");
|
|
||||||
}
|
|
||||||
Json(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn events_handler(State(app): State<AppState>, ws: WebSocketUpgrade) -> Response {
|
|
||||||
ws.on_upgrade(move |socket| handle_events_socket(socket, app))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The tab's first frame reports who and where it is; everything after that is
|
|
||||||
/// a reply to something the server asked.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct AttachPayload {
|
|
||||||
label: String,
|
|
||||||
url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_events_socket(socket: WebSocket, app: AppState) {
|
|
||||||
use futures::{SinkExt, StreamExt};
|
|
||||||
|
|
||||||
let (mut sink, mut stream) = socket.split();
|
|
||||||
let mut outbound = app.state.events.subscribe();
|
|
||||||
|
|
||||||
// Server to client.
|
|
||||||
let send_task = tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match outbound.recv().await {
|
|
||||||
Ok(frame) => {
|
|
||||||
let Ok(text) = serde_json::to_string(&frame) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if sink.send(Message::Text(text)).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A tab that fell behind has missed writes, and the model store
|
|
||||||
// would be silently stale. Close instead, so a reconnect
|
|
||||||
// re-reads the workspace from scratch.
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
|
||||||
log::warn!("Events client lagged by {n} frames; closing so it resyncs");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Client to server.
|
|
||||||
let state = app.state.clone();
|
|
||||||
let recv_task = tokio::spawn(async move {
|
|
||||||
while let Some(Ok(message)) = stream.next().await {
|
|
||||||
let Message::Text(text) = message else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Ok(frame) = serde_json::from_str::<EventFrame>(&text) else {
|
|
||||||
log::warn!("Ignoring malformed event frame from browser");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// `bridge_attach` is the browser telling us what the desktop would
|
|
||||||
// have read off the window: its label and its current URL.
|
|
||||||
if frame.event == "bridge_attach" {
|
|
||||||
match serde_json::from_value::<AttachPayload>(frame.payload.clone()) {
|
|
||||||
Ok(attach) => {
|
|
||||||
log::info!("Browser attached: {} at {}", attach.label, attach.url);
|
|
||||||
state.session.set(SessionContext {
|
|
||||||
label: attach.label,
|
|
||||||
url: attach.url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => log::warn!("Bad bridge_attach payload: {e}"),
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.events.dispatch_inbound(frame);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
_ = send_task => {},
|
|
||||||
_ = recv_task => {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BodyQuery {
|
|
||||||
/// Present so the shared token extractor doesn't reject the request; the
|
|
||||||
/// value itself is checked in the middleware.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stream a response body, with Range support.
|
|
||||||
///
|
|
||||||
/// Keyed by response id rather than by path: the tab hands back a `bodyPath`
|
|
||||||
/// the backend gave it, and resolving that through the database means this
|
|
||||||
/// route can only ever serve a file the engine wrote, not an arbitrary path a
|
|
||||||
/// page asked for. Range matters because the video and audio viewers seek.
|
|
||||||
async fn response_body(
|
|
||||||
State(app): State<AppState>,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
Query(_q): Query<BodyQuery>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Response {
|
|
||||||
let 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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
//! Yaak Bridge — the local companion that runs the real Yaak engine for a
|
|
||||||
//! browser tab.
|
|
||||||
//!
|
|
||||||
//! The tab is the Yaak UI, unchanged. Everything it cannot do in a page —
|
|
||||||
//! sending an HTTP request and seeing every response header, following
|
|
||||||
//! redirects, keeping a cookie jar, running plugins, reading a response body
|
|
||||||
//! off disk — happens in this process, over a local HTTP and WebSocket
|
|
||||||
//! connection.
|
|
||||||
//!
|
|
||||||
//! Loopback only, and every route needs the token printed at startup.
|
|
||||||
|
|
||||||
mod events;
|
|
||||||
mod http;
|
|
||||||
mod model_writes;
|
|
||||||
mod plugin_events;
|
|
||||||
mod rpc;
|
|
||||||
mod session;
|
|
||||||
mod state;
|
|
||||||
|
|
||||||
use clap::Parser;
|
|
||||||
use rand::Rng;
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
const APP_ID: &str = "app.yaak.bridge";
|
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
|
||||||
#[command(name = "yaak-bridge", about = "Run the Yaak engine for a browser tab")]
|
|
||||||
struct Args {
|
|
||||||
/// Port to listen on. Loopback only, always.
|
|
||||||
#[arg(long, default_value_t = 9444, env = "YAAK_BRIDGE_PORT")]
|
|
||||||
port: u16,
|
|
||||||
|
|
||||||
/// Where the database, plugins and response bodies live.
|
|
||||||
#[arg(long, env = "YAAK_BRIDGE_DATA_DIR")]
|
|
||||||
data_dir: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Use a fixed token instead of generating one. For scripted dev loops.
|
|
||||||
#[arg(long, env = "YAAK_BRIDGE_TOKEN")]
|
|
||||||
token: Option<String>,
|
|
||||||
|
|
||||||
/// Where the frontend was built to. Serving it makes this the only process
|
|
||||||
/// to run; without it, point a Vite dev server at this bridge instead.
|
|
||||||
#[arg(long, env = "YAAK_BRIDGE_WEB_DIR")]
|
|
||||||
web_dir: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
|
||||||
|
|
||||||
let args = Args::parse();
|
|
||||||
|
|
||||||
let data_dir = args.data_dir.unwrap_or_else(default_data_dir);
|
|
||||||
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
|
||||||
eprintln!("Error: failed to create data dir {}: {e}", data_dir.display());
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(web_dir) = &args.web_dir {
|
|
||||||
// Read back by the router; keeping it in the environment avoids
|
|
||||||
// threading an option through every layer for a dev-mode convenience.
|
|
||||||
unsafe { std::env::set_var("YAAK_BRIDGE_WEB_DIR", web_dir) };
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = args.token.unwrap_or_else(generate_token);
|
|
||||||
let is_dev = cfg!(debug_assertions);
|
|
||||||
|
|
||||||
let mut state = state::BridgeState::new(data_dir.clone(), APP_ID, token.clone(), is_dev);
|
|
||||||
state.init_plugins().await;
|
|
||||||
let state = Arc::new(state);
|
|
||||||
|
|
||||||
let router = Arc::new(rpc::build_router());
|
|
||||||
let app = http::build_app(state.clone(), router);
|
|
||||||
|
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], args.port));
|
|
||||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
|
||||||
Ok(listener) => listener,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error: failed to bind {addr}: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let base = format!("http://127.0.0.1:{}", args.port);
|
|
||||||
println!();
|
|
||||||
println!(" Yaak Bridge listening on {base}");
|
|
||||||
println!(" Data dir: {}", data_dir.display());
|
|
||||||
println!(" Plugins: {}", if state.capabilities.plugins { "running" } else { "unavailable" });
|
|
||||||
println!();
|
|
||||||
if std::env::var("YAAK_BRIDGE_WEB_DIR").is_ok() {
|
|
||||||
println!(" Open: {base}/?bridgeToken={token}");
|
|
||||||
} else {
|
|
||||||
println!(" Token: {token}");
|
|
||||||
println!(" Open your dev server with ?bridgeToken={token}");
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
let shutdown_state = state.clone();
|
|
||||||
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
|
|
||||||
let _ = tokio::signal::ctrl_c().await;
|
|
||||||
log::info!("Shutting down");
|
|
||||||
shutdown_state.shutdown().await;
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(e) = server.await {
|
|
||||||
eprintln!("Error: server failed: {e}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_data_dir() -> PathBuf {
|
|
||||||
dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")).join("yaak-bridge")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A 256-bit random token, hex encoded. Per process, never written to disk.
|
|
||||||
fn generate_token() -> String {
|
|
||||||
let bytes: [u8; 32] = rand::thread_rng().r#gen();
|
|
||||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
//! Pushing model writes to the connected tab.
|
|
||||||
//!
|
|
||||||
//! A direct port of the desktop's two paths (see
|
|
||||||
//! crates-tauri/yaak-app-client/src/models_ext.rs), and for the same reason:
|
|
||||||
//! the in-memory channel is the fast path for writes this process made on a
|
|
||||||
//! client's behalf, while polling the `model_changes` table is what makes an
|
|
||||||
//! external writer — the CLI, a second bridge, the desktop app open on the same
|
|
||||||
//! database — show up live in the browser. Keeping both means the browser
|
|
||||||
//! behaves like the desktop rather than like a cache.
|
|
||||||
|
|
||||||
use crate::events::EventHub;
|
|
||||||
use chrono::Utc;
|
|
||||||
use log::error;
|
|
||||||
use std::sync::mpsc::Receiver;
|
|
||||||
use std::time::Duration;
|
|
||||||
use yaak_models::query_manager::QueryManager;
|
|
||||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
|
||||||
|
|
||||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
|
||||||
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
|
||||||
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
|
|
||||||
|
|
||||||
struct ModelChangeCursor {
|
|
||||||
created_at: String,
|
|
||||||
id: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModelChangeCursor {
|
|
||||||
fn from_launch_time() -> Self {
|
|
||||||
Self {
|
|
||||||
created_at: Utc::now().naive_utc().format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
|
|
||||||
id: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start(query_manager: &QueryManager, rx: Receiver<ModelPayload>, events: EventHub) {
|
|
||||||
if let Err(err) =
|
|
||||||
query_manager.connect().prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
|
|
||||||
{
|
|
||||||
error!("Failed to prune model_changes rows on startup: {err:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only stream writes that happen after this process started.
|
|
||||||
let cursor = ModelChangeCursor::from_launch_time();
|
|
||||||
let poll_query_manager = query_manager.clone();
|
|
||||||
let poll_events = events.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
run_model_change_poller(poll_query_manager, poll_events, cursor).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
// `init_standalone` hands back a std (blocking) receiver, so it gets a
|
|
||||||
// thread rather than a task.
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
while let Ok(payload) = rx.recv() {
|
|
||||||
let mut batch: Vec<ModelPayload> = Vec::new();
|
|
||||||
if matches!(payload.update_source, UpdateSource::Window { .. }) {
|
|
||||||
batch.push(payload);
|
|
||||||
}
|
|
||||||
// Coalesce anything already queued into the same frame.
|
|
||||||
while let Ok(next) = rx.try_recv() {
|
|
||||||
if matches!(next.update_source, UpdateSource::Window { .. }) {
|
|
||||||
batch.push(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if batch.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
events.emit("model_writes", &batch);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_model_change_poller(
|
|
||||||
query_manager: QueryManager,
|
|
||||||
events: EventHub,
|
|
||||||
mut cursor: ModelChangeCursor,
|
|
||||||
) {
|
|
||||||
loop {
|
|
||||||
while drain_model_changes_batch(&query_manager, &events, &mut cursor) {}
|
|
||||||
tokio::time::sleep(Duration::from_millis(MODEL_CHANGES_POLL_INTERVAL_MS)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain_model_changes_batch(
|
|
||||||
query_manager: &QueryManager,
|
|
||||||
events: &EventHub,
|
|
||||||
cursor: &mut ModelChangeCursor,
|
|
||||||
) -> bool {
|
|
||||||
let changes = match query_manager.connect().list_model_changes_since(
|
|
||||||
&cursor.created_at,
|
|
||||||
cursor.id,
|
|
||||||
MODEL_CHANGES_POLL_BATCH_SIZE,
|
|
||||||
) {
|
|
||||||
Ok(changes) => changes,
|
|
||||||
Err(err) => {
|
|
||||||
error!("Failed to poll model_changes rows: {err:?}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if changes.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fetched_count = changes.len();
|
|
||||||
let mut batch: Vec<ModelPayload> = Vec::with_capacity(fetched_count);
|
|
||||||
for change in changes {
|
|
||||||
cursor.created_at = change.created_at;
|
|
||||||
cursor.id = change.id;
|
|
||||||
|
|
||||||
// Window-sourced writes already went out on the in-memory fast path.
|
|
||||||
if matches!(change.payload.update_source, UpdateSource::Window { .. }) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
batch.push(change.payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One batch per drain so bulk writes don't flood the tab.
|
|
||||||
if !batch.is_empty() {
|
|
||||||
events.emit("model_writes", &batch);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetched_count == MODEL_CHANGES_POLL_BATCH_SIZE
|
|
||||||
}
|
|
||||||
@@ -1,582 +0,0 @@
|
|||||||
//! The bridge's plugin host.
|
|
||||||
//!
|
|
||||||
//! Same shape as the CLI's bridge (crates-cli/yaak-cli/src/plugin_events.rs):
|
|
||||||
//! subscribe to the plugin manager, let `handle_shared_plugin_event` answer
|
|
||||||
//! everything that is only a database question, and implement the rest here.
|
|
||||||
//!
|
|
||||||
//! Where it differs is that a UI is attached. The CLI answers a prompt from a
|
|
||||||
//! TTY and refuses when there isn't one; the bridge does what the desktop does
|
|
||||||
//! instead — pushes the event to the tab and waits for the reply keyed by the
|
|
||||||
//! event's id. Toasts, clipboard writes and external URLs go the same way,
|
|
||||||
//! because the browser is the only thing here that can show or do them.
|
|
||||||
|
|
||||||
use crate::events::EventHub;
|
|
||||||
use crate::session::SessionStore;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use yaak::plugin_events::{
|
|
||||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
|
||||||
};
|
|
||||||
use yaak::render::{render_grpc_request, render_http_request};
|
|
||||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
|
||||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
|
||||||
use yaak_http::manager::HttpConnectionManager;
|
|
||||||
use yaak_models::blob_manager::BlobManager;
|
|
||||||
use yaak_models::models::Environment;
|
|
||||||
use yaak_models::queries::any_request::AnyRequest;
|
|
||||||
use yaak_models::query_manager::QueryManager;
|
|
||||||
use yaak_models::render::make_vars_hashmap;
|
|
||||||
use yaak_models::util::UpdateSource;
|
|
||||||
use yaak_plugins::events::{
|
|
||||||
EmptyPayload, ErrorResponse, GetCookieValueResponse, InternalEvent, InternalEventPayload,
|
|
||||||
ListCookieNamesResponse, ListOpenWorkspacesResponse, PluginContext, PromptTextResponse,
|
|
||||||
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
|
|
||||||
TemplateRenderResponse, WindowInfoResponse, WorkspaceInfo,
|
|
||||||
};
|
|
||||||
use yaak_plugins::manager::PluginManager;
|
|
||||||
use yaak_plugins::plugin_handle::PluginHandle;
|
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
|
||||||
use yaak_templates::{RenderOptions, TemplateCallback, render_json_value_raw};
|
|
||||||
|
|
||||||
pub struct BridgePluginEventBridge {
|
|
||||||
rx_id: String,
|
|
||||||
task: JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BridgeHostContext {
|
|
||||||
query_manager: QueryManager,
|
|
||||||
blob_manager: BlobManager,
|
|
||||||
plugin_manager: Arc<PluginManager>,
|
|
||||||
encryption_manager: Arc<EncryptionManager>,
|
|
||||||
connection_manager: Arc<HttpConnectionManager>,
|
|
||||||
response_dir: PathBuf,
|
|
||||||
events: EventHub,
|
|
||||||
session: SessionStore,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BridgePluginEventBridge {
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn start(
|
|
||||||
plugin_manager: Arc<PluginManager>,
|
|
||||||
query_manager: QueryManager,
|
|
||||||
blob_manager: BlobManager,
|
|
||||||
encryption_manager: Arc<EncryptionManager>,
|
|
||||||
connection_manager: Arc<HttpConnectionManager>,
|
|
||||||
data_dir: PathBuf,
|
|
||||||
events: EventHub,
|
|
||||||
session: SessionStore,
|
|
||||||
) -> Self {
|
|
||||||
let (rx_id, mut rx) = plugin_manager.subscribe("bridge").await;
|
|
||||||
let rx_id_for_task = rx_id.clone();
|
|
||||||
let pm = plugin_manager.clone();
|
|
||||||
let host_context = Arc::new(BridgeHostContext {
|
|
||||||
query_manager,
|
|
||||||
blob_manager,
|
|
||||||
plugin_manager,
|
|
||||||
encryption_manager,
|
|
||||||
connection_manager,
|
|
||||||
response_dir: data_dir.join("responses"),
|
|
||||||
events,
|
|
||||||
session,
|
|
||||||
});
|
|
||||||
|
|
||||||
let task = tokio::spawn(async move {
|
|
||||||
while let Some(event) = rx.recv().await {
|
|
||||||
// Events with reply IDs are replies to app-originated requests.
|
|
||||||
if event.reply_id.is_some() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(plugin_handle) = pm.get_plugin_by_ref_id(&event.plugin_ref_id).await
|
|
||||||
else {
|
|
||||||
log::warn!(
|
|
||||||
"Ignoring plugin event with unknown plugin ref '{}'",
|
|
||||||
event.plugin_ref_id
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let pm = pm.clone();
|
|
||||||
let host_context = host_context.clone();
|
|
||||||
|
|
||||||
// Avoid deadlocks for nested plugin-host requests (for example, template functions
|
|
||||||
// that trigger additional host requests during render) by handling each event in
|
|
||||||
// its own task.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let plugin_name = plugin_handle.info().name;
|
|
||||||
let Some(reply_payload) = build_plugin_reply(
|
|
||||||
host_context.as_ref(),
|
|
||||||
&event,
|
|
||||||
&plugin_name,
|
|
||||||
&plugin_handle,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = pm.reply(&event, &reply_payload).await {
|
|
||||||
log::warn!("Failed replying to plugin event: {err}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.unsubscribe(&rx_id_for_task).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
Self { rx_id, task }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn shutdown(self, plugin_manager: &PluginManager) {
|
|
||||||
plugin_manager.unsubscribe(&self.rx_id).await;
|
|
||||||
self.task.abort();
|
|
||||||
let _ = self.task.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn build_plugin_reply(
|
|
||||||
host_context: &BridgeHostContext,
|
|
||||||
event: &InternalEvent,
|
|
||||||
plugin_name: &str,
|
|
||||||
plugin_handle: &PluginHandle,
|
|
||||||
) -> Option<InternalEventPayload> {
|
|
||||||
let session = host_context.session.get();
|
|
||||||
let shared_workspace_id =
|
|
||||||
event.context.workspace_id.clone().or_else(|| session.workspace_id());
|
|
||||||
|
|
||||||
match handle_shared_plugin_event(
|
|
||||||
&host_context.query_manager,
|
|
||||||
&event.payload,
|
|
||||||
SharedPluginEventContext {
|
|
||||||
plugin_name,
|
|
||||||
workspace_id: shared_workspace_id.as_deref(),
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
GroupedPluginEvent::Handled(payload) => payload,
|
|
||||||
GroupedPluginEvent::ToHandle(host_request) => match host_request {
|
|
||||||
HostRequest::ErrorResponse(resp) => {
|
|
||||||
log::warn!("[plugin:{plugin_name}] error: {}", resp.error);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
HostRequest::ReloadResponse(_) => None,
|
|
||||||
|
|
||||||
// The tab owns everything the user can see or the OS can do. These
|
|
||||||
// are fire-and-forget: the plugin gets its acknowledgement as soon
|
|
||||||
// as the frame is queued, matching the desktop, which also does not
|
|
||||||
// wait for the webview to paint.
|
|
||||||
HostRequest::ShowToast(req) => {
|
|
||||||
host_context.events.emit("show_toast", &req);
|
|
||||||
Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))
|
|
||||||
}
|
|
||||||
HostRequest::CopyText(req) => {
|
|
||||||
host_context.events.emit("bridge_copy_text", &req);
|
|
||||||
Some(InternalEventPayload::CopyTextResponse(EmptyPayload {}))
|
|
||||||
}
|
|
||||||
HostRequest::OpenExternalUrl(req) => {
|
|
||||||
host_context.events.emit("bridge_open_url", &req);
|
|
||||||
Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prompts are questions, so they round-trip: the tab renders the
|
|
||||||
// dialog and emits the answer back under the event's own id.
|
|
||||||
HostRequest::PromptText(_) => {
|
|
||||||
let reply = call_frontend(host_context, event).await;
|
|
||||||
Some(reply.unwrap_or(InternalEventPayload::PromptTextResponse(
|
|
||||||
PromptTextResponse { value: None },
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// A form streams: the tab sends a response per interaction and the
|
|
||||||
// plugin re-renders, until one comes back marked done.
|
|
||||||
HostRequest::PromptForm(_) => {
|
|
||||||
host_context.events.emit("plugin_event", event);
|
|
||||||
if event.reply_id.is_none() {
|
|
||||||
spawn_form_reply_pump(host_context, event, plugin_handle);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
HostRequest::ListOpenWorkspaces(_) => {
|
|
||||||
let workspaces = match host_context.query_manager.connect().list_workspaces() {
|
|
||||||
Ok(workspaces) => workspaces
|
|
||||||
.into_iter()
|
|
||||||
.map(|w| WorkspaceInfo {
|
|
||||||
id: w.id.clone(),
|
|
||||||
name: w.name,
|
|
||||||
label: session.label.clone(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
Err(err) => {
|
|
||||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Failed to list workspaces in bridge: {err}"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Some(InternalEventPayload::ListOpenWorkspacesResponse(ListOpenWorkspacesResponse {
|
|
||||||
workspaces,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
HostRequest::SendHttpRequest(req) => {
|
|
||||||
let mut http_request = req.http_request.clone();
|
|
||||||
if http_request.workspace_id.is_empty() {
|
|
||||||
let Some(workspace_id) = shared_workspace_id.clone() else {
|
|
||||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: "workspace_id is required to send HTTP requests in bridge"
|
|
||||||
.to_string(),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
http_request.workspace_id = workspace_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cookie_jar_id = match session.cookie_jar_id() {
|
|
||||||
Some(id) => Some(id),
|
|
||||||
None => match host_context
|
|
||||||
.query_manager
|
|
||||||
.connect()
|
|
||||||
.list_cookie_jars(http_request.workspace_id.as_str())
|
|
||||||
{
|
|
||||||
Ok(jars) => {
|
|
||||||
jars.into_iter().min_by_key(|jar| jar.created_at).map(|jar| jar.id)
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Failed to list cookie jars in bridge: {err}"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let plugin_context = PluginContext {
|
|
||||||
workspace_id: Some(http_request.workspace_id.clone()),
|
|
||||||
..event.context.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
match send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
|
|
||||||
query_manager: &host_context.query_manager,
|
|
||||||
blob_manager: &host_context.blob_manager,
|
|
||||||
request: http_request,
|
|
||||||
environment_id: session.environment_id().as_deref(),
|
|
||||||
update_source: UpdateSource::Plugin,
|
|
||||||
cookie_jar_id,
|
|
||||||
response_dir: &host_context.response_dir,
|
|
||||||
emit_events_to: None,
|
|
||||||
emit_response_body_chunks_to: None,
|
|
||||||
existing_response: None,
|
|
||||||
plugin_manager: host_context.plugin_manager.clone(),
|
|
||||||
encryption_manager: host_context.encryption_manager.clone(),
|
|
||||||
plugin_context: &plugin_context,
|
|
||||||
cancelled_rx: None,
|
|
||||||
connection_manager: &host_context.connection_manager,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
|
|
||||||
SendHttpRequestResponse { http_response: result.response },
|
|
||||||
)),
|
|
||||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Failed to send HTTP request in bridge: {err}"),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HostRequest::RenderHttpRequest(req) => {
|
|
||||||
let mut http_request = req.http_request.clone();
|
|
||||||
if http_request.workspace_id.is_empty() {
|
|
||||||
let Some(workspace_id) = shared_workspace_id.clone() else {
|
|
||||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: "workspace_id is required to render HTTP requests in bridge"
|
|
||||||
.to_string(),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
http_request.workspace_id = workspace_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
let plugin_context = PluginContext {
|
|
||||||
workspace_id: Some(http_request.workspace_id.clone()),
|
|
||||||
..event.context.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let environment_chain = match host_context.query_manager.connect().resolve_environments(
|
|
||||||
&http_request.workspace_id,
|
|
||||||
http_request.folder_id.as_deref(),
|
|
||||||
session.environment_id().as_deref(),
|
|
||||||
) {
|
|
||||||
Ok(chain) => chain,
|
|
||||||
Err(err) => {
|
|
||||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Failed to resolve environments in bridge: {err}"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let template_callback = PluginTemplateCallback::new(
|
|
||||||
host_context.plugin_manager.clone(),
|
|
||||||
host_context.encryption_manager.clone(),
|
|
||||||
&plugin_context,
|
|
||||||
req.purpose.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
match render_http_request(
|
|
||||||
&http_request,
|
|
||||||
environment_chain,
|
|
||||||
&template_callback,
|
|
||||||
&RenderOptions::throw(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(http_request) => Some(InternalEventPayload::RenderHttpRequestResponse(
|
|
||||||
RenderHttpRequestResponse { http_request },
|
|
||||||
)),
|
|
||||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Failed to render HTTP request in bridge: {err}"),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HostRequest::RenderGrpcRequest(req) => {
|
|
||||||
let mut grpc_request = req.grpc_request.clone();
|
|
||||||
if grpc_request.workspace_id.is_empty() {
|
|
||||||
let Some(workspace_id) = shared_workspace_id.clone() else {
|
|
||||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: "workspace_id is required to render gRPC requests in bridge"
|
|
||||||
.to_string(),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
grpc_request.workspace_id = workspace_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
let plugin_context = PluginContext {
|
|
||||||
workspace_id: Some(grpc_request.workspace_id.clone()),
|
|
||||||
..event.context.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let environment_chain = match host_context.query_manager.connect().resolve_environments(
|
|
||||||
&grpc_request.workspace_id,
|
|
||||||
grpc_request.folder_id.as_deref(),
|
|
||||||
session.environment_id().as_deref(),
|
|
||||||
) {
|
|
||||||
Ok(chain) => chain,
|
|
||||||
Err(err) => {
|
|
||||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Failed to resolve environments in bridge: {err}"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let template_callback = PluginTemplateCallback::new(
|
|
||||||
host_context.plugin_manager.clone(),
|
|
||||||
host_context.encryption_manager.clone(),
|
|
||||||
&plugin_context,
|
|
||||||
req.purpose.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
match render_grpc_request(
|
|
||||||
&grpc_request,
|
|
||||||
environment_chain,
|
|
||||||
&template_callback,
|
|
||||||
&RenderOptions::throw(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(grpc_request) => Some(InternalEventPayload::RenderGrpcRequestResponse(
|
|
||||||
RenderGrpcRequestResponse { grpc_request },
|
|
||||||
)),
|
|
||||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Failed to render gRPC request in bridge: {err}"),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HostRequest::TemplateRender(req) => {
|
|
||||||
let Some(workspace_id) = shared_workspace_id.clone() else {
|
|
||||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: "workspace_id is required to render templates in bridge".to_string(),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
let plugin_context =
|
|
||||||
PluginContext { workspace_id: Some(workspace_id.clone()), ..event.context.clone() };
|
|
||||||
|
|
||||||
let folder_id = session.request_id().and_then(|rid| {
|
|
||||||
match host_context.query_manager.connect().get_any_request(&rid) {
|
|
||||||
Ok(AnyRequest::HttpRequest(r)) => r.folder_id,
|
|
||||||
Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,
|
|
||||||
Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,
|
|
||||||
Err(_) => None,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let environment_chain = match host_context.query_manager.connect().resolve_environments(
|
|
||||||
&workspace_id,
|
|
||||||
folder_id.as_deref(),
|
|
||||||
session.environment_id().as_deref(),
|
|
||||||
) {
|
|
||||||
Ok(chain) => chain,
|
|
||||||
Err(err) => {
|
|
||||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Failed to resolve environments in bridge: {err}"),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let template_callback = PluginTemplateCallback::new(
|
|
||||||
host_context.plugin_manager.clone(),
|
|
||||||
host_context.encryption_manager.clone(),
|
|
||||||
&plugin_context,
|
|
||||||
req.purpose.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
match render_json_value(
|
|
||||||
req.data.clone(),
|
|
||||||
environment_chain,
|
|
||||||
&template_callback,
|
|
||||||
&RenderOptions::throw(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(data) => {
|
|
||||||
Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse {
|
|
||||||
data,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Failed to render template data in bridge: {err}"),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HostRequest::ListCookieNames(_) => {
|
|
||||||
let Some(cookie_jar_id) = session.cookie_jar_id() else {
|
|
||||||
return Some(InternalEventPayload::ListCookieNamesResponse(
|
|
||||||
ListCookieNamesResponse { names: Vec::new() },
|
|
||||||
));
|
|
||||||
};
|
|
||||||
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
|
|
||||||
Ok(jar) => Some(InternalEventPayload::ListCookieNamesResponse(
|
|
||||||
ListCookieNamesResponse {
|
|
||||||
names: jar.cookies.into_iter().map(|c| c.name).collect(),
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Failed to load cookie jar in bridge: {err}"),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HostRequest::GetCookieValue(req) => {
|
|
||||||
let Some(cookie_jar_id) = session.cookie_jar_id() else {
|
|
||||||
return Some(InternalEventPayload::GetCookieValueResponse(
|
|
||||||
GetCookieValueResponse { value: None },
|
|
||||||
));
|
|
||||||
};
|
|
||||||
match host_context.query_manager.connect().get_cookie_jar(&cookie_jar_id) {
|
|
||||||
Ok(jar) => {
|
|
||||||
let value =
|
|
||||||
get_cookie_value_from_jar(jar.cookies, &req.name, req.domain.as_deref());
|
|
||||||
Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse {
|
|
||||||
value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Failed to load cookie jar in bridge: {err}"),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HostRequest::WindowInfo(req) => {
|
|
||||||
Some(InternalEventPayload::WindowInfoResponse(WindowInfoResponse {
|
|
||||||
label: req.label.clone(),
|
|
||||||
request_id: session.request_id(),
|
|
||||||
workspace_id: shared_workspace_id.clone(),
|
|
||||||
environment_id: session.environment_id(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// A tab is one window. Opening and closing them needs the
|
|
||||||
// multiWindow capability the bridge reports false.
|
|
||||||
HostRequest::OpenWindow(_) => Some(unsupported("open_window_request")),
|
|
||||||
HostRequest::CloseWindow(_) => Some(unsupported("close_window_request")),
|
|
||||||
HostRequest::OtherRequest(payload) => Some(unsupported(&payload.type_name())),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unsupported(type_name: &str) -> InternalEventPayload {
|
|
||||||
InternalEventPayload::ErrorResponse(ErrorResponse {
|
|
||||||
error: format!("Unsupported plugin request in bridge: {type_name}"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ask the tab and wait for its answer, keyed by the event's id — the same
|
|
||||||
/// contract as the desktop's `call_frontend`.
|
|
||||||
async fn call_frontend(
|
|
||||||
host_context: &BridgeHostContext,
|
|
||||||
event: &InternalEvent,
|
|
||||||
) -> Option<InternalEventPayload> {
|
|
||||||
// Subscribe before emitting: the tab can answer faster than this task is
|
|
||||||
// rescheduled, and a reply that arrives before the listener exists is lost.
|
|
||||||
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
|
|
||||||
host_context.events.emit("plugin_event", event);
|
|
||||||
|
|
||||||
let value = replies.recv().await?;
|
|
||||||
match serde_json::from_value::<InternalEvent>(value) {
|
|
||||||
Ok(reply) => Some(reply.payload),
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to parse plugin reply from browser: {e}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forward every form response the tab sends back to the plugin, until one is
|
|
||||||
/// marked done.
|
|
||||||
fn spawn_form_reply_pump(
|
|
||||||
host_context: &BridgeHostContext,
|
|
||||||
event: &InternalEvent,
|
|
||||||
plugin_handle: &PluginHandle,
|
|
||||||
) {
|
|
||||||
let mut replies = host_context.events.subscribe_inbound(event.id.clone());
|
|
||||||
let plugin_handle = plugin_handle.clone();
|
|
||||||
let plugin_context = event.context.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some(value) = replies.recv().await {
|
|
||||||
let Ok(resp) = serde_json::from_value::<InternalEvent>(value) else {
|
|
||||||
log::warn!("Failed to parse form response from browser");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let is_done = matches!(
|
|
||||||
&resp.payload,
|
|
||||||
InternalEventPayload::PromptFormResponse(r) if r.done.unwrap_or(false)
|
|
||||||
);
|
|
||||||
|
|
||||||
let event_to_send = plugin_handle.build_event_to_send(
|
|
||||||
&plugin_context,
|
|
||||||
&resp.payload,
|
|
||||||
Some(resp.reply_id.unwrap_or_default()),
|
|
||||||
);
|
|
||||||
if let Err(e) = plugin_handle.send(&event_to_send).await {
|
|
||||||
log::warn!("Failed to forward form response to plugin: {e:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_done {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn render_json_value<T: TemplateCallback>(
|
|
||||||
value: Value,
|
|
||||||
environment_chain: Vec<Environment>,
|
|
||||||
cb: &T,
|
|
||||||
opt: &RenderOptions,
|
|
||||||
) -> yaak_templates::error::Result<Value> {
|
|
||||||
let vars = &make_vars_hashmap(environment_chain);
|
|
||||||
render_json_value_raw(value, vars, cb, opt).await
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,147 +0,0 @@
|
|||||||
//! The bridge's RPC surface.
|
|
||||||
//!
|
|
||||||
//! Same envelope and same command names as the desktop, dispatched through the
|
|
||||||
//! same `RpcRouter`. Only the adapters differ: the desktop's take a Tauri
|
|
||||||
//! window and read the workspace off its URL, while these take a `BridgeCtx`
|
|
||||||
//! carrying the connected tab's reported URL. The bodies underneath call the
|
|
||||||
//! same engine functions in `yaak`, `yaak-models` and `yaak-plugins`.
|
|
||||||
//!
|
|
||||||
//! This is a subset — enough to boot, edit, send and inspect. Anything not
|
|
||||||
//! registered here still gets a well-formed answer: `unsupported_command`
|
|
||||||
//! turns it into an RPC error naming the command and this host, so the frontend
|
|
||||||
//! surfaces "not supported by the Yaak Bridge" instead of a bare failure.
|
|
||||||
|
|
||||||
mod commands;
|
|
||||||
|
|
||||||
pub use commands::implemented_commands;
|
|
||||||
|
|
||||||
use crate::session::SessionContext;
|
|
||||||
use crate::state::BridgeState;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use yaak_plugins::events::PluginContext;
|
|
||||||
use yaak_rpc::{RpcError, RpcRouter};
|
|
||||||
|
|
||||||
/// Per-call context. The tab's identity and location, plus the engine.
|
|
||||||
///
|
|
||||||
/// Mirrors the desktop's `ClientCtx { window }`: the window there answers both
|
|
||||||
/// "who is calling" and "what are they looking at", and those are exactly the
|
|
||||||
/// two things a bridge call needs that the payload doesn't carry.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct BridgeCtx {
|
|
||||||
pub state: Arc<BridgeState>,
|
|
||||||
pub session: SessionContext,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BridgeCtx {
|
|
||||||
pub fn plugin_context(&self) -> PluginContext {
|
|
||||||
PluginContext::new(Some(self.session.label.clone()), self.session.workspace_id())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_source(&self) -> yaak_models::util::UpdateSource {
|
|
||||||
yaak_models::util::UpdateSource::from_window_label(&self.session.label)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The plugin runtime, or an error naming the reason it isn't there.
|
|
||||||
pub fn plugins(&self) -> Result<Arc<yaak_plugins::manager::PluginManager>, RpcError> {
|
|
||||||
self.state.plugin_manager().ok_or_else(|| RpcError {
|
|
||||||
message: "The plugin runtime failed to start, so this command is unavailable"
|
|
||||||
.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_router() -> RpcRouter<BridgeCtx> {
|
|
||||||
commands::build_router()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every command the desktop has that the bridge does not implement.
|
|
||||||
///
|
|
||||||
/// Registered explicitly rather than left to fall through to "unknown command",
|
|
||||||
/// so the message says *why* — the frontend can tell a host that will never
|
|
||||||
/// support git from one that is simply out of date.
|
|
||||||
pub const UNSUPPORTED_COMMANDS: &[&str] = &[
|
|
||||||
// Multi-window. A tab is one window; Settings opens through this on the
|
|
||||||
// desktop and is therefore unreachable in the browser today.
|
|
||||||
"cmd_new_child_window",
|
|
||||||
"cmd_new_main_window",
|
|
||||||
// gRPC and WebSocket sending.
|
|
||||||
"cmd_grpc_reflect",
|
|
||||||
"cmd_grpc_go",
|
|
||||||
"cmd_grpc_request_actions",
|
|
||||||
"cmd_call_grpc_request_action",
|
|
||||||
"cmd_delete_all_grpc_connections",
|
|
||||||
"cmd_ws_connect",
|
|
||||||
"cmd_ws_send",
|
|
||||||
"cmd_ws_close",
|
|
||||||
"cmd_ws_delete_connections",
|
|
||||||
"cmd_websocket_request_actions",
|
|
||||||
"cmd_call_websocket_request_action",
|
|
||||||
// Git-backed workspaces.
|
|
||||||
"cmd_git_checkout",
|
|
||||||
"cmd_git_branch",
|
|
||||||
"cmd_git_delete_branch",
|
|
||||||
"cmd_git_delete_remote_branch",
|
|
||||||
"cmd_git_merge_branch",
|
|
||||||
"cmd_git_rename_branch",
|
|
||||||
"cmd_git_status",
|
|
||||||
"cmd_git_branch_info",
|
|
||||||
"cmd_git_worktree_status",
|
|
||||||
"cmd_git_log",
|
|
||||||
"cmd_git_log_for_file",
|
|
||||||
"cmd_git_file_diff_for_commit",
|
|
||||||
"cmd_git_initialize",
|
|
||||||
"cmd_git_clone",
|
|
||||||
"cmd_git_commit",
|
|
||||||
"cmd_git_fetch_all",
|
|
||||||
"cmd_git_push",
|
|
||||||
"cmd_git_pull",
|
|
||||||
"cmd_git_pull_force_reset",
|
|
||||||
"cmd_git_pull_merge",
|
|
||||||
"cmd_git_add",
|
|
||||||
"cmd_git_unstage",
|
|
||||||
"cmd_git_reset_changes",
|
|
||||||
"cmd_git_restore_files",
|
|
||||||
"cmd_git_restore_file_from_commit",
|
|
||||||
"cmd_git_add_credential",
|
|
||||||
"cmd_git_remotes",
|
|
||||||
"cmd_git_add_remote",
|
|
||||||
"cmd_git_rm_remote",
|
|
||||||
"cmd_git_watch_worktree_status",
|
|
||||||
// Filesystem sync.
|
|
||||||
"cmd_sync_calculate",
|
|
||||||
"cmd_sync_calculate_fs",
|
|
||||||
"cmd_sync_apply",
|
|
||||||
"cmd_sync_watch",
|
|
||||||
// Workspace encryption.
|
|
||||||
"cmd_enable_encryption",
|
|
||||||
"cmd_disable_encryption",
|
|
||||||
"cmd_reveal_workspace_key",
|
|
||||||
"cmd_set_workspace_key",
|
|
||||||
// Things that need a local filesystem the tab can point at.
|
|
||||||
//
|
|
||||||
// `cmd_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)"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
//! What the connected tab is currently looking at.
|
|
||||||
//!
|
|
||||||
//! The desktop reads workspace, environment, cookie jar and request straight off
|
|
||||||
//! the window's URL (crates-tauri/yaak-tauri-utils/src/window.rs). A browser tab
|
|
||||||
//! runs the same router and so has the same URL, but the server cannot see it —
|
|
||||||
//! so the tab reports it, on connect and whenever it changes, and the same
|
|
||||||
//! parsing happens here.
|
|
||||||
//!
|
|
||||||
//! One session for the whole process: this slice serves a single tab. A second
|
|
||||||
//! tab overwrites the first's context rather than getting its own.
|
|
||||||
|
|
||||||
use std::sync::{Arc, RwLock};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct SessionContext {
|
|
||||||
/// Identifies the tab, and lands in `UpdateSource::Window { label }` so
|
|
||||||
/// model-write echo suppression works exactly as it does on the desktop.
|
|
||||||
pub label: String,
|
|
||||||
pub url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionContext {
|
|
||||||
pub fn workspace_id(&self) -> Option<String> {
|
|
||||||
let rest = self.url.split("/workspaces/").nth(1)?;
|
|
||||||
let id: String =
|
|
||||||
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
|
|
||||||
if id.is_empty() { None } else { Some(id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn request_id(&self) -> Option<String> {
|
|
||||||
let rest = self.url.split("/requests/").nth(1)?;
|
|
||||||
let id: String =
|
|
||||||
rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();
|
|
||||||
if id.is_empty() { None } else { Some(id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn environment_id(&self) -> Option<String> {
|
|
||||||
self.query_param("environment_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cookie_jar_id(&self) -> Option<String> {
|
|
||||||
self.query_param("cookie_jar_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_param(&self, key: &str) -> Option<String> {
|
|
||||||
let query = self.url.split('?').nth(1)?;
|
|
||||||
let value = query.split('&').find_map(|pair| {
|
|
||||||
let (k, v) = pair.split_once('=')?;
|
|
||||||
if k != key {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(percent_decode(v))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// The router writes `environment_id=null` when nothing is selected.
|
|
||||||
// Neither of these is an id, and treating them as one sends a lookup
|
|
||||||
// for a model that cannot exist.
|
|
||||||
if value.is_empty() || value == "null" || value == "undefined" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn percent_decode(input: &str) -> String {
|
|
||||||
let bytes = input.replace('+', " ").into_bytes();
|
|
||||||
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
|
||||||
let mut i = 0;
|
|
||||||
while i < bytes.len() {
|
|
||||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
|
||||||
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
|
|
||||||
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
|
||||||
out.push(byte);
|
|
||||||
i += 3;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push(bytes[i]);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
String::from_utf8_lossy(&out).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
pub struct SessionStore {
|
|
||||||
inner: Arc<RwLock<SessionContext>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionStore {
|
|
||||||
pub fn get(&self) -> SessionContext {
|
|
||||||
match self.inner.read() {
|
|
||||||
Ok(guard) => guard.clone(),
|
|
||||||
Err(poisoned) => poisoned.into_inner().clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set(&self, context: SessionContext) {
|
|
||||||
let mut guard = match self.inner.write() {
|
|
||||||
Ok(guard) => guard,
|
|
||||||
Err(poisoned) => poisoned.into_inner(),
|
|
||||||
};
|
|
||||||
*guard = context;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn ctx(url: &str) -> SessionContext {
|
|
||||||
SessionContext { label: "tab".into(), url: url.into() }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_ids_from_a_router_url() {
|
|
||||||
let c = ctx(
|
|
||||||
"http://localhost:1472/workspaces/wk_abc123/requests/rq_def456?environment_id=ev_1&cookie_jar_id=cj_2",
|
|
||||||
);
|
|
||||||
assert_eq!(c.workspace_id().as_deref(), Some("wk_abc123"));
|
|
||||||
assert_eq!(c.request_id().as_deref(), Some("rq_def456"));
|
|
||||||
assert_eq!(c.environment_id().as_deref(), Some("ev_1"));
|
|
||||||
assert_eq!(c.cookie_jar_id().as_deref(), Some("cj_2"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn placeholder_query_values_are_not_ids() {
|
|
||||||
let c = ctx("http://localhost:1472/workspaces/wk_a?environment_id=null&cookie_jar_id=");
|
|
||||||
assert_eq!(c.environment_id(), None);
|
|
||||||
assert_eq!(c.cookie_jar_id(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_parts_are_none() {
|
|
||||||
let c = ctx("http://localhost:1472/");
|
|
||||||
assert_eq!(c.workspace_id(), None);
|
|
||||||
assert_eq!(c.request_id(), None);
|
|
||||||
assert_eq!(c.environment_id(), None);
|
|
||||||
assert_eq!(c.cookie_jar_id(), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
//! The bridge's engine handles, shared by every route.
|
|
||||||
//!
|
|
||||||
//! Structurally this is `CliContext` (crates-cli/yaak-cli/src/context.rs) with
|
|
||||||
//! an event hub bolted on: the same `init_standalone` database, the same
|
|
||||||
//! `PluginManager` over the same Node sidecar. What differs is that a browser
|
|
||||||
//! tab is attached, so writes have to be pushed out as they happen instead of
|
|
||||||
//! the process exiting when a command finishes.
|
|
||||||
|
|
||||||
use crate::events::EventHub;
|
|
||||||
use crate::plugin_events::BridgePluginEventBridge;
|
|
||||||
use crate::session::SessionStore;
|
|
||||||
use include_dir::{Dir, include_dir};
|
|
||||||
use serde::Serialize;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
|
||||||
use yaak_http::manager::HttpConnectionManager;
|
|
||||||
use yaak_models::blob_manager::BlobManager;
|
|
||||||
use yaak_models::client_db::ClientDb;
|
|
||||||
use yaak_models::query_manager::QueryManager;
|
|
||||||
use yaak_plugins::events::PluginContext;
|
|
||||||
use yaak_plugins::manager::PluginManager;
|
|
||||||
|
|
||||||
const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
|
|
||||||
));
|
|
||||||
static EMBEDDED_VENDORED_PLUGINS: Dir<'_> =
|
|
||||||
include_dir!("$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app-client/vendored/plugins");
|
|
||||||
|
|
||||||
/// What this host can do, mirroring `PlatformCapabilities` in
|
|
||||||
/// packages/platform/src/types.ts.
|
|
||||||
///
|
|
||||||
/// Reported to the browser rather than hardcoded there, because the honest
|
|
||||||
/// answer depends on how the bridge was built — these become cargo features as
|
|
||||||
/// the surface grows, and the tab should not have to guess.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct BridgeCapabilities {
|
|
||||||
pub grpc: bool,
|
|
||||||
pub websocket: bool,
|
|
||||||
pub git: bool,
|
|
||||||
pub sync: bool,
|
|
||||||
pub tls_options: bool,
|
|
||||||
pub cookie_jar: bool,
|
|
||||||
pub local_files: bool,
|
|
||||||
pub timeline: bool,
|
|
||||||
pub multi_window: bool,
|
|
||||||
pub plugins: bool,
|
|
||||||
pub encryption: bool,
|
|
||||||
pub updater: bool,
|
|
||||||
pub clipboard_read: bool,
|
|
||||||
pub system_fonts: bool,
|
|
||||||
pub license: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BridgeCapabilities {
|
|
||||||
/// The first slice: real HTTP sending with full fidelity, real plugins, a
|
|
||||||
/// real cookie jar and timeline. Everything the bridge has no route for is
|
|
||||||
/// reported false so the UI hides it rather than calling and failing.
|
|
||||||
fn for_this_build(plugins: bool) -> Self {
|
|
||||||
Self {
|
|
||||||
grpc: false,
|
|
||||||
websocket: false,
|
|
||||||
git: false,
|
|
||||||
sync: false,
|
|
||||||
// The engine does the TLS, so client certs and custom CAs are real.
|
|
||||||
tls_options: true,
|
|
||||||
cookie_jar: true,
|
|
||||||
// The bridge has a filesystem but the tab has no way to pick a path
|
|
||||||
// on it: there is no dialog implementation on this host.
|
|
||||||
local_files: false,
|
|
||||||
timeline: true,
|
|
||||||
multi_window: false,
|
|
||||||
plugins,
|
|
||||||
encryption: false,
|
|
||||||
updater: false,
|
|
||||||
clipboard_read: false,
|
|
||||||
system_fonts: false,
|
|
||||||
license: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
name = "yaak-web"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
publish = false
|
||||||
|
description = "The server behind Yaak in the browser: executes sends, and can serve the app"
|
||||||
|
|
||||||
|
# The send engine (yaak-http) and the model types it speaks (yaak-models, for
|
||||||
|
# HttpRequest / Cookie / HttpResponseEventData). Deliberately NOT yaak (the
|
||||||
|
# render + storage orchestration), yaak-plugins, or the RPC router: this binary
|
||||||
|
# opens no database, runs no plugins, and renders nothing. yaak-models comes
|
||||||
|
# along only because yaak-http's types are its types; nothing here calls into
|
||||||
|
# its query layer.
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "yaak-web"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1"
|
||||||
|
axum = "0.7"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
bytes = "1.11.1"
|
||||||
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
|
env_logger = "0.11"
|
||||||
|
futures-util = "0.3"
|
||||||
|
log = { workspace = true }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync", "io-util", "time", "net"] }
|
||||||
|
tower-http = { version = "0.6", features = ["compression-gzip", "compression-zstd", "cors", "fs"] }
|
||||||
|
ts-rs = { workspace = true }
|
||||||
|
url = "2"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
yaak-http = { workspace = true }
|
||||||
|
yaak-models = { workspace = true }
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# yaak-web
|
||||||
|
|
||||||
|
The network half of Yaak in a browser — and, with `--serve`, the half that
|
||||||
|
hands the browser the app in the first place.
|
||||||
|
|
||||||
|
A tab can't see an HTTP response the way a desktop app can: CORS hides most
|
||||||
|
headers (2 of 8 in a typical response), redirects are followed silently, and
|
||||||
|
there is no timeline. So the tab renders the request and posts it here, and this
|
||||||
|
process puts it on the network with the desktop's own engine (`yaak-http`) and
|
||||||
|
streams back everything that happened — every header, every redirect hop, DNS
|
||||||
|
timing, the body — for the tab to store.
|
||||||
|
|
||||||
|
It is a **stateless executor**. It keeps nothing: no database, no files, no
|
||||||
|
sessions, no cookies between calls. Every byte it sees comes from the tab in the
|
||||||
|
request, and every byte it returns is stored by the tab. Restart it any time.
|
||||||
|
|
||||||
|
## Self-hosting it
|
||||||
|
|
||||||
|
One container, no configuration, nothing behind it:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:8080>. The image carries the built web client and this
|
||||||
|
binary, which serves it — so the app and its sends are on one origin, and the
|
||||||
|
tab's send URL is a path (`/v1/http/send`) rather than an address anyone has to
|
||||||
|
configure. The image is `linux/amd64` and `linux/arm64`, built from
|
||||||
|
`Dockerfile.web` at the repo root.
|
||||||
|
|
||||||
|
Your data lives in your browser (SQLite compiled to wasm, in IndexedDB), not in
|
||||||
|
the container. The container is stateless: nothing is written to disk, so
|
||||||
|
upgrading is `docker pull` and nothing else.
|
||||||
|
|
||||||
|
Two settings are worth knowing about:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker run -p 8080:8080 \
|
||||||
|
-e YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true \
|
||||||
|
-e YAAK_WEB_RATE_LIMIT_PER_MINUTE=0 \
|
||||||
|
ghcr.io/mountain-loop/yaak-web
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true`** lets sends reach loopback,
|
||||||
|
private and link-local addresses. Off by default, and it should stay off on
|
||||||
|
anything strangers can reach — see [What it refuses](#what-it-refuses-and-why).
|
||||||
|
Turn it on for an instance on your own network, where calling the API on the
|
||||||
|
next machine is the whole point. Note that "private" is relative to the
|
||||||
|
*container*: `127.0.0.1` is the container itself, and reaching the Docker
|
||||||
|
host means `host.docker.internal` (or `--network host`).
|
||||||
|
- **`YAAK_WEB_RATE_LIMIT_PER_MINUTE`** defaults to 120 sends per client IP,
|
||||||
|
which suits a public instance and not a team of your own; `0` disables it.
|
||||||
|
|
||||||
|
Behind a reverse proxy, add `YAAK_WEB_TRUST_FORWARDED_FOR=true` so the rate
|
||||||
|
limit sees real client addresses instead of its own — and only then, since
|
||||||
|
otherwise anyone can spoof the header. If the reverse proxy buffers responses,
|
||||||
|
tell it not to: sends are streamed, and the `X-Accel-Buffering: no` header this
|
||||||
|
binary sets is honoured by nginx-shaped ones.
|
||||||
|
|
||||||
|
## Running it from source
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cargo run -p yaak-web -- --serve dist/apps/yaak-client
|
||||||
|
```
|
||||||
|
|
||||||
|
after a `YAAK_TARGET=web SKIP_WASM_BUILD=1 npx vp -C apps/yaak-client build`.
|
||||||
|
Without `--serve` it is the send executor alone, which is what the frontend
|
||||||
|
dev server wants:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cargo run -p yaak-web
|
||||||
|
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||||
|
```
|
||||||
|
|
||||||
|
A dev build looks for the server at `http://127.0.0.1:9227` (the Vite server is a
|
||||||
|
different origin and serves no `/v1`); a production build sends to its own
|
||||||
|
origin unless `VITE_YAAK_WEB_URL` was set when it was built.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Every flag has a `YAAK_WEB_*` environment variable, so a container needs no
|
||||||
|
arguments; `--help` lists them all.
|
||||||
|
|
||||||
|
| Flag | Default | What |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--serve` | off | Also serve a built web client from this directory, on the same origin. |
|
||||||
|
| `--bind` | `127.0.0.1:9227` | Listen address. The image sets `0.0.0.0:8080`. |
|
||||||
|
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
|
||||||
|
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
|
||||||
|
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
|
||||||
|
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
|
||||||
|
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
|
||||||
|
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
|
||||||
|
| `--max-concurrent` | 256 | Sends in flight at once. |
|
||||||
|
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
|
||||||
|
|
||||||
|
## Serving the app
|
||||||
|
|
||||||
|
`--serve DIR` puts a file server behind the API routes: `/v1/*` is matched
|
||||||
|
first, everything else comes from `DIR`, and a path with no file behind it gets
|
||||||
|
`index.html` so the app's own routes survive a refresh. Responses are compressed
|
||||||
|
(gzip or zstd) on the fly. `/assets/*` is cached forever — Vite content-hashes
|
||||||
|
those names — and everything else is `no-cache`, so a new deploy arrives on the
|
||||||
|
next reload.
|
||||||
|
|
||||||
|
Serving files changes nothing about sending: the same rendered request, the same
|
||||||
|
destination policy, the same stateless executor. It exists so that a
|
||||||
|
self-hosted Yaak is one thing to run rather than two.
|
||||||
|
|
||||||
|
## Split deployments
|
||||||
|
|
||||||
|
The app and the sender can still be separate services — one CDN-hosted bundle and
|
||||||
|
one server elsewhere, or one server shared by several fronts. Then the bundle has
|
||||||
|
to be told where to send, at build time:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker build -f Dockerfile.web \
|
||||||
|
--build-arg VITE_YAAK_WEB_URL=https://send.example.com .
|
||||||
|
```
|
||||||
|
|
||||||
|
and the server needs the CORS origins its callers use, since the requests are no
|
||||||
|
longer same-origin:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker run -p 8080:8080 \
|
||||||
|
-e YAAK_WEB_ALLOWED_ORIGINS=https://yaak.example.com \
|
||||||
|
ghcr.io/mountain-loop/yaak-web \
|
||||||
|
yaak-web
|
||||||
|
```
|
||||||
|
|
||||||
|
The trailing `yaak-web` is a command override: the same image run without
|
||||||
|
`--serve`, so it executes sends and serves no app.
|
||||||
|
|
||||||
|
## What it refuses, and why
|
||||||
|
|
||||||
|
A hosted sender is, by construction, a machine that makes HTTP requests on
|
||||||
|
behalf of strangers. Left alone that is an open relay into whatever network it
|
||||||
|
sits on. So by default it refuses to connect to:
|
||||||
|
|
||||||
|
- loopback (`127/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`,
|
||||||
|
`fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
|
||||||
|
`fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges,
|
||||||
|
IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the well-known
|
||||||
|
NAT64 prefix, 6to4), and the whole NAT64 local-use range;
|
||||||
|
- anything not `http://` or `https://`.
|
||||||
|
|
||||||
|
The check runs **on the resolved addresses, after DNS**, for every hop of a
|
||||||
|
redirect chain, so a public hostname that points at an internal address is
|
||||||
|
caught, and so is a `Location:` header that points at one. It also refuses body
|
||||||
|
types that would read files on its own disk (`binary`, multipart file
|
||||||
|
fields), since no browser tab could legitimately mean those.
|
||||||
|
|
||||||
|
Refusals are logged with the reason. On a public instance (`web.yaak.app`, or
|
||||||
|
anything else strangers can reach) this must stay on: the machine's private
|
||||||
|
network is the host's, not the user's, so a `localhost` or LAN API is not the
|
||||||
|
user's to reach through it — the desktop app is what reaches those. On an
|
||||||
|
instance you run for yourself, that reasoning is inverted, and
|
||||||
|
`--allow-private-networks` inverts the policy with it. It allows every range
|
||||||
|
above, including `169.254.169.254`, so use it only where the network on the
|
||||||
|
other side is one the users are entitled to.
|
||||||
|
|
||||||
|
There is no authentication either way: an instance is anonymous, protected by
|
||||||
|
the per-client rate limit and the destination policy. Anything more (a shared
|
||||||
|
token, per-user quotas) is a later slice and would sit in front of `send_http`
|
||||||
|
in `main.rs`. Put TLS in front of a public instance.
|
||||||
|
|
||||||
|
## The wire
|
||||||
|
|
||||||
|
`POST /v1/http/send` with a JSON body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"request": { "url": "https://…", "method": "GET", "headers": […], "body": {…}, "bodyType": null, "urlParameters": […] },
|
||||||
|
"settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true },
|
||||||
|
"cookies": [ … ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`request` is a Yaak `HttpRequest` in the desktop's own model shape with every
|
||||||
|
template already rendered by the tab; the server builds the URL, headers and
|
||||||
|
body from it exactly the way the desktop does after rendering. `cookies` is the
|
||||||
|
jar's contents (or `null` for no jar).
|
||||||
|
|
||||||
|
The reply is `application/x-ndjson`, one JSON frame per line, in the order things
|
||||||
|
happened:
|
||||||
|
|
||||||
|
| `type` | When | Carries |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
|
||||||
|
| `response` | once, when the final hop's headers arrive | status, all headers, request headers as sent, remote address, HTTP version, timing |
|
||||||
|
| `body` | as the body is read | a decompressed chunk, base64 |
|
||||||
|
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
|
||||||
|
| `error` | last, on failure | the reason, and any cookies collected before the failure |
|
||||||
|
|
||||||
|
Refusals that happen before anything is sent (a blocked destination, a bad body,
|
||||||
|
rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
|
||||||
|
`{"error": "…"}`, not streams.
|
||||||
|
|
||||||
|
Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by
|
||||||
|
construction, cancellable by closing the connection, readable with `curl`, and
|
||||||
|
needs no upgrade handling on either side. A WebSocket only earns its keep when
|
||||||
|
traffic is bidirectional, which a single send is not.
|
||||||
|
|
||||||
|
The TypeScript side of this contract is generated from `src/wire.rs` by ts-rs
|
||||||
|
into `bindings/` (run `cargo test -p yaak-web` after changing a frame)
|
||||||
|
and published to the tab as `@yaakapp-internal/web`, so a change to the
|
||||||
|
wire on one side is a type error on the other.
|
||||||
|
|
||||||
|
`GET /v1/health` reports the version and the effective limits.
|
||||||
|
|
||||||
|
## What comes later
|
||||||
|
|
||||||
|
Not built, by design, but the router is shaped for it: a WebSocket relay
|
||||||
|
(`/v1/ws/relay`) and a gRPC relay (`/v1/grpc/relay`) would be long-lived,
|
||||||
|
bidirectional endpoints on the same binary, behind the same destination policy
|
||||||
|
and limits. They differ from this endpoint in holding per-connection
|
||||||
|
in-memory state while a connection is open (never persisted), which brings
|
||||||
|
connection limits and a larger abuse surface — the reason they are separate
|
||||||
|
work.
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
// 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
@@ -0,0 +1,64 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings } from "./gen_models";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
|
||||||
|
* camelCase like every model the tab stores.
|
||||||
|
*/
|
||||||
|
export type Frame = { "type": "event", event: HttpResponseEventData, } | { "type": "response", status: number, statusReason: string | null,
|
||||||
|
/**
|
||||||
|
* The URL that answered, after redirects.
|
||||||
|
*/
|
||||||
|
url: string, remoteAddr: string | null, version: string | null, headers: Array<HttpResponseHeader>,
|
||||||
|
/**
|
||||||
|
* The headers that were actually sent on the final hop, cookies and all.
|
||||||
|
*/
|
||||||
|
requestHeaders: Array<HttpResponseHeader>,
|
||||||
|
/**
|
||||||
|
* `Content-Length` as declared by the server, if it declared one.
|
||||||
|
*/
|
||||||
|
contentLength: number | null,
|
||||||
|
/**
|
||||||
|
* Milliseconds from the start of the send to the response head.
|
||||||
|
*/
|
||||||
|
elapsedHeaders: number,
|
||||||
|
/**
|
||||||
|
* Milliseconds spent in DNS on the last lookup, or zero.
|
||||||
|
*/
|
||||||
|
elapsedDns: number, } | { "type": "body", data: string, } | { "type": "done",
|
||||||
|
/**
|
||||||
|
* Milliseconds from the start of the send to the end of the body.
|
||||||
|
*/
|
||||||
|
elapsed: number,
|
||||||
|
/**
|
||||||
|
* Bytes of body relayed, after decompression.
|
||||||
|
*/
|
||||||
|
contentLength: number,
|
||||||
|
/**
|
||||||
|
* Bytes on the wire as declared by the server, or the relayed size when unknown.
|
||||||
|
*/
|
||||||
|
contentLengthCompressed: number,
|
||||||
|
/**
|
||||||
|
* The jar as the send left it, for the tab to persist. `None` when the tab sent none.
|
||||||
|
*/
|
||||||
|
cookies: Array<Cookie> | null, } | { "type": "error", message: string, cookies: Array<Cookie> | null, };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body of `POST /v1/http/send`.
|
||||||
|
*/
|
||||||
|
export type SendRequest = {
|
||||||
|
/**
|
||||||
|
* The request to send, in the desktop's own model shape but with every template already
|
||||||
|
* rendered by the tab. The server builds the URL, headers and body from it exactly the way
|
||||||
|
* the desktop does after rendering.
|
||||||
|
*/
|
||||||
|
request: HttpRequest,
|
||||||
|
/**
|
||||||
|
* The resolved settings, values only. Where they came from is the tab's to record in
|
||||||
|
* its timeline; the server only needs to obey them.
|
||||||
|
*/
|
||||||
|
settings: HttpSendSettings,
|
||||||
|
/**
|
||||||
|
* The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
|
||||||
|
*/
|
||||||
|
cookies: Array<Cookie> | null, };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// The server's wire contract, generated by ts-rs from src/wire.rs
|
||||||
|
// (`cargo test -p yaak-web`). The tab imports these so a change to a
|
||||||
|
// frame on the Rust side is a type error in packages/platform/src/web.
|
||||||
|
export type { Frame, SendRequest } from "./bindings/gen_web";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "@yaakapp-internal/web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "index.ts"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// The server behind Yaak running in a browser.
|
||||||
|
///
|
||||||
|
/// The tab renders the request and owns the data; this binary puts the bytes on the network
|
||||||
|
/// and streams back what came back, and with `--serve` hands the browser the app as well.
|
||||||
|
/// Nothing is written to disk or a database.
|
||||||
|
#[derive(Parser, Debug, Clone)]
|
||||||
|
#[command(name = "yaak-web", version, about, long_about = None)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Address to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container.
|
||||||
|
#[arg(long, env = "YAAK_WEB_BIND", default_value = "127.0.0.1:9227")]
|
||||||
|
pub bind: SocketAddr,
|
||||||
|
|
||||||
|
/// Also serve a built web client from this directory, on the same origin as the API.
|
||||||
|
/// Unknown paths fall back to `index.html` so the app's own routes work on a refresh.
|
||||||
|
/// Without this the binary is only the send executor.
|
||||||
|
#[arg(long, env = "YAAK_WEB_SERVE", value_name = "DIR")]
|
||||||
|
pub serve: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Allow sends to loopback, private and link-local addresses. Off by default, because a
|
||||||
|
/// server reachable by strangers is an open relay into the network it sits on. Turn it on
|
||||||
|
/// only for an instance whose users are meant to reach that network — a self-hosted one
|
||||||
|
/// on a LAN, where the point is to call the API on the next machine.
|
||||||
|
#[arg(long, env = "YAAK_WEB_ALLOW_PRIVATE_NETWORKS", default_value_t = false)]
|
||||||
|
pub allow_private_networks: bool,
|
||||||
|
|
||||||
|
/// Browser origins allowed to call this server (CORS), comma-separated. `*` allows any.
|
||||||
|
/// A local dev instance wants the Vite origin; a hosted instance wants its own web origin.
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "YAAK_WEB_ALLOWED_ORIGINS",
|
||||||
|
default_value = "*",
|
||||||
|
value_delimiter = ','
|
||||||
|
)]
|
||||||
|
pub allowed_origins: Vec<String>,
|
||||||
|
|
||||||
|
/// Largest request the server accepts from the tab (the rendered request JSON, body included).
|
||||||
|
#[arg(long, env = "YAAK_WEB_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
|
||||||
|
pub max_request_bytes: usize,
|
||||||
|
|
||||||
|
/// Largest upstream response body the server will relay before cutting the send off.
|
||||||
|
#[arg(long, env = "YAAK_WEB_MAX_RESPONSE_BYTES", default_value_t = 64 * 1024 * 1024)]
|
||||||
|
pub max_response_bytes: usize,
|
||||||
|
|
||||||
|
/// Ceiling on a send's timeout, in seconds. A request asking for longer (or for no timeout)
|
||||||
|
/// gets this instead.
|
||||||
|
#[arg(long, env = "YAAK_WEB_MAX_TIMEOUT_SECS", default_value_t = 60)]
|
||||||
|
pub max_timeout_secs: u64,
|
||||||
|
|
||||||
|
/// Sends allowed per client IP per minute. 0 disables the limit. This and the concurrency
|
||||||
|
/// cap are the whole of what protects an instance: there is no authentication.
|
||||||
|
#[arg(long, env = "YAAK_WEB_RATE_LIMIT_PER_MINUTE", default_value_t = 120)]
|
||||||
|
pub rate_limit_per_minute: u32,
|
||||||
|
|
||||||
|
/// Sends in flight at once across all clients.
|
||||||
|
#[arg(long, env = "YAAK_WEB_MAX_CONCURRENT", default_value_t = 256)]
|
||||||
|
pub max_concurrent: usize,
|
||||||
|
|
||||||
|
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only turn
|
||||||
|
/// this on behind a load balancer that sets the header; otherwise anyone can spoof their way
|
||||||
|
/// past the rate limit.
|
||||||
|
#[arg(long, env = "YAAK_WEB_TRUST_FORWARDED_FOR", default_value_t = false)]
|
||||||
|
pub trust_forwarded_for: bool,
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
//! Where a send may go.
|
||||||
|
//!
|
||||||
|
//! A hosted sender is, by construction, a machine that makes HTTP requests on
|
||||||
|
//! behalf of strangers. Left alone that is an open relay into whatever network
|
||||||
|
//! it sits on: cloud metadata endpoints, internal admin panels, the database
|
||||||
|
//! next door. So every destination is checked twice — once on the URL before a
|
||||||
|
//! hop is attempted (literal IPs, host allow/deny lists) and once on the
|
||||||
|
//! addresses a hostname actually resolves to, right before the connection is
|
||||||
|
//! made. The second check is the one that matters for a hostname pointing at
|
||||||
|
//! an internal address, and it runs on every redirect hop because the engine
|
||||||
|
//! resolves every hop.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use log::warn;
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use url::Url;
|
||||||
|
use yaak_http::dns::AddressFilter;
|
||||||
|
use yaak_http::sender::{HttpResponse, HttpResponseEvent, HttpSender};
|
||||||
|
use yaak_http::types::SendableHttpRequest;
|
||||||
|
|
||||||
|
/// The destination policy, shared by every send: public addresses only, unless the operator
|
||||||
|
/// has said otherwise. A hosted server's "private network" is the cloud's, not the user's, so
|
||||||
|
/// the default is public-only; a self-hosted instance on a LAN can be told that its private
|
||||||
|
/// network *is* the user's, which is what `--allow-private-networks` means.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct DestinationPolicy {
|
||||||
|
allow_private: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DestinationPolicy {
|
||||||
|
pub fn new(allow_private: bool) -> Self {
|
||||||
|
Self { allow_private }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check a URL before a hop is attempted: scheme and literal IPs. A hostname that passes
|
||||||
|
/// here still has its resolved addresses checked by [`Self::address_filter`].
|
||||||
|
pub fn check_url(&self, raw: &str) -> Result<(), String> {
|
||||||
|
let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?;
|
||||||
|
match url.scheme() {
|
||||||
|
"http" | "https" => {}
|
||||||
|
other => return Err(format!("Refusing to send over {other:?}; only http and https")),
|
||||||
|
}
|
||||||
|
let host = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?;
|
||||||
|
let host = host.trim_matches(|c| c == '[' || c == ']');
|
||||||
|
|
||||||
|
// A literal IP never reaches the resolver, so it is checked here. Hostnames are checked
|
||||||
|
// where their addresses become known.
|
||||||
|
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||||
|
self.check_ip(ip)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The veto the engine's resolver applies to every address a hostname resolves to.
|
||||||
|
pub fn address_filter(&self) -> AddressFilter {
|
||||||
|
let policy = self.clone();
|
||||||
|
Arc::new(move |ip| policy.check_ip(ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_ip(&self, ip: IpAddr) -> Result<(), String> {
|
||||||
|
if self.allow_private {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match non_public_reason(ip) {
|
||||||
|
Some(reason) => Err(format!(
|
||||||
|
"Refusing to connect to {ip}: {reason}. This server only sends to public addresses"
|
||||||
|
)),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why an address is not a public internet address, or `None` if it is one.
|
||||||
|
///
|
||||||
|
/// Every range here is one a hosted relay must never be talked into reaching: the machine
|
||||||
|
/// itself, the network it sits on, and the link-local range where cloud metadata services
|
||||||
|
/// (169.254.169.254) live. IPv4 addresses carried inside fixed-layout IPv6 forms — IPv4-mapped,
|
||||||
|
/// the well-known NAT64 prefix, 6to4 — are unwrapped and judged as IPv4, since that is where
|
||||||
|
/// the packets end up; the NAT64 local-use range is refused outright. This is the stable-Rust
|
||||||
|
/// stand-in for `IpAddr::is_global`, which is still behind `#![feature(ip)]`; a network-specific
|
||||||
|
/// NAT64 prefix is not knowable here.
|
||||||
|
pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => non_public_v4(v4),
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||||
|
return non_public_v4(v4);
|
||||||
|
}
|
||||||
|
if let Some(v4) = embedded_v4(&v6) {
|
||||||
|
return non_public_v4(v4);
|
||||||
|
}
|
||||||
|
if v6.is_loopback() {
|
||||||
|
Some("loopback")
|
||||||
|
} else if v6.is_unspecified() {
|
||||||
|
Some("unspecified")
|
||||||
|
} else if v6.is_unique_local() {
|
||||||
|
Some("unique local (fc00::/7)")
|
||||||
|
} else if v6.is_unicast_link_local() {
|
||||||
|
Some("link-local (fe80::/10)")
|
||||||
|
} else if v6.is_multicast() {
|
||||||
|
Some("multicast")
|
||||||
|
} else if v6.segments()[..3] == [0x64, 0xff9b, 1] {
|
||||||
|
Some("NAT64 local-use (64:ff9b:1::/48)")
|
||||||
|
} else if v6.segments()[..4] == [0x100, 0, 0, 0] {
|
||||||
|
Some("discard-only (100::/64)")
|
||||||
|
} else if (v6.segments()[0] & 0xffc0) == 0xfec0 {
|
||||||
|
Some("site-local (fec0::/10)")
|
||||||
|
} else if v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8 {
|
||||||
|
Some("documentation (2001:db8::/32)")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_public_v4(v4: Ipv4Addr) -> Option<&'static str> {
|
||||||
|
let o = v4.octets();
|
||||||
|
if v4.is_loopback() {
|
||||||
|
Some("loopback (127.0.0.0/8)")
|
||||||
|
} else if v4.is_private() {
|
||||||
|
Some("private (10/8, 172.16/12, 192.168/16)")
|
||||||
|
} else if v4.is_link_local() {
|
||||||
|
Some("link-local (169.254.0.0/16, where cloud metadata lives)")
|
||||||
|
} else if v4.is_unspecified() || o[0] == 0 {
|
||||||
|
Some("this network (0.0.0.0/8)")
|
||||||
|
} else if o[0] == 100 && (o[1] & 0xc0) == 64 {
|
||||||
|
Some("carrier-grade NAT (100.64.0.0/10)")
|
||||||
|
} else if v4.is_broadcast() {
|
||||||
|
Some("broadcast")
|
||||||
|
} else if v4.is_multicast() {
|
||||||
|
Some("multicast (224.0.0.0/4)")
|
||||||
|
} else if o[0] >= 240 {
|
||||||
|
Some("reserved (240.0.0.0/4)")
|
||||||
|
} else if v4.is_documentation() {
|
||||||
|
Some("documentation")
|
||||||
|
} else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
|
||||||
|
Some("IETF protocol assignments (192.0.0.0/24)")
|
||||||
|
} else if o[0] == 198 && (o[1] & 0xfe) == 18 {
|
||||||
|
Some("benchmarking (198.18.0.0/15)")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The IPv4 address an IPv6 address stands for, when it is one of the fixed-layout translation
|
||||||
|
/// forms: the NAT64 well-known prefix (64:ff9b::/96) or 6to4 (2002::/16, IPv4 in the next 32
|
||||||
|
/// bits). The NAT64 local-use range (64:ff9b:1::/48) is a pool operators carve their own
|
||||||
|
/// prefix from, at a length only they know, so it is refused wholesale in [`non_public_reason`]
|
||||||
|
/// rather than decoded — the same call `std`'s (still unstable) `Ipv6Addr::is_global` makes.
|
||||||
|
fn embedded_v4(v6: &Ipv6Addr) -> Option<Ipv4Addr> {
|
||||||
|
let s = v6.segments();
|
||||||
|
let o = v6.octets();
|
||||||
|
if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|x| *x == 0) {
|
||||||
|
return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
|
||||||
|
}
|
||||||
|
if s[0] == 0x2002 {
|
||||||
|
return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5]));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An [`HttpSender`] that checks each hop's URL against the policy before delegating.
|
||||||
|
///
|
||||||
|
/// The engine's redirect loop calls the sender once per hop with the hop's URL, so wrapping
|
||||||
|
/// the sender is what makes `Location:` headers subject to the same rules as the first URL —
|
||||||
|
/// including a redirect to a literal internal IP, which the resolver would never see.
|
||||||
|
pub struct GuardedSender<S> {
|
||||||
|
inner: S,
|
||||||
|
policy: DestinationPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: HttpSender> GuardedSender<S> {
|
||||||
|
pub fn new(inner: S, policy: DestinationPolicy) -> Self {
|
||||||
|
Self { inner, policy }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<S: HttpSender> HttpSender for GuardedSender<S> {
|
||||||
|
async fn send(
|
||||||
|
&self,
|
||||||
|
request: SendableHttpRequest,
|
||||||
|
event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||||
|
) -> yaak_http::error::Result<HttpResponse> {
|
||||||
|
if let Err(reason) = self.policy.check_url(&request.url) {
|
||||||
|
warn!("Refused {} {}: {reason}", request.method, request.url);
|
||||||
|
return Err(yaak_http::error::Error::RequestError(reason));
|
||||||
|
}
|
||||||
|
self.inner.send(request, event_tx).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ip(s: &str) -> IpAddr {
|
||||||
|
s.parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refuses_the_ranges_a_relay_must_never_reach() {
|
||||||
|
for addr in [
|
||||||
|
"127.0.0.1",
|
||||||
|
"127.9.9.9",
|
||||||
|
"10.0.0.1",
|
||||||
|
"172.16.0.1",
|
||||||
|
"172.31.255.255",
|
||||||
|
"192.168.1.1",
|
||||||
|
"169.254.169.254",
|
||||||
|
"169.254.0.1",
|
||||||
|
"0.0.0.0",
|
||||||
|
"100.64.0.1",
|
||||||
|
"255.255.255.255",
|
||||||
|
"224.0.0.1",
|
||||||
|
"240.0.0.1",
|
||||||
|
"::1",
|
||||||
|
"::",
|
||||||
|
"fc00::1",
|
||||||
|
"fd12::1",
|
||||||
|
"fe80::1",
|
||||||
|
"::ffff:127.0.0.1",
|
||||||
|
"::ffff:169.254.169.254",
|
||||||
|
"64:ff9b::7f00:1",
|
||||||
|
"ff02::1",
|
||||||
|
] {
|
||||||
|
assert!(non_public_reason(ip(addr)).is_some(), "{addr} should be refused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_public_addresses() {
|
||||||
|
for addr in [
|
||||||
|
"1.1.1.1",
|
||||||
|
"8.8.8.8",
|
||||||
|
"93.184.216.34",
|
||||||
|
"172.32.0.1",
|
||||||
|
"2606:4700:4700::1111",
|
||||||
|
] {
|
||||||
|
assert!(non_public_reason(ip(addr)).is_none(), "{addr} should be allowed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn literal_private_addresses_in_urls_are_refused() {
|
||||||
|
let policy = DestinationPolicy::new(false);
|
||||||
|
assert!(policy.check_url("http://127.0.0.1/").is_err());
|
||||||
|
assert!(policy.check_url("http://[::1]/").is_err());
|
||||||
|
assert!(policy.check_url("http://169.254.169.254/latest/meta-data").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allow_private_networks_opens_the_local_ranges_but_not_other_schemes() {
|
||||||
|
let policy = DestinationPolicy::new(true);
|
||||||
|
assert!(policy.check_url("http://127.0.0.1/").is_ok());
|
||||||
|
assert!(policy.check_ip(ip("10.0.0.1")).is_ok());
|
||||||
|
assert!(policy.check_ip(ip("169.254.169.254")).is_ok());
|
||||||
|
assert!(policy.check_url("file:///etc/passwd").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_http_schemes() {
|
||||||
|
let policy = DestinationPolicy::new(false);
|
||||||
|
assert!(policy.check_url("ftp://example.com/").is_err());
|
||||||
|
assert!(policy.check_url("file:///etc/passwd").is_err());
|
||||||
|
assert!(policy.check_url("https://example.com/").is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//! Per-client rate limiting, kept deliberately small.
|
||||||
|
//!
|
||||||
|
//! One token bucket per client IP, refilled continuously, in a mutex-guarded
|
||||||
|
//! map that is swept of idle entries as it goes. Good enough to keep one
|
||||||
|
//! caller from monopolising a hosted instance; not a substitute for whatever
|
||||||
|
//! sits in front of it in production.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
pub struct RateLimiter {
|
||||||
|
per_minute: u32,
|
||||||
|
buckets: Mutex<HashMap<IpAddr, Bucket>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Bucket {
|
||||||
|
tokens: f64,
|
||||||
|
last: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
/// `per_minute == 0` disables limiting.
|
||||||
|
pub fn new(per_minute: u32) -> Self {
|
||||||
|
Self { per_minute, buckets: Mutex::new(HashMap::new()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take one token for `client`, or say how long until one is available.
|
||||||
|
pub fn check(&self, client: IpAddr) -> Result<(), Duration> {
|
||||||
|
if self.per_minute == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let capacity = self.per_minute as f64;
|
||||||
|
let per_second = capacity / 60.0;
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
|
||||||
|
// Sweep buckets that have been idle long enough to be full again; there is nothing
|
||||||
|
// to remember about them.
|
||||||
|
if buckets.len() > 1024 {
|
||||||
|
buckets.retain(|_, b| now.duration_since(b.last).as_secs_f64() * per_second < capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bucket = buckets.entry(client).or_insert(Bucket { tokens: capacity, last: now });
|
||||||
|
let elapsed = now.duration_since(bucket.last).as_secs_f64();
|
||||||
|
bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
|
||||||
|
bucket.last = now;
|
||||||
|
|
||||||
|
if bucket.tokens >= 1.0 {
|
||||||
|
bucket.tokens -= 1.0;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let wait = (1.0 - bucket.tokens) / per_second;
|
||||||
|
Err(Duration::from_secs_f64(wait.max(0.001)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_bucket_then_a_wait() {
|
||||||
|
let limiter = RateLimiter::new(3);
|
||||||
|
let ip: IpAddr = "203.0.113.5".parse().unwrap();
|
||||||
|
assert!(limiter.check(ip).is_ok());
|
||||||
|
assert!(limiter.check(ip).is_ok());
|
||||||
|
assert!(limiter.check(ip).is_ok());
|
||||||
|
let wait = limiter.check(ip).expect_err("fourth call in a burst should wait");
|
||||||
|
assert!(wait > Duration::ZERO && wait <= Duration::from_secs(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clients_are_independent_and_zero_disables() {
|
||||||
|
let limiter = RateLimiter::new(1);
|
||||||
|
let a: IpAddr = "203.0.113.5".parse().unwrap();
|
||||||
|
let b: IpAddr = "203.0.113.6".parse().unwrap();
|
||||||
|
assert!(limiter.check(a).is_ok());
|
||||||
|
assert!(limiter.check(a).is_err());
|
||||||
|
assert!(limiter.check(b).is_ok());
|
||||||
|
|
||||||
|
let unlimited = RateLimiter::new(0);
|
||||||
|
for _ in 0..1000 {
|
||||||
|
assert!(unlimited.check(a).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
//! yaak-web: the network half of Yaak in a browser.
|
||||||
|
//!
|
||||||
|
//! A tab can't see a response the way a desktop app can — CORS hides most
|
||||||
|
//! headers, redirects are followed silently, there is no timeline. So the tab
|
||||||
|
//! renders the request and hands it here; this process puts it on the network
|
||||||
|
//! with the desktop's own engine and streams back everything that happened,
|
||||||
|
//! for the tab to store. It keeps nothing: no database, no files, no session.
|
||||||
|
//!
|
||||||
|
//! One binary, configured by flags or `YAAK_WEB_*` environment variables.
|
||||||
|
//! See README.md for running and deploying it, and `guard.rs` for what it
|
||||||
|
//! refuses to talk to.
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod guard;
|
||||||
|
mod limits;
|
||||||
|
mod send;
|
||||||
|
mod wire;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
|
||||||
|
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||||
|
use axum::middleware::{self, Next};
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use clap::Parser;
|
||||||
|
use config::Config;
|
||||||
|
use guard::DestinationPolicy;
|
||||||
|
use limits::RateLimiter;
|
||||||
|
use log::{info, warn};
|
||||||
|
use send::{Refusal, SendLimits};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
use tower_http::compression::CompressionLayer;
|
||||||
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||||
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
use wire::SendRequest;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
config: Arc<Config>,
|
||||||
|
limits: Arc<SendLimits>,
|
||||||
|
rate_limiter: Arc<RateLimiter>,
|
||||||
|
in_flight: Arc<Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
let config = Config::parse();
|
||||||
|
|
||||||
|
let policy = DestinationPolicy::new(config.allow_private_networks);
|
||||||
|
if config.allow_private_networks {
|
||||||
|
warn!(
|
||||||
|
"Sends to loopback, private and link-local addresses are ALLOWED. Only run this way \
|
||||||
|
on an instance strangers cannot reach"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let state = AppState {
|
||||||
|
limits: Arc::new(SendLimits {
|
||||||
|
policy,
|
||||||
|
max_response_bytes: config.max_response_bytes,
|
||||||
|
max_timeout: Duration::from_secs(config.max_timeout_secs),
|
||||||
|
}),
|
||||||
|
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
|
||||||
|
in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
|
||||||
|
config: Arc::new(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||||
|
.allow_headers([header::CONTENT_TYPE])
|
||||||
|
.allow_origin(allowed_origins(&state.config.allowed_origins));
|
||||||
|
|
||||||
|
let api = Router::new()
|
||||||
|
.route("/v1/health", get(health))
|
||||||
|
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay`
|
||||||
|
// on the same router, behind the same policy, limits and auth. Not built; see README.
|
||||||
|
.route("/v1/http/send", post(send_http))
|
||||||
|
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(state.clone());
|
||||||
|
|
||||||
|
let app = match &state.config.serve {
|
||||||
|
Some(dir) => {
|
||||||
|
info!("Serving the web client from {}", dir.display());
|
||||||
|
api.merge(web_router(dir))
|
||||||
|
}
|
||||||
|
None => api,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bind = state.config.bind;
|
||||||
|
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
|
||||||
|
eprintln!("Failed to bind {bind}: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
info!(
|
||||||
|
"yaak-web listening on http://{bind} (rate limit: {}/min)",
|
||||||
|
state.config.rate_limit_per_minute,
|
||||||
|
);
|
||||||
|
|
||||||
|
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
||||||
|
.with_graceful_shutdown(async {
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
info!("Shutting down");
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("server error");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The built web client, served on the same origin as the API.
|
||||||
|
///
|
||||||
|
/// This is what makes a single container zero-configuration: the tab's send URL is a path on
|
||||||
|
/// the page's own origin, so there is no CORS, no second service and no URL to bake in. It is
|
||||||
|
/// only a file server — a send behaves exactly as it does without this flag.
|
||||||
|
///
|
||||||
|
/// Merged as a fallback, so the `/v1` routes are matched first and a request that matches no
|
||||||
|
/// file at all gets `index.html` (the app routes client-side; a deep link must survive a
|
||||||
|
/// refresh).
|
||||||
|
fn web_router(dir: &Path) -> Router {
|
||||||
|
let index = ServeFile::new(dir.join("index.html"));
|
||||||
|
Router::new()
|
||||||
|
// `fallback`, not `not_found_service`: the app's own routes are real pages, so
|
||||||
|
// index.html is served with the 200 the browser expects, not a 404 carrying HTML.
|
||||||
|
.fallback_service(ServeDir::new(dir).fallback(index))
|
||||||
|
.layer(middleware::from_fn(cache_control))
|
||||||
|
.layer(CompressionLayer::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vite gives everything in `/assets` a content-hashed name, so those can be cached forever.
|
||||||
|
/// Everything else — `index.html` above all, including the copy served for an unknown path —
|
||||||
|
/// must be revalidated, or a browser keeps serving the deploy before last.
|
||||||
|
async fn cache_control(req: Request, next: Next) -> Response {
|
||||||
|
let hashed_name = req.uri().path().starts_with("/assets/");
|
||||||
|
let mut res = next.run(req).await;
|
||||||
|
if !res.status().is_success() {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
let is_html = res
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|v| v.starts_with("text/html"));
|
||||||
|
let value = if hashed_name && !is_html {
|
||||||
|
"public, max-age=31536000, immutable"
|
||||||
|
} else {
|
||||||
|
"no-cache"
|
||||||
|
};
|
||||||
|
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_origins(origins: &[String]) -> AllowOrigin {
|
||||||
|
if origins.iter().any(|o| o.trim() == "*") {
|
||||||
|
return AllowOrigin::any();
|
||||||
|
}
|
||||||
|
let parsed: Vec<HeaderValue> =
|
||||||
|
origins.iter().filter_map(|o| HeaderValue::from_str(o.trim()).ok()).collect();
|
||||||
|
AllowOrigin::list(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"maxResponseBytes": state.config.max_response_bytes,
|
||||||
|
"maxTimeoutSecs": state.config.max_timeout_secs,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
|
||||||
|
let message = message.into();
|
||||||
|
(status, Json(json!({ "error": message }))).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client's address for rate limiting: the socket peer, or the first `X-Forwarded-For`
|
||||||
|
/// hop when the operator has said the header can be trusted.
|
||||||
|
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
||||||
|
if config.trust_forwarded_for
|
||||||
|
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
|
||||||
|
&& let Some(first) = forwarded.split(',').next()
|
||||||
|
&& let Ok(ip) = first.trim().parse::<IpAddr>()
|
||||||
|
{
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
peer.ip()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_http(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<SendRequest>,
|
||||||
|
) -> Response {
|
||||||
|
let ip = client_ip(&state.config, &headers, peer);
|
||||||
|
if let Err(wait) = state.rate_limiter.check(ip) {
|
||||||
|
warn!("Rate limited {ip}");
|
||||||
|
let mut res = error_response(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
format!("Rate limit reached; try again in {}s", wait.as_secs().max(1)),
|
||||||
|
);
|
||||||
|
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(wait.as_secs().max(1)));
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(permit) = state.in_flight.clone().try_acquire_owned() else {
|
||||||
|
warn!("At capacity; refusing {ip}");
|
||||||
|
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This server is at capacity");
|
||||||
|
};
|
||||||
|
|
||||||
|
let prepared = match send::prepare(state.limits.clone(), body).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(Refusal::Unsupported(m)) => return error_response(StatusCode::BAD_REQUEST, m),
|
||||||
|
Err(Refusal::Invalid(m)) => return error_response(StatusCode::BAD_REQUEST, m),
|
||||||
|
Err(Refusal::Destination(m)) => {
|
||||||
|
warn!("Refused send from {ip}: {m}");
|
||||||
|
return error_response(StatusCode::FORBIDDEN, m);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let description = prepared.describe();
|
||||||
|
info!("{ip} -> {description}");
|
||||||
|
let started = Instant::now();
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(send::FRAME_CHANNEL_CAPACITY);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
prepared.run(tx).await;
|
||||||
|
send::log_outcome(&description, started, "finished");
|
||||||
|
drop(permit);
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = tokio_stream_from(rx);
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "application/x-ndjson")
|
||||||
|
.header(header::CACHE_CONTROL, "no-store")
|
||||||
|
// Some reverse proxies buffer streamed responses unless told not to
|
||||||
|
.header("x-accel-buffering", "no")
|
||||||
|
.body(Body::from_stream(stream))
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokio_stream_from<T: Send + 'static>(
|
||||||
|
mut rx: tokio::sync::mpsc::Receiver<T>,
|
||||||
|
) -> impl futures_util::Stream<Item = T> + Send + 'static {
|
||||||
|
futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx))
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
//! 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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! What crosses the wire between a tab and this server.
|
||||||
|
//!
|
||||||
|
//! One `POST /v1/http/send` carries a request the tab has already rendered —
|
||||||
|
//! templates resolved, inheritance applied — plus the send settings and the
|
||||||
|
//! cookies the send starts with. The reply is a stream of newline-delimited
|
||||||
|
//! JSON frames: timeline events as they happen, the response head as soon as
|
||||||
|
//! headers arrive, body chunks as they are read, and one terminal frame.
|
||||||
|
//!
|
||||||
|
//! Nothing here names a workspace, a request id, or a response id. The server
|
||||||
|
//! does not know what the tab will call this response; it only knows what came
|
||||||
|
//! back.
|
||||||
|
//!
|
||||||
|
//! The TypeScript side of this contract is generated from these types into
|
||||||
|
//! `bindings/` (`cargo test -p yaak-web`) and published to the tab as
|
||||||
|
//! `@yaakapp-internal/web`, so a change here is a type error there.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
use yaak_models::models::{
|
||||||
|
Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The body of `POST /v1/http/send`.
|
||||||
|
#[derive(Deserialize, Debug, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_web.ts")]
|
||||||
|
pub struct SendRequest {
|
||||||
|
/// The request to send, in the desktop's own model shape but with every template already
|
||||||
|
/// rendered by the tab. The server builds the URL, headers and body from it exactly the way
|
||||||
|
/// the desktop does after rendering.
|
||||||
|
pub request: HttpRequest,
|
||||||
|
/// The resolved settings, values only. Where they came from is the tab's to record in
|
||||||
|
/// its timeline; the server only needs to obey them.
|
||||||
|
pub settings: HttpSendSettings,
|
||||||
|
/// The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cookies: Option<Vec<Cookie>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
|
||||||
|
/// camelCase like every model the tab stores.
|
||||||
|
#[derive(Serialize, Debug, TS)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "snake_case",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
#[ts(export, export_to = "gen_web.ts")]
|
||||||
|
pub enum Frame {
|
||||||
|
/// A timeline event, in the same shape the desktop stores. Interleaved with everything
|
||||||
|
/// else in the order the engine produced it.
|
||||||
|
Event { event: HttpResponseEventData },
|
||||||
|
/// The response head. Sent once, as soon as the final hop's headers are in — before any of
|
||||||
|
/// the body — so the tab can show status and headers while the body streams.
|
||||||
|
Response {
|
||||||
|
status: u16,
|
||||||
|
status_reason: Option<String>,
|
||||||
|
/// The URL that answered, after redirects.
|
||||||
|
url: String,
|
||||||
|
remote_addr: Option<String>,
|
||||||
|
version: Option<String>,
|
||||||
|
headers: Vec<HttpResponseHeader>,
|
||||||
|
/// The headers that were actually sent on the final hop, cookies and all.
|
||||||
|
request_headers: Vec<HttpResponseHeader>,
|
||||||
|
/// `Content-Length` as declared by the server, if it declared one.
|
||||||
|
#[ts(type = "number | null")]
|
||||||
|
content_length: Option<u64>,
|
||||||
|
/// Milliseconds from the start of the send to the response head.
|
||||||
|
#[ts(type = "number")]
|
||||||
|
elapsed_headers: u64,
|
||||||
|
/// Milliseconds spent in DNS on the last lookup, or zero.
|
||||||
|
#[ts(type = "number")]
|
||||||
|
elapsed_dns: u64,
|
||||||
|
},
|
||||||
|
/// A piece of the response body, decompressed, base64-encoded.
|
||||||
|
Body { data: String },
|
||||||
|
/// The send finished. The last frame on a successful stream.
|
||||||
|
Done {
|
||||||
|
/// Milliseconds from the start of the send to the end of the body.
|
||||||
|
#[ts(type = "number")]
|
||||||
|
elapsed: u64,
|
||||||
|
/// Bytes of body relayed, after decompression.
|
||||||
|
#[ts(type = "number")]
|
||||||
|
content_length: u64,
|
||||||
|
/// Bytes on the wire as declared by the server, or the relayed size when unknown.
|
||||||
|
#[ts(type = "number")]
|
||||||
|
content_length_compressed: u64,
|
||||||
|
/// The jar as the send left it, for the tab to persist. `None` when the tab sent none.
|
||||||
|
cookies: Option<Vec<Cookie>>,
|
||||||
|
},
|
||||||
|
/// The send failed. The last frame on a failed stream. Cookies collected before the failure
|
||||||
|
/// still come back — the transaction may have set some before the hop that failed.
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
cookies: Option<Vec<Cookie>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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.25.0"
|
r2d2_sqlite = "0.32"
|
||||||
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,12 +73,14 @@ 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 }
|
||||||
@@ -86,6 +88,7 @@ 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
@@ -1,116 +0,0 @@
|
|||||||
// 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, };
|
|
||||||
+27
-9
@@ -1,19 +1,37 @@
|
|||||||
// 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 GitWatchResult = { unlistenEvent: string, };
|
export type PluginUpdateInfo = { name: string; currentVersion: string; latestVersion: string };
|
||||||
|
|
||||||
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
|
export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
|
||||||
|
|
||||||
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
|
export type UpdateInfo = {
|
||||||
|
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 WatchResult = { unlistenEvent: string, };
|
export type YaakNotification = {
|
||||||
|
timestamp: string;
|
||||||
|
timeout: number | null;
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
message: string;
|
||||||
|
color: string | null;
|
||||||
|
action: YaakNotificationAction | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type 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 };
|
||||||
|
|
||||||
export type YaakNotificationAction = { label: string, url: string, };
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// ts-rs owns bindings/index.ts and rewrites it on export, so this hand-written
|
// ts-rs owns bindings/index.ts and rewrites it on export. What remains here
|
||||||
// entry point is where the generated files come together.
|
// after the RPC schema moved to @yaakapp-internal/rpc-schema is the
|
||||||
export * from "./bindings/gen_rpc";
|
// desktop-only surface: updater and notification types.
|
||||||
export * from "./bindings/index";
|
export * from "./bindings/index";
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
use crate::PluginContextExt;
|
|
||||||
use crate::error::Result;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
|
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
|
||||||
use yaak_models::models::HttpRequestHeader;
|
|
||||||
use yaak_models::queries::workspaces::default_headers;
|
|
||||||
use yaak_plugins::events::GetThemesResponse;
|
|
||||||
use yaak_plugins::manager::PluginManager;
|
|
||||||
use yaak_plugins::native_template_functions::{
|
|
||||||
decrypt_secure_template_function, encrypt_secure_template_function,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Extension trait for accessing the EncryptionManager from Tauri Manager types.
|
|
||||||
pub trait EncryptionManagerExt<'a, R> {
|
|
||||||
fn crypto(&'a self) -> State<'a, EncryptionManager>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, R: Runtime, M: Manager<R>> EncryptionManagerExt<'a, R> for M {
|
|
||||||
fn crypto(&'a self) -> State<'a, EncryptionManager> {
|
|
||||||
self.state::<EncryptionManager>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn cmd_decrypt_template<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
template: &str,
|
|
||||||
) -> Result<String> {
|
|
||||||
let encryption_manager = window.app_handle().state::<EncryptionManager>();
|
|
||||||
let plugin_context = window.plugin_context();
|
|
||||||
Ok(decrypt_secure_template_function(&encryption_manager, &plugin_context, template)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn cmd_secure_template<R: Runtime>(
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
template: &str,
|
|
||||||
) -> Result<String> {
|
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
|
||||||
let plugin_context = window.plugin_context();
|
|
||||||
Ok(encrypt_secure_template_function(
|
|
||||||
plugin_manager,
|
|
||||||
encryption_manager,
|
|
||||||
&plugin_context,
|
|
||||||
template,
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn cmd_get_themes<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> Result<Vec<GetThemesResponse>> {
|
|
||||||
Ok(plugin_manager.get_themes(&window.plugin_context()).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn cmd_enable_encryption<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
workspace_id: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
window.crypto().ensure_workspace_key(workspace_id)?;
|
|
||||||
window.crypto().reveal_workspace_key(workspace_id)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn cmd_reveal_workspace_key<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
workspace_id: &str,
|
|
||||||
) -> Result<String> {
|
|
||||||
Ok(window.crypto().reveal_workspace_key(workspace_id)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
workspace_id: &str,
|
|
||||||
key: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
window.crypto().set_human_key(workspace_id, key)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
workspace_id: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
window.crypto().disable_encryption(workspace_id)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
|
|
||||||
default_headers()
|
|
||||||
}
|
|
||||||
@@ -41,6 +41,9 @@ pub enum Error {
|
|||||||
#[error(transparent)]
|
#[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,7 +2,6 @@ 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;
|
||||||
@@ -10,18 +9,11 @@ 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,7 +2,6 @@ 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};
|
||||||
@@ -21,22 +20,6 @@ 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::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
use yaak::send::{ResponseBody, 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,6 +62,12 @@ 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,
|
||||||
@@ -69,7 +75,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<HttpResponse> {
|
) -> Result<SentHttpRequest> {
|
||||||
send_http_request_with_context(
|
send_http_request_with_context(
|
||||||
window,
|
window,
|
||||||
unrendered_request,
|
unrendered_request,
|
||||||
@@ -90,7 +96,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<HttpResponse> {
|
) -> Result<SentHttpRequest> {
|
||||||
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 =
|
||||||
@@ -110,7 +116,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(response) => Ok(response),
|
Ok(sent) => Ok(sent),
|
||||||
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;
|
||||||
@@ -123,7 +129,12 @@ pub async fn send_http_request_with_context<R: Runtime>(
|
|||||||
}
|
}
|
||||||
r.error = Some(error);
|
r.error = Some(error);
|
||||||
});
|
});
|
||||||
Ok(response_ctx.response().clone())
|
// The send failed, so whatever body exists is the partial one
|
||||||
|
// already on disk under the response's id.
|
||||||
|
Ok(SentHttpRequest {
|
||||||
|
response: response_ctx.response().clone(),
|
||||||
|
body: ResponseBody::Stored,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +147,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<HttpResponse> {
|
) -> Result<SentHttpRequest> {
|
||||||
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());
|
||||||
@@ -165,22 +176,6 @@ 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(result.response)
|
Ok(SentHttpRequest { response: result.response, body: result.response_body })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_http_request<R: Runtime>(
|
|
||||||
window: &WebviewWindow<R>,
|
|
||||||
request: &HttpRequest,
|
|
||||||
) -> Result<(HttpRequest, String)> {
|
|
||||||
let mut new_request = request.clone();
|
|
||||||
|
|
||||||
let (authentication_type, authentication, authentication_context_id) =
|
|
||||||
window.db().resolve_auth_for_http_request(request)?;
|
|
||||||
new_request.authentication_type = authentication_type;
|
|
||||||
new_request.authentication = authentication;
|
|
||||||
|
|
||||||
let headers = window.db().resolve_headers_for_http_request(request)?;
|
|
||||||
new_request.headers = headers;
|
|
||||||
|
|
||||||
Ok((new_request, authentication_context_id))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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;
|
||||||
@@ -13,10 +14,25 @@ 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(),
|
||||||
@@ -30,11 +46,57 @@ pub(crate) async fn import_data<R: Runtime>(
|
|||||||
plugin_manager: &plugin_manager,
|
plugin_manager: &plugin_manager,
|
||||||
plugin_context: &plugin_context,
|
plugin_context: &plugin_context,
|
||||||
workspace_context,
|
workspace_context,
|
||||||
contents: &file,
|
contents,
|
||||||
})
|
})
|
||||||
.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 {
|
||||||
@@ -71,4 +133,22 @@ 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ extern crate core;
|
|||||||
use crate::encoding::read_response_body;
|
use crate::encoding::read_response_body;
|
||||||
use crate::error::Error::GenericError;
|
use crate::error::Error::GenericError;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};
|
use crate::grpc::{build_metadata, metadata_to_map};
|
||||||
use crate::http_request::{resolve_http_request, send_http_request};
|
use crate::http_request::send_http_request;
|
||||||
use crate::import::import_data;
|
use crate::import::{import_data, import_url};
|
||||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||||
use crate::notifications::YaakNotifier;
|
use crate::notifications::YaakNotifier;
|
||||||
use crate::render::{render_grpc_request, render_json_value, render_template};
|
use crate::render::{render_grpc_request, render_template};
|
||||||
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
|
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
|
||||||
use crate::uri_scheme::handle_deep_link;
|
use crate::uri_scheme::handle_deep_link;
|
||||||
use error::Result as YaakResult;
|
use error::Result as YaakResult;
|
||||||
use eventsource_client::{EventParser, SSE};
|
use eventsource_client::{EventParser, SSE};
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use std::collections::HashMap;
|
use std::path::PathBuf;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -29,41 +28,32 @@ use tauri_plugin_log::{Builder, Target, TargetKind, log};
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::task::block_in_place;
|
use tokio::task::block_in_place;
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
use yaak::export::{self, ExportDataParams};
|
use yaak::send::ResponseBody;
|
||||||
use yaak::responses::{is_response_id, response_body_path};
|
use yaak_commands::responses::locate_response_body;
|
||||||
|
use yaak_commands::resolve::resolve_grpc_request;
|
||||||
use yaak_common::command::new_checked_command;
|
use yaak_common::command::new_checked_command;
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||||
use yaak_grpc::{Code, ServiceDefinition};
|
use yaak_grpc::{Code, ServiceDefinition};
|
||||||
use yaak_mac_window::AppHandleMacWindowExt;
|
use yaak_mac_window::AppHandleMacWindowExt;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
|
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||||
WorkspaceMeta,
|
|
||||||
};
|
};
|
||||||
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
||||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
RenderPurpose, ShowToastRequest,
|
||||||
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
|
|
||||||
CallWorkspaceActionRequest, Color, FilterResponse, GetFolderActionsResponse,
|
|
||||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
|
||||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
|
||||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse,
|
|
||||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, InternalEvent,
|
|
||||||
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
|
|
||||||
};
|
};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
|
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
|
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
|
||||||
use yaak_sse::sse::ServerSentEvent;
|
use yaak_sse::sse::ServerSentEvent;
|
||||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||||
use yaak_templates::format_json::format_json;
|
|
||||||
use yaak_templates::strip_json_comments::strip_json_comments;
|
use yaak_templates::strip_json_comments::strip_json_comments;
|
||||||
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
|
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||||
use yaak_tls::find_client_certificate;
|
use yaak_tls::find_client_certificate;
|
||||||
|
|
||||||
mod commands;
|
|
||||||
mod encoding;
|
mod encoding;
|
||||||
mod error;
|
mod error;
|
||||||
mod feedback;
|
mod feedback;
|
||||||
@@ -78,6 +68,7 @@ mod notifications;
|
|||||||
mod plugin_events;
|
mod plugin_events;
|
||||||
mod plugins_ext;
|
mod plugins_ext;
|
||||||
mod render;
|
mod render;
|
||||||
|
mod restart;
|
||||||
mod rpc_ext;
|
mod rpc_ext;
|
||||||
mod sync_ext;
|
mod sync_ext;
|
||||||
mod updates;
|
mod updates;
|
||||||
@@ -183,22 +174,6 @@ impl<R: Runtime> PluginContextExt<R> for WebviewWindow<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, ts_rs::TS)]
|
|
||||||
#[serde(default, rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct AppMetaData {
|
|
||||||
is_dev: bool,
|
|
||||||
version: String,
|
|
||||||
cli_version: Option<String>,
|
|
||||||
name: String,
|
|
||||||
app_data_dir: String,
|
|
||||||
app_log_dir: String,
|
|
||||||
vendored_plugin_dir: String,
|
|
||||||
default_project_dir: String,
|
|
||||||
feature_updater: bool,
|
|
||||||
feature_license: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_metadata<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<AppMetaData> {
|
async fn cmd_metadata<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<AppMetaData> {
|
||||||
let app_data_dir = app_handle.path().app_data_dir()?;
|
let app_data_dir = app_handle.path().app_data_dir()?;
|
||||||
let app_log_dir = app_handle.path().app_log_dir()?;
|
let app_log_dir = app_handle.path().app_log_dir()?;
|
||||||
@@ -238,56 +213,6 @@ async fn detect_cli_version_for_binary(program: &str) -> Option<String> {
|
|||||||
Some(parts.next().unwrap_or(line).to_string())
|
Some(parts.next().unwrap_or(line).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_template_tokens_to_string<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
tokens: Tokens,
|
|
||||||
) -> YaakResult<String> {
|
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
|
||||||
let cb = PluginTemplateCallback::new(
|
|
||||||
plugin_manager,
|
|
||||||
encryption_manager,
|
|
||||||
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
|
|
||||||
RenderPurpose::Preview,
|
|
||||||
);
|
|
||||||
let new_tokens = transform_args(tokens, &cb)?;
|
|
||||||
Ok(new_tokens.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_render_template<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
template: &str,
|
|
||||||
workspace_id: &str,
|
|
||||||
environment_id: Option<&str>,
|
|
||||||
purpose: Option<RenderPurpose>,
|
|
||||||
ignore_error: Option<bool>,
|
|
||||||
) -> YaakResult<String> {
|
|
||||||
let environment_chain =
|
|
||||||
app_handle.db().resolve_environments(workspace_id, None, environment_id)?;
|
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
|
||||||
let result = render_template(
|
|
||||||
template,
|
|
||||||
environment_chain,
|
|
||||||
&PluginTemplateCallback::new(
|
|
||||||
plugin_manager,
|
|
||||||
encryption_manager,
|
|
||||||
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
|
|
||||||
purpose.unwrap_or(RenderPurpose::Preview),
|
|
||||||
),
|
|
||||||
&RenderOptions {
|
|
||||||
error_behavior: match ignore_error {
|
|
||||||
Some(true) => RenderErrorBehavior::ReturnEmpty,
|
|
||||||
_ => RenderErrorBehavior::Throw,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_send_feedback<R: Runtime>(
|
async fn cmd_send_feedback<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
feature: String,
|
feature: String,
|
||||||
@@ -314,7 +239,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
|||||||
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
||||||
) -> YaakResult<Vec<ServiceDefinition>> {
|
) -> YaakResult<Vec<ServiceDefinition>> {
|
||||||
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
||||||
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
|
let (resolved_request, auth_context_id) =
|
||||||
|
resolve_grpc_request(&window.db(), &unrendered_request)?;
|
||||||
|
|
||||||
let environment_chain = app_handle.db().resolve_environments(
|
let environment_chain = app_handle.db().resolve_environments(
|
||||||
&unrendered_request.workspace_id,
|
&unrendered_request.workspace_id,
|
||||||
@@ -374,7 +300,8 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
||||||
) -> YaakResult<String> {
|
) -> YaakResult<String> {
|
||||||
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
||||||
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
|
let (resolved_request, auth_context_id) =
|
||||||
|
resolve_grpc_request(&window.db(), &unrendered_request)?;
|
||||||
let environment_chain = app_handle.db().resolve_environments(
|
let environment_chain = app_handle.db().resolve_environments(
|
||||||
&unrendered_request.workspace_id,
|
&unrendered_request.workspace_id,
|
||||||
unrendered_request.folder_id.as_deref(),
|
unrendered_request.folder_id.as_deref(),
|
||||||
@@ -978,17 +905,22 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
|
async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
|
||||||
app_handle.request_restart();
|
restart::request_restart(&app_handle);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send without saving anything.
|
||||||
|
///
|
||||||
|
/// The response never reaches the database, so its body cannot be read back by
|
||||||
|
/// id later the way a saved response's can. It comes back here instead, which
|
||||||
|
/// is the only copy the caller gets.
|
||||||
async fn cmd_send_ephemeral_request<R: Runtime>(
|
async fn cmd_send_ephemeral_request<R: Runtime>(
|
||||||
mut request: HttpRequest,
|
mut request: HttpRequest,
|
||||||
environment_id: Option<&str>,
|
environment_id: Option<&str>,
|
||||||
cookie_jar_id: Option<&str>,
|
cookie_jar_id: Option<&str>,
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
) -> YaakResult<HttpResponse> {
|
) -> YaakResult<EphemeralHttpResponse> {
|
||||||
let response = HttpResponse::default();
|
let response = HttpResponse::default();
|
||||||
request.id = "".to_string();
|
request.id = "".to_string();
|
||||||
let environment = match environment_id {
|
let environment = match environment_id {
|
||||||
@@ -1007,11 +939,18 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx).await
|
let sent =
|
||||||
}
|
send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
async fn cmd_format_json(text: &str) -> YaakResult<String> {
|
// Blanking the request id above is what makes this send unsaved, so the
|
||||||
Ok(format_json(text, " "))
|
// engine always hands the body back. Failing loudly beats returning an
|
||||||
|
// empty body that reads as "the server sent nothing".
|
||||||
|
let ResponseBody::Returned(body) = sent.body else {
|
||||||
|
return Err(GenericError("Unsaved response did not return a body".to_string()));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(EphemeralHttpResponse { response: sent.response, body })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
|
async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
|
||||||
@@ -1021,59 +960,13 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a response's body is, and what it is meant to be read as.
|
|
||||||
struct ResponseBodyLocation {
|
|
||||||
/// None when the response has no stored body.
|
|
||||||
path: Option<PathBuf>,
|
|
||||||
/// The response's declared `Content-Type`, empty when it has none. An
|
|
||||||
/// ephemeral response has no headers to consult, so its body decodes as
|
|
||||||
/// UTF-8.
|
|
||||||
content_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find a response's body from its id alone.
|
|
||||||
///
|
|
||||||
/// The frontend hands back an id and never a path, so nothing a caller says can
|
|
||||||
/// point this at a file the engine didn't write. Persisted responses carry the
|
|
||||||
/// path they were written to; ephemeral ones (GraphQL introspection) never
|
|
||||||
/// reach the database, but their body still lands at `<response dir>/<id>`,
|
|
||||||
/// which is how [`yaak::send`] builds the path in the first place.
|
|
||||||
fn locate_response_body<R: Runtime>(
|
|
||||||
app_handle: &AppHandle<R>,
|
|
||||||
response_id: &str,
|
|
||||||
) -> YaakResult<ResponseBodyLocation> {
|
|
||||||
if !is_response_id(response_id) {
|
|
||||||
return Err(GenericError(format!("Invalid response ID {response_id}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
match app_handle.db().get_http_response(response_id) {
|
|
||||||
Ok(response) => Ok(ResponseBodyLocation {
|
|
||||||
path: response.body_path.map(PathBuf::from),
|
|
||||||
content_type: response
|
|
||||||
.headers
|
|
||||||
.iter()
|
|
||||||
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
|
|
||||||
.map(|h| h.value.clone())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
}),
|
|
||||||
Err(yaak_models::error::Error::ModelNotFound(_)) => {
|
|
||||||
let response_dir = app_handle.path().app_data_dir()?.join("responses");
|
|
||||||
Ok(ResponseBodyLocation {
|
|
||||||
path: Some(response_body_path(&response_dir, response_id)),
|
|
||||||
content_type: String::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_http_response_body<R: Runtime>(
|
async fn cmd_http_response_body<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
plugin_manager: State<'_, PluginManager>,
|
plugin_manager: State<'_, PluginManager>,
|
||||||
response_id: &str,
|
response_id: &str,
|
||||||
filter: Option<&str>,
|
filter: Option<&str>,
|
||||||
) -> YaakResult<FilterResponse> {
|
) -> YaakResult<FilterResponse> {
|
||||||
let location = locate_response_body(window.app_handle(), response_id)?;
|
let location = locate_response_body(&window.db(), response_id)?;
|
||||||
let Some(body_path) = location.path else {
|
let Some(body_path) = location.path else {
|
||||||
return Ok(FilterResponse { content: String::new(), error: None });
|
return Ok(FilterResponse { content: String::new(), error: None });
|
||||||
};
|
};
|
||||||
@@ -1091,41 +984,11 @@ async fn cmd_http_response_body<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The body's path on this machine, for the desktop host to read or hand to the
|
|
||||||
/// webview's asset protocol.
|
|
||||||
///
|
|
||||||
/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
|
|
||||||
/// the path, and only because it is about to open the file itself. Hosts
|
|
||||||
/// without a filesystem serve the same bytes over HTTP instead.
|
|
||||||
async fn cmd_http_response_body_path<R: Runtime>(
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
response_id: &str,
|
|
||||||
) -> YaakResult<Option<String>> {
|
|
||||||
let location = locate_response_body(&app_handle, response_id)?;
|
|
||||||
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_http_request_body<R: Runtime>(
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
response_id: &str,
|
|
||||||
) -> YaakResult<Option<Vec<u8>>> {
|
|
||||||
let body_id = format!("{}.request", response_id);
|
|
||||||
let chunks = app_handle.blobs().get_chunks(&body_id)?;
|
|
||||||
|
|
||||||
if chunks.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Concatenate all chunks
|
|
||||||
let body: Vec<u8> = chunks.into_iter().flat_map(|c| c.data).collect();
|
|
||||||
Ok(Some(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_get_sse_events<R: Runtime>(
|
async fn cmd_get_sse_events<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
response_id: &str,
|
response_id: &str,
|
||||||
) -> YaakResult<Vec<ServerSentEvent>> {
|
) -> YaakResult<Vec<ServerSentEvent>> {
|
||||||
let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else {
|
let Some(body_path) = locate_response_body(&app_handle.db(), response_id)?.path else {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1148,14 +1011,6 @@ async fn cmd_get_sse_events<R: Runtime>(
|
|||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_get_http_response_events<R: Runtime>(
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
response_id: &str,
|
|
||||||
) -> YaakResult<Vec<HttpResponseEvent>> {
|
|
||||||
let events: Vec<HttpResponseEvent> = app_handle.db().list_http_response_events(response_id)?;
|
|
||||||
Ok(events)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_import_data<R: Runtime>(
|
async fn cmd_import_data<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
file_path: &str,
|
file_path: &str,
|
||||||
@@ -1163,299 +1018,26 @@ async fn cmd_import_data<R: Runtime>(
|
|||||||
import_data(&window, file_path).await
|
import_data(&window, file_path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_http_request_actions<R: Runtime>(
|
async fn cmd_import_url<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
plugin_manager: State<'_, PluginManager>,
|
url: &str,
|
||||||
) -> YaakResult<Vec<GetHttpRequestActionsResponse>> {
|
) -> YaakResult<BatchUpsertResult> {
|
||||||
Ok(plugin_manager.get_http_request_actions(&window.plugin_context()).await?)
|
import_url(&window, url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_websocket_request_actions<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<Vec<GetWebsocketRequestActionsResponse>> {
|
|
||||||
Ok(plugin_manager.get_websocket_request_actions(&window.plugin_context()).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_call_websocket_request_action<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
req: CallWebsocketRequestActionRequest,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<()> {
|
|
||||||
let websocket_request = window.db().get_websocket_request(&req.args.websocket_request.id)?;
|
|
||||||
Ok(plugin_manager
|
|
||||||
.call_websocket_request_action(
|
|
||||||
&window.plugin_context(),
|
|
||||||
CallWebsocketRequestActionRequest {
|
|
||||||
args: CallWebsocketRequestActionArgs { websocket_request },
|
|
||||||
..req
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_workspace_actions<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<Vec<GetWorkspaceActionsResponse>> {
|
|
||||||
Ok(plugin_manager.get_workspace_actions(&window.plugin_context()).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_call_workspace_action<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
req: CallWorkspaceActionRequest,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<()> {
|
|
||||||
let workspace = window.db().get_workspace(&req.args.workspace.id)?;
|
|
||||||
Ok(plugin_manager
|
|
||||||
.call_workspace_action(
|
|
||||||
&window.plugin_context(),
|
|
||||||
CallWorkspaceActionRequest { args: CallWorkspaceActionArgs { workspace }, ..req },
|
|
||||||
)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_folder_actions<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<Vec<GetFolderActionsResponse>> {
|
|
||||||
Ok(plugin_manager.get_folder_actions(&window.plugin_context()).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_call_folder_action<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
req: CallFolderActionRequest,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<()> {
|
|
||||||
let folder = window.db().get_folder(&req.args.folder.id)?;
|
|
||||||
Ok(plugin_manager
|
|
||||||
.call_folder_action(
|
|
||||||
&window.plugin_context(),
|
|
||||||
CallFolderActionRequest { args: CallFolderActionArgs { folder }, ..req },
|
|
||||||
)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_grpc_request_actions<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<Vec<GetGrpcRequestActionsResponse>> {
|
|
||||||
Ok(plugin_manager.get_grpc_request_actions(&window.plugin_context()).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_template_function_summaries<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<Vec<GetTemplateFunctionSummaryResponse>> {
|
|
||||||
let results = plugin_manager.get_template_function_summaries(&window.plugin_context()).await?;
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_template_function_config<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
function_name: &str,
|
|
||||||
values: HashMap<String, JsonPrimitive>,
|
|
||||||
model: AnyModel,
|
|
||||||
_environment_id: Option<&str>,
|
|
||||||
) -> YaakResult<GetTemplateFunctionConfigResponse> {
|
|
||||||
Ok(plugin_manager
|
|
||||||
.get_template_function_config(&window.plugin_context(), function_name, values, model.id())
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<Vec<GetHttpAuthenticationSummaryResponse>> {
|
|
||||||
let results =
|
|
||||||
plugin_manager.get_http_authentication_summaries(&window.plugin_context()).await?;
|
|
||||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_get_http_authentication_config<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
encryption_manager: State<'_, EncryptionManager>,
|
|
||||||
auth_name: &str,
|
|
||||||
values: HashMap<String, JsonPrimitive>,
|
|
||||||
model: AnyModel,
|
|
||||||
environment_id: Option<&str>,
|
|
||||||
) -> YaakResult<GetHttpAuthenticationConfigResponse> {
|
|
||||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
|
||||||
let (workspace_id, folder_id) = match &model {
|
|
||||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
|
||||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
|
||||||
_ => return Err(GenericError("Unsupported model type for authentication config".into())),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolve environment chain and render the values for token lookup
|
|
||||||
let environment_chain = app_handle.db().resolve_environments(
|
|
||||||
&workspace_id,
|
|
||||||
folder_id.as_deref(),
|
|
||||||
environment_id,
|
|
||||||
)?;
|
|
||||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
|
||||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
|
||||||
let cb = PluginTemplateCallback::new(
|
|
||||||
plugin_manager_arc,
|
|
||||||
encryption_manager_arc,
|
|
||||||
&window.plugin_context(),
|
|
||||||
RenderPurpose::Preview,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
|
||||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
|
||||||
let rendered_json =
|
|
||||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::return_empty())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Convert back to HashMap<String, JsonPrimitive>
|
|
||||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
|
||||||
|
|
||||||
Ok(plugin_manager
|
|
||||||
.get_http_authentication_config(
|
|
||||||
&window.plugin_context(),
|
|
||||||
auth_name,
|
|
||||||
rendered_values,
|
|
||||||
model.id(),
|
|
||||||
)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_call_http_request_action<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
req: CallHttpRequestActionRequest,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<()> {
|
|
||||||
Ok(plugin_manager
|
|
||||||
.call_http_request_action(
|
|
||||||
&window.plugin_context(),
|
|
||||||
CallHttpRequestActionRequest {
|
|
||||||
args: CallHttpRequestActionArgs {
|
|
||||||
http_request: resolve_http_request(&window, &req.args.http_request)?.0,
|
|
||||||
..req.args
|
|
||||||
},
|
|
||||||
..req
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_call_grpc_request_action<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
req: CallGrpcRequestActionRequest,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<()> {
|
|
||||||
Ok(plugin_manager
|
|
||||||
.call_grpc_request_action(
|
|
||||||
&window.plugin_context(),
|
|
||||||
CallGrpcRequestActionRequest {
|
|
||||||
args: CallGrpcRequestActionArgs {
|
|
||||||
grpc_request: resolve_grpc_request(&window, &req.args.grpc_request)?.0,
|
|
||||||
..req.args
|
|
||||||
},
|
|
||||||
..req
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_call_http_authentication_action<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
encryption_manager: State<'_, EncryptionManager>,
|
|
||||||
auth_name: &str,
|
|
||||||
action_index: i32,
|
|
||||||
values: HashMap<String, JsonPrimitive>,
|
|
||||||
model: AnyModel,
|
|
||||||
environment_id: Option<&str>,
|
|
||||||
) -> YaakResult<()> {
|
|
||||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
|
||||||
let (workspace_id, folder_id) = match &model {
|
|
||||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
|
||||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
|
||||||
_ => return Err(GenericError("Unsupported model type for authentication action".into())),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolve environment chain and render the values
|
|
||||||
let environment_chain = app_handle.db().resolve_environments(
|
|
||||||
&workspace_id,
|
|
||||||
folder_id.as_deref(),
|
|
||||||
environment_id,
|
|
||||||
)?;
|
|
||||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
|
||||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
|
||||||
let cb = PluginTemplateCallback::new(
|
|
||||||
plugin_manager_arc,
|
|
||||||
encryption_manager_arc,
|
|
||||||
&window.plugin_context(),
|
|
||||||
RenderPurpose::Send,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
|
||||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
|
||||||
let rendered_json =
|
|
||||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
|
|
||||||
|
|
||||||
// Convert back to HashMap<String, JsonPrimitive>
|
|
||||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
|
||||||
|
|
||||||
Ok(plugin_manager
|
|
||||||
.call_http_authentication_action(
|
|
||||||
&window.plugin_context(),
|
|
||||||
auth_name,
|
|
||||||
action_index,
|
|
||||||
rendered_values,
|
|
||||||
&model.id(),
|
|
||||||
)
|
|
||||||
.await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_curl_to_request<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
command: &str,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
workspace_id: &str,
|
|
||||||
) -> YaakResult<HttpRequest> {
|
|
||||||
let import_result = plugin_manager.import_data(&window.plugin_context(), command).await?;
|
|
||||||
|
|
||||||
Ok(import_result
|
|
||||||
.resources
|
|
||||||
.http_requests
|
|
||||||
.get(0)
|
|
||||||
.ok_or(GenericError("No curl command found".to_string()))
|
|
||||||
.map(|r| {
|
|
||||||
let mut request = r.clone();
|
|
||||||
request.workspace_id = workspace_id.into();
|
|
||||||
request.id = "".to_string();
|
|
||||||
request
|
|
||||||
})?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_export_data<R: Runtime>(
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
export_path: &str,
|
|
||||||
workspace_ids: Vec<&str>,
|
|
||||||
include_private_environments: bool,
|
|
||||||
) -> YaakResult<()> {
|
|
||||||
let version = app_handle.package_info().version.to_string();
|
|
||||||
Ok(export::export_data(ExportDataParams {
|
|
||||||
query_manager: &app_handle.db_manager(),
|
|
||||||
yaak_version: &version,
|
|
||||||
export_path: Path::new(export_path),
|
|
||||||
workspace_ids,
|
|
||||||
include_private_environments,
|
|
||||||
})?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes base64 and writes the bytes to a file the user picked.
|
/// Decodes base64 and writes the bytes to a file the user picked.
|
||||||
///
|
///
|
||||||
@@ -1480,20 +1062,6 @@ async fn cmd_save_base64_to_binary<R: Runtime>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_save_response<R: Runtime>(
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
response_id: &str,
|
|
||||||
filepath: &str,
|
|
||||||
) -> YaakResult<()> {
|
|
||||||
let response = app_handle.db().get_http_response(response_id)?;
|
|
||||||
|
|
||||||
let body_path =
|
|
||||||
response.body_path.ok_or(GenericError("Response does not have a body".to_string()))?;
|
|
||||||
fs::copy(body_path, filepath).map_err(|e| GenericError(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_send_http_request<R: Runtime>(
|
async fn cmd_send_http_request<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
@@ -1547,7 +1115,7 @@ async fn cmd_send_http_request<R: Runtime>(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => r,
|
Ok(sent) => sent.response,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let resp = app_handle.db().get_http_response(&response.id)?;
|
let resp = app_handle.db().get_http_response(&response.id)?;
|
||||||
app_handle.db().upsert_http_response(
|
app_handle.db().upsert_http_response(
|
||||||
@@ -1565,101 +1133,6 @@ async fn cmd_send_http_request<R: Runtime>(
|
|||||||
Ok(r)
|
Ok(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_reload_plugins<R: Runtime>(
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<Vec<(String, String)>> {
|
|
||||||
let plugins = app_handle.db().list_plugins()?;
|
|
||||||
let plugin_context =
|
|
||||||
PluginContext::new(Some(window.label().to_string()), window.workspace_id());
|
|
||||||
let errors = plugin_manager.initialize_all_plugins(plugins, &plugin_context).await;
|
|
||||||
Ok(errors)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_plugin_info<R: Runtime>(
|
|
||||||
id: &str,
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> YaakResult<PluginMetadata> {
|
|
||||||
let plugin = app_handle.db().get_plugin(id)?;
|
|
||||||
if let Some(plugin_handle) = plugin_manager
|
|
||||||
.get_plugin_by_dir(plugin.directory.as_str())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
return Ok(plugin_handle.info());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
|
|
||||||
return Ok(metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(fallback_plugin_metadata(&plugin.directory))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
|
|
||||||
let display_name = PathBuf::from(directory)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.filter(|name| !name.is_empty())
|
|
||||||
.unwrap_or(directory)
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
PluginMetadata {
|
|
||||||
version: "Unavailable".to_string(),
|
|
||||||
name: directory.to_string(),
|
|
||||||
display_name,
|
|
||||||
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
|
|
||||||
homepage_url: None,
|
|
||||||
repository_url: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_delete_all_grpc_connections<R: Runtime>(
|
|
||||||
request_id: &str,
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
) -> YaakResult<()> {
|
|
||||||
Ok(app_handle.db().delete_all_grpc_connections_for_request(
|
|
||||||
request_id,
|
|
||||||
&UpdateSource::from_window_label(window.label()),
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_delete_send_history<R: Runtime>(
|
|
||||||
workspace_id: &str,
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
) -> YaakResult<()> {
|
|
||||||
Ok(app_handle.with_tx(|tx| {
|
|
||||||
let source = &UpdateSource::from_window_label(window.label());
|
|
||||||
tx.delete_all_http_responses_for_workspace(workspace_id, source)?;
|
|
||||||
tx.delete_all_grpc_connections_for_workspace(workspace_id, source)?;
|
|
||||||
tx.delete_all_websocket_connections_for_workspace(workspace_id, source)?;
|
|
||||||
Ok(())
|
|
||||||
})?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_delete_all_http_responses<R: Runtime>(
|
|
||||||
request_id: &str,
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
) -> YaakResult<()> {
|
|
||||||
app_handle.db().delete_all_http_responses_for_request(
|
|
||||||
request_id,
|
|
||||||
&UpdateSource::from_window_label(window.label()),
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_get_workspace_meta<R: Runtime>(
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
workspace_id: &str,
|
|
||||||
) -> YaakResult<WorkspaceMeta> {
|
|
||||||
let db = app_handle.db();
|
|
||||||
let workspace = db.get_workspace(workspace_id)?;
|
|
||||||
Ok(db.get_or_create_workspace_meta(&workspace.id)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_new_child_window<R: Runtime>(
|
async fn cmd_new_child_window<R: Runtime>(
|
||||||
parent_window: WebviewWindow<R>,
|
parent_window: WebviewWindow<R>,
|
||||||
@@ -1786,6 +1259,14 @@ pub fn run() {
|
|||||||
|
|
||||||
builder
|
builder
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
let lifecycle_host = yaak_lifecycle::Host::owner()
|
||||||
|
.with_responses_dir(app.path().app_data_dir()?.join("responses"));
|
||||||
|
if let Err(e) =
|
||||||
|
yaak_lifecycle::on_launch(&lifecycle_host, &app.db(), &app.blob_manager())
|
||||||
|
{
|
||||||
|
error!("on_launch hook failed: {e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
// The RPC command registry — every frontend command dispatches
|
// The RPC command registry — every frontend command dispatches
|
||||||
// through this via the single `rpc` Tauri command
|
// through this via the single `rpc` Tauri command
|
||||||
app.manage(rpc_ext::build_rpc_router::<TauriRuntime>());
|
app.manage(rpc_ext::build_rpc_router::<TauriRuntime>());
|
||||||
@@ -1885,15 +1366,16 @@ pub fn run() {
|
|||||||
let info = history::get_or_upsert_launch_info(&h);
|
let info = history::get_or_upsert_launch_info(&h);
|
||||||
debug!("Launched Yaak {:?}", info);
|
debug!("Launched Yaak {:?}", info);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Cancel pending requests
|
RunEvent::WindowEvent { event: WindowEvent::ThemeChanged(_), .. } => {
|
||||||
let h = app_handle.clone();
|
// On macOS this is how OS appearance changes arrive: tao observes
|
||||||
tauri::async_runtime::block_on(async move {
|
// AppleInterfaceThemeChangedNotification and emits it for every window
|
||||||
let db = h.db();
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
let _ = db.cancel_pending_http_responses();
|
if let Some(state) =
|
||||||
let _ = db.cancel_pending_grpc_connections();
|
app_handle.try_state::<yaak_system_appearance::SystemAppearanceState>()
|
||||||
let _ = db.cancel_pending_websocket_connections();
|
{
|
||||||
});
|
yaak_system_appearance::emit_change(app_handle, &state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
@@ -1937,6 +1419,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
RunEvent::Exit => restart::relaunch_if_requested(),
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1979,6 +1462,7 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
|||||||
|
|
||||||
let ev = match ev {
|
let ev = match ev {
|
||||||
Ok(Some(ev)) => ev,
|
Ok(Some(ev)) => ev,
|
||||||
|
// Nothing to say, or the reply comes later from somewhere else.
|
||||||
Ok(None) => return,
|
Ok(None) => return,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to handle plugin event: {e:?}");
|
warn!("Failed to handle plugin event: {e:?}");
|
||||||
@@ -1991,7 +1475,10 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
|||||||
timeout: Some(30000),
|
timeout: Some(30000),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return;
|
// Tell the plugin as well as the user. It is awaiting a
|
||||||
|
// reply, and a toast it cannot see would leave it
|
||||||
|
// waiting for one that never comes.
|
||||||
|
InternalEventPayload::ErrorResponse(ErrorResponse { error: e.to_string() })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,9 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
|||||||
use yaak_models::blob_manager::BlobManager;
|
use yaak_models::blob_manager::BlobManager;
|
||||||
use yaak_models::client_db::ClientDb;
|
use yaak_models::client_db::ClientDb;
|
||||||
use yaak_models::error::Result;
|
use yaak_models::error::Result;
|
||||||
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
|
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||||
use yaak_plugins::manager::PluginManager;
|
|
||||||
|
|
||||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
|
||||||
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
||||||
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
|
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
|
||||||
|
|
||||||
@@ -123,163 +120,12 @@ impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
|
|||||||
/// Extension trait for accessing the BlobManager from Tauri Manager types.
|
/// Extension trait for accessing the BlobManager from Tauri Manager types.
|
||||||
pub trait BlobManagerExt<'a, R> {
|
pub trait BlobManagerExt<'a, R> {
|
||||||
fn blob_manager(&'a self) -> State<'a, BlobManager>;
|
fn blob_manager(&'a self) -> State<'a, BlobManager>;
|
||||||
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, R: Runtime, M: Manager<R>> BlobManagerExt<'a, R> for M {
|
impl<'a, R: Runtime, M: Manager<R>> BlobManagerExt<'a, R> for M {
|
||||||
fn blob_manager(&'a self) -> State<'a, BlobManager> {
|
fn blob_manager(&'a self) -> State<'a, BlobManager> {
|
||||||
self.state::<BlobManager>()
|
self.state::<BlobManager>()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext {
|
|
||||||
let manager = self.state::<BlobManager>();
|
|
||||||
manager.inner().connect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commands for yaak-models
|
|
||||||
use tauri::WebviewWindow;
|
|
||||||
|
|
||||||
pub(crate) fn models_upsert<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
model: AnyModel,
|
|
||||||
) -> Result<String> {
|
|
||||||
let db = window.db();
|
|
||||||
let blobs = window.blob_manager();
|
|
||||||
let source = &UpdateSource::from_window_label(window.label());
|
|
||||||
yaak::models_ops::upsert_model(&db, &blobs, model, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a
|
|
||||||
// blocking thread instead of stalling the main thread and all other IPC.
|
|
||||||
pub(crate) async fn models_delete<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
model: AnyModel,
|
|
||||||
) -> Result<String> {
|
|
||||||
use yaak_models::error::Error::GenericError;
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
let blobs = window.blob_manager();
|
|
||||||
// Use transaction for deletions because it might recurse
|
|
||||||
window.with_tx(|tx| {
|
|
||||||
let source = &UpdateSource::from_window_label(window.label());
|
|
||||||
yaak::models_ops::delete_model(tx, &blobs, model, source)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| GenericError(format!("Delete task failed: {e}")))?
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn models_duplicate<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
model_type: String,
|
|
||||||
model_id: String,
|
|
||||||
) -> Result<String> {
|
|
||||||
// Use transaction for duplications because it might recurse
|
|
||||||
window.with_tx(|tx| {
|
|
||||||
let source = &UpdateSource::from_window_label(window.label());
|
|
||||||
yaak::models_ops::duplicate_model(tx, &model_type, &model_id, source)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn models_websocket_events<R: Runtime>(
|
|
||||||
app_handle: tauri::AppHandle<R>,
|
|
||||||
connection_id: &str,
|
|
||||||
) -> Result<Vec<WebsocketEvent>> {
|
|
||||||
Ok(app_handle.db().list_websocket_events(connection_id)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn models_grpc_events<R: Runtime>(
|
|
||||||
app_handle: tauri::AppHandle<R>,
|
|
||||||
connection_id: &str,
|
|
||||||
) -> Result<Vec<GrpcEvent>> {
|
|
||||||
Ok(app_handle.db().list_grpc_events(connection_id)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn models_get_settings<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Result<Settings> {
|
|
||||||
Ok(app_handle.db().get_settings())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn models_get_graphql_introspection<R: Runtime>(
|
|
||||||
app_handle: tauri::AppHandle<R>,
|
|
||||||
request_id: &str,
|
|
||||||
) -> Result<Option<GraphQlIntrospection>> {
|
|
||||||
Ok(app_handle.db().get_graphql_introspection(request_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn models_upsert_graphql_introspection<R: Runtime>(
|
|
||||||
app_handle: tauri::AppHandle<R>,
|
|
||||||
request_id: &str,
|
|
||||||
workspace_id: &str,
|
|
||||||
content: Option<String>,
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
) -> Result<GraphQlIntrospection> {
|
|
||||||
let source = UpdateSource::from_window_label(window.label());
|
|
||||||
Ok(app_handle.db().upsert_graphql_introspection(workspace_id, request_id, content, &source)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn models_workspace_models<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
workspace_id: Option<&str>,
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> Result<String> {
|
|
||||||
let mut l: Vec<AnyModel> = Vec::new();
|
|
||||||
|
|
||||||
// Add the global models
|
|
||||||
{
|
|
||||||
let db = window.db();
|
|
||||||
l.push(db.get_settings().into());
|
|
||||||
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
|
|
||||||
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
|
|
||||||
}
|
|
||||||
|
|
||||||
let plugins = {
|
|
||||||
let db = window.db();
|
|
||||||
db.list_plugins()?
|
|
||||||
};
|
|
||||||
|
|
||||||
let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;
|
|
||||||
l.append(&mut plugins.into_iter().map(Into::into).collect());
|
|
||||||
|
|
||||||
// Add the workspace children
|
|
||||||
if let Some(wid) = workspace_id {
|
|
||||||
let db = window.db();
|
|
||||||
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
|
|
||||||
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
|
|
||||||
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
|
||||||
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
|
|
||||||
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
|
|
||||||
l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect());
|
|
||||||
l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect());
|
|
||||||
l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect());
|
|
||||||
l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect());
|
|
||||||
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
|
|
||||||
}
|
|
||||||
|
|
||||||
let j = serde_json::to_string(&l)?;
|
|
||||||
|
|
||||||
Ok(escape_str_for_webview(&j))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn escape_str_for_webview(input: &str) -> String {
|
|
||||||
input
|
|
||||||
.chars()
|
|
||||||
.map(|c| {
|
|
||||||
let code = c as u32;
|
|
||||||
// ASCII
|
|
||||||
if code <= 0x7F {
|
|
||||||
c.to_string()
|
|
||||||
// BMP characters encoded normally
|
|
||||||
} else if code < 0xFFFF {
|
|
||||||
format!("\\u{:04X}", code)
|
|
||||||
// Beyond BMP encoded a surrogate pairs
|
|
||||||
} else {
|
|
||||||
let high = ((code - 0x10000) >> 10) + 0xD800;
|
|
||||||
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
|
|
||||||
format!("\\u{:04X}\\u{:04X}", high, low)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize database managers as a plugin (for initialization order).
|
/// Initialize database managers as a plugin (for initialization order).
|
||||||
@@ -305,30 +151,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let db = query_manager.connect();
|
|
||||||
if let Err(err) = db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
|
|
||||||
{
|
|
||||||
error!("Failed to prune model_changes rows on startup: {err:?}");
|
|
||||||
}
|
|
||||||
// Only stream writes that happen after this app launch.
|
// Only stream writes that happen after this app launch.
|
||||||
let cursor = ModelChangeCursor::from_launch_time();
|
let cursor = ModelChangeCursor::from_launch_time();
|
||||||
|
|
||||||
let poll_query_manager = query_manager.clone();
|
let poll_query_manager = query_manager.clone();
|
||||||
|
|
||||||
// GC response bodies orphaned by cascade deletes, which historically
|
|
||||||
// didn't clean the blob DB or responses directory
|
|
||||||
let gc_query_manager = query_manager.clone();
|
|
||||||
let gc_blob_manager = blob_manager.clone();
|
|
||||||
let gc_responses_dir = app_path.join("responses");
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
let db = gc_query_manager.connect();
|
|
||||||
match db.delete_orphaned_response_bodies(&gc_blob_manager, &gc_responses_dir) {
|
|
||||||
Ok(0) => {}
|
|
||||||
Ok(n) => log::info!("Deleted {n} orphaned response bodies"),
|
|
||||||
Err(e) => error!("Failed to delete orphaned response bodies: {e:?}"),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app_handle.manage(query_manager);
|
app_handle.manage(query_manager);
|
||||||
app_handle.manage(blob_manager);
|
app_handle.manage(blob_manager);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use crate::{
|
|||||||
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
|
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
|
||||||
workspace_from_window,
|
workspace_from_window,
|
||||||
};
|
};
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::prelude::BASE64_STANDARD;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use log::error;
|
use log::error;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -16,6 +18,7 @@ use tauri_plugin_opener::OpenerExt;
|
|||||||
use yaak::plugin_events::{
|
use yaak::plugin_events::{
|
||||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||||
};
|
};
|
||||||
|
use yaak::response_body::FileResponseBodyStore;
|
||||||
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;
|
||||||
use yaak_models::models::{HttpResponse, Plugin};
|
use yaak_models::models::{HttpResponse, Plugin};
|
||||||
@@ -54,6 +57,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
|||||||
|
|
||||||
match handle_shared_plugin_event(
|
match handle_shared_plugin_event(
|
||||||
app_handle.db_manager().inner(),
|
app_handle.db_manager().inner(),
|
||||||
|
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
|
||||||
&event.payload,
|
&event.payload,
|
||||||
SharedPluginEventContext {
|
SharedPluginEventContext {
|
||||||
plugin_name: &plugin_name,
|
plugin_name: &plugin_name,
|
||||||
@@ -313,8 +317,13 @@ async fn handle_host_plugin_request<R: Runtime>(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// An ad-hoc request saves nothing, so the engine hands the body
|
||||||
|
// back and this reply is the only place the plugin can get it.
|
||||||
|
let body = http_response.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b));
|
||||||
|
|
||||||
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
|
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
|
||||||
http_response,
|
http_response: http_response.response,
|
||||||
|
body,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
HostRequest::OpenWindow(req) => {
|
HostRequest::OpenWindow(req) => {
|
||||||
|
|||||||
@@ -194,12 +194,6 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
|
|||||||
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
|
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cmd_plugin_init_errors(
|
|
||||||
plugin_manager: State<'_, PluginManager>,
|
|
||||||
) -> Result<Vec<(String, String)>> {
|
|
||||||
Ok(plugin_manager.take_init_errors().await)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_plugins_updates<R: Runtime>(
|
pub async fn cmd_plugins_updates<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
) -> Result<PluginUpdatesResponse> {
|
) -> Result<PluginUpdatesResponse> {
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
use serde_json::Value;
|
//! One import path for rendering, wherever the pieces actually live.
|
||||||
pub use yaak::render::{render_grpc_request, render_http_request};
|
//!
|
||||||
use yaak_models::models::Environment;
|
//! The request renderers are engine code; the template renderers moved to
|
||||||
use yaak_models::render::make_vars_hashmap;
|
//! `yaak-commands` when the template commands did. Callers in this crate do not
|
||||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
//! need to track which is which.
|
||||||
|
|
||||||
pub async fn render_template<T: TemplateCallback>(
|
pub use yaak_models::render::{render_grpc_request, render_http_request};
|
||||||
template: &str,
|
pub use yaak_commands::render::{render_json_value, render_template};
|
||||||
environment_chain: Vec<Environment>,
|
|
||||||
cb: &T,
|
|
||||||
opt: &RenderOptions,
|
|
||||||
) -> yaak_templates::error::Result<String> {
|
|
||||||
let vars = &make_vars_hashmap(environment_chain);
|
|
||||||
parse_and_render(template, vars, cb, &opt).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn render_json_value<T: TemplateCallback>(
|
|
||||||
value: Value,
|
|
||||||
environment_chain: Vec<Environment>,
|
|
||||||
cb: &T,
|
|
||||||
opt: &RenderOptions,
|
|
||||||
) -> yaak_templates::error::Result<Value> {
|
|
||||||
let vars = &make_vars_hashmap(environment_chain);
|
|
||||||
render_json_value_raw(value, vars, cb, opt).await
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use log::{error, info};
|
||||||
|
#[cfg(any(target_os = "macos", test))]
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use tauri::{AppHandle, Runtime};
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
static RELAUNCH_WITH_LAUNCH_SERVICES: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Restart the app without directly spawning the executable on macOS.
|
||||||
|
///
|
||||||
|
/// Tauri's current macOS restart path starts the executable from the dying
|
||||||
|
/// process. Besides inheriting stale process state, that bypasses
|
||||||
|
/// LaunchServices and can leave the replacement app running without an active
|
||||||
|
/// window. Defer the relaunch until `RunEvent::Exit`, when the event loop is
|
||||||
|
/// already shutting down, and hand it to LaunchServices instead.
|
||||||
|
pub fn request_restart<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if current_app_bundle().is_some() {
|
||||||
|
info!("Requesting restart through macOS LaunchServices");
|
||||||
|
RELAUNCH_WITH_LAUNCH_SERVICES.store(true, Ordering::SeqCst);
|
||||||
|
app_handle.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app_handle.request_restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete a pending macOS restart after Tauri has emitted its exit events.
|
||||||
|
pub fn relaunch_if_requested() {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if !RELAUNCH_WITH_LAUNCH_SERVICES.swap(false, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(bundle) = current_app_bundle() else {
|
||||||
|
error!("Failed to resolve the app bundle for restart");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match Command::new("/usr/bin/open")
|
||||||
|
.arg("-n")
|
||||||
|
.arg(&bundle)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(_) => info!("Relaunching {} through LaunchServices", bundle.display()),
|
||||||
|
Err(error) => error!("Failed to relaunch through LaunchServices: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn current_app_bundle() -> Option<PathBuf> {
|
||||||
|
app_bundle_from_executable(&std::env::current_exe().ok()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", test))]
|
||||||
|
fn app_bundle_from_executable(executable: &Path) -> Option<PathBuf> {
|
||||||
|
let macos_dir = executable.parent()?;
|
||||||
|
if macos_dir.file_name()? != "MacOS" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents_dir = macos_dir.parent()?;
|
||||||
|
if contents_dir.file_name()? != "Contents" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bundle = contents_dir.parent()?;
|
||||||
|
if bundle.extension()? != "app" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(bundle.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::app_bundle_from_executable;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_macos_app_bundle() {
|
||||||
|
assert_eq!(
|
||||||
|
app_bundle_from_executable(Path::new(
|
||||||
|
"/Applications/Yaak.app/Contents/MacOS/yaak-app-client"
|
||||||
|
)),
|
||||||
|
Some(PathBuf::from("/Applications/Yaak.app"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_unbundled_executable() {
|
||||||
|
assert_eq!(
|
||||||
|
app_bundle_from_executable(Path::new("/workspace/target/debug/yaak-app-client")),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,10 @@ use crate::error::Result;
|
|||||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri::{AppHandle, Listener, Runtime};
|
use tauri::{AppHandle, Listener, Runtime};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
use ts_rs::TS;
|
use yaak_rpc_schema::WatchResult;
|
||||||
use yaak_sync::error::Error::InvalidSyncDirectory;
|
use yaak_sync::error::Error::InvalidSyncDirectory;
|
||||||
use yaak_sync::sync::{
|
use yaak_sync::sync::{
|
||||||
FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,
|
FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,
|
||||||
@@ -57,13 +56,6 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "index.ts")]
|
|
||||||
pub(crate) struct WatchResult {
|
|
||||||
unlisten_event: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn sync_watch<R, F>(
|
pub(crate) async fn sync_watch<R, F>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
sync_dir: &Path,
|
sync_dir: &Path,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models_ext::QueryManagerExt;
|
use crate::models_ext::QueryManagerExt;
|
||||||
|
use crate::restart;
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
|
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
|
||||||
@@ -75,14 +76,6 @@ impl YaakUpdater {
|
|||||||
auto_download: bool,
|
auto_download: bool,
|
||||||
update_trigger: UpdateTrigger,
|
update_trigger: UpdateTrigger,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
// Only AppImage supports updates on Linux, so skip if it's not
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
if std::env::var("APPIMAGE").is_err() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = window.db().get_settings();
|
let settings = window.db().get_settings();
|
||||||
let update_key = format!("{:x}", md5::compute(settings.id));
|
let update_key = format!("{:x}", md5::compute(settings.id));
|
||||||
self.last_check = Some(Instant::now());
|
self.last_check = Some(Instant::now());
|
||||||
@@ -129,6 +122,18 @@ impl YaakUpdater {
|
|||||||
Some(update) => {
|
Some(update) => {
|
||||||
let w = window.clone();
|
let w = window.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
|
// Only hand the artifact to the updater plugin when this install can
|
||||||
|
// apply it itself; otherwise tell the user how to update instead
|
||||||
|
let install = update_install_method(&update);
|
||||||
|
if install != UpdateInstall::Integrated {
|
||||||
|
info!(
|
||||||
|
"{} available, but this install updates via {install:?}",
|
||||||
|
update.version
|
||||||
|
);
|
||||||
|
notify_external_update(&w, &update, install);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Force native updater if specified (useful if a release broke the UI)
|
// Force native updater if specified (useful if a release broke the UI)
|
||||||
let native_install_mode =
|
let native_install_mode =
|
||||||
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
|
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
|
||||||
@@ -206,6 +211,23 @@ struct UpdateInfo {
|
|||||||
reply_event_id: String,
|
reply_event_id: String,
|
||||||
version: String,
|
version: String,
|
||||||
downloaded: bool,
|
downloaded: bool,
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an update can be applied to this install.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Default, TS)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[ts(export, export_to = "index.ts")]
|
||||||
|
enum UpdateInstall {
|
||||||
|
/// The app downloads and installs it itself
|
||||||
|
#[default]
|
||||||
|
Integrated,
|
||||||
|
/// Flatpak install: updated by `flatpak update` from its remote (e.g. FlatPark)
|
||||||
|
Flatpak,
|
||||||
|
/// Nothing can install it in-app (distro package, Nix, unknown); download by hand
|
||||||
|
Manual,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
||||||
@@ -271,8 +293,12 @@ async fn start_integrated_update<R: Runtime>(
|
|||||||
let _guard = Unlisten { win: window, id: event_id };
|
let _guard = Unlisten { win: window, id: event_id };
|
||||||
|
|
||||||
// 2) Emit the event now that listener is in place
|
// 2) Emit the event now that listener is in place
|
||||||
let info =
|
let info = UpdateInfo {
|
||||||
UpdateInfo { version: update.version.to_string(), downloaded, reply_event_id: reply_id };
|
version: update.version.to_string(),
|
||||||
|
downloaded,
|
||||||
|
install: UpdateInstall::Integrated,
|
||||||
|
reply_event_id: reply_id,
|
||||||
|
};
|
||||||
window
|
window
|
||||||
.emit_to(window.label(), "update_available", &info)
|
.emit_to(window.label(), "update_available", &info)
|
||||||
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
|
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
|
||||||
@@ -305,6 +331,24 @@ async fn start_integrated_update<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell the frontend about an update this install can't apply itself, so the user can be
|
||||||
|
/// told how to get it. Unlike the integrated flow, there is nothing to reply to.
|
||||||
|
fn notify_external_update<R: Runtime>(
|
||||||
|
window: &WebviewWindow<R>,
|
||||||
|
update: &Update,
|
||||||
|
install: UpdateInstall,
|
||||||
|
) {
|
||||||
|
let info = UpdateInfo {
|
||||||
|
version: update.version.to_string(),
|
||||||
|
downloaded: false,
|
||||||
|
install,
|
||||||
|
reply_event_id: generate_id(),
|
||||||
|
};
|
||||||
|
if let Err(e) = window.emit_to(window.label(), "update_available", &info) {
|
||||||
|
warn!("Failed to emit update_available: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
||||||
// If the frontend doesn't respond, fallback to native dialogs
|
// If the frontend doesn't respond, fallback to native dialogs
|
||||||
let confirmed = window
|
let confirmed = window
|
||||||
@@ -332,7 +376,7 @@ async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Upd
|
|||||||
))
|
))
|
||||||
.blocking_show()
|
.blocking_show()
|
||||||
{
|
{
|
||||||
window.app_handle().request_restart();
|
restart::request_restart(window.app_handle());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -375,7 +419,137 @@ fn detect_install_mode() -> Option<&'static str> {
|
|||||||
return Some("nsis");
|
return Some("nsis");
|
||||||
}
|
}
|
||||||
#[allow(unreachable_code)]
|
#[allow(unreachable_code)]
|
||||||
None
|
if !cfg!(target_os = "linux") {
|
||||||
|
None
|
||||||
|
} else if is_flatpak() {
|
||||||
|
Some("flatpak")
|
||||||
|
} else {
|
||||||
|
linux_installer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatpak installs (e.g. FlatPark) are updated by flatpak from their remote; the in-app
|
||||||
|
/// updater can't write inside the sandbox and must not try.
|
||||||
|
fn is_flatpak() -> bool {
|
||||||
|
std::env::var_os("FLATPAK_ID").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How Yaak was installed on Linux, as far as the updater plugin can install into it.
|
||||||
|
///
|
||||||
|
/// The bundle type is stamped into the binary by the bundler, but that only says how the
|
||||||
|
/// binary was *packaged*: third-party packages (AUR, Nix, ...) repackage the .deb, and
|
||||||
|
/// letting dpkg/rpm replace those would stomp on another package manager's files. So a
|
||||||
|
/// deb/rpm install also has to be one the package manager actually owns. The AppImage
|
||||||
|
/// updater needs `$APPIMAGE` since that's the file it replaces.
|
||||||
|
fn linux_installer() -> Option<&'static str> {
|
||||||
|
use tauri::utils::{config::BundleType, platform::bundle_type};
|
||||||
|
match bundle_type() {
|
||||||
|
Some(BundleType::Deb) if package_manager_owns_exe("dpkg", "-S") => Some("deb"),
|
||||||
|
Some(BundleType::Rpm) if package_manager_owns_exe("rpm", "-qf") => Some("rpm"),
|
||||||
|
Some(BundleType::Deb) | Some(BundleType::Rpm) => None,
|
||||||
|
_ if std::env::var_os("APPIMAGE").is_some() => Some("appimage"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `cmd query_arg <current exe>` succeeds, i.e. that package manager knows the
|
||||||
|
/// running executable as one of its files. False when the tool isn't installed at all.
|
||||||
|
fn package_manager_owns_exe(cmd: &str, query_arg: &str) -> bool {
|
||||||
|
let Ok(exe) = std::env::current_exe() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
std::process::Command::new(cmd)
|
||||||
|
.arg(query_arg)
|
||||||
|
.arg(&exe)
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the artifact the server returned can be applied to this install. On Linux the
|
||||||
|
/// server may hand back a different package format than the one installed, Flatpak can't
|
||||||
|
/// be written from inside the sandbox, and unknown install methods (distro packages,
|
||||||
|
/// Nix, ...) can't be updated in-app at all.
|
||||||
|
fn update_install_method(update: &Update) -> UpdateInstall {
|
||||||
|
// Dev-only override to preview the non-integrated flows on any OS:
|
||||||
|
// YAAK_SIMULATE_INSTALL=flatpak|manual
|
||||||
|
if is_dev() {
|
||||||
|
match std::env::var("YAAK_SIMULATE_INSTALL").as_deref() {
|
||||||
|
Ok("flatpak") => return UpdateInstall::Flatpak,
|
||||||
|
Ok("manual") => return UpdateInstall::Manual,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg!(target_os = "linux") {
|
||||||
|
return UpdateInstall::Integrated;
|
||||||
|
}
|
||||||
|
if is_flatpak() {
|
||||||
|
return UpdateInstall::Flatpak;
|
||||||
|
}
|
||||||
|
if artifact_matches_installer(linux_installer(), update.download_url.path()) {
|
||||||
|
UpdateInstall::Integrated
|
||||||
|
} else {
|
||||||
|
UpdateInstall::Manual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the artifact at `url_path` is in the package format `installer` can install.
|
||||||
|
fn artifact_matches_installer(installer: Option<&str>, url_path: &str) -> bool {
|
||||||
|
let path = url_path.to_ascii_lowercase();
|
||||||
|
match installer {
|
||||||
|
Some("deb") => path.ends_with(".deb"),
|
||||||
|
Some("rpm") => path.ends_with(".rpm"),
|
||||||
|
Some("appimage") => path.ends_with(".appimage") || path.ends_with(".appimage.tar.gz"),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::artifact_matches_installer;
|
||||||
|
|
||||||
|
const BASE: &str = "/mountain-loop/yaak/releases/download/v2026.6.0/";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_package_format_is_installable() {
|
||||||
|
let cases = [
|
||||||
|
("deb", "yaak_2026.6.0_amd64.deb"),
|
||||||
|
("deb", "yaak_2026.6.0_arm64.deb"),
|
||||||
|
("rpm", "yaak-2026.6.0-1.x86_64.rpm"),
|
||||||
|
("rpm", "yaak-2026.6.0-1.aarch64.rpm"),
|
||||||
|
("appimage", "yaak_2026.6.0_amd64.AppImage"),
|
||||||
|
("appimage", "yaak_2026.6.0_amd64.AppImage.tar.gz"),
|
||||||
|
];
|
||||||
|
for (installer, asset) in cases {
|
||||||
|
assert!(
|
||||||
|
artifact_matches_installer(Some(installer), &format!("{BASE}{asset}")),
|
||||||
|
"{installer} should install {asset}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mismatched_package_format_is_not_installable() {
|
||||||
|
// What the server returns for every Linux install today
|
||||||
|
let appimage = format!("{BASE}yaak_2026.6.0_amd64.AppImage");
|
||||||
|
assert!(!artifact_matches_installer(Some("deb"), &appimage));
|
||||||
|
assert!(!artifact_matches_installer(Some("rpm"), &appimage));
|
||||||
|
|
||||||
|
let deb = format!("{BASE}yaak_2026.6.0_amd64.deb");
|
||||||
|
assert!(!artifact_matches_installer(Some("rpm"), &deb));
|
||||||
|
assert!(!artifact_matches_installer(Some("appimage"), &deb));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_installer_is_never_installable() {
|
||||||
|
for asset in ["yaak_2026.6.0_amd64.deb", "yaak_2026.6.0_amd64.AppImage"] {
|
||||||
|
assert!(!artifact_matches_installer(None, &format!("{BASE}{asset}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn install_update_maybe_download<R: Runtime>(
|
pub async fn install_update_maybe_download<R: Runtime>(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use yaak_http::cookies::CookieStore;
|
|||||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
|
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
|
||||||
WebsocketEventType, WebsocketRequest,
|
WebsocketEventType,
|
||||||
};
|
};
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||||
@@ -27,19 +27,9 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
|
|||||||
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||||
use yaak_tls::find_client_certificate;
|
use yaak_tls::find_client_certificate;
|
||||||
|
use yaak_commands::resolve::resolve_websocket_request;
|
||||||
use yaak_ws::{WebsocketManager, render_websocket_request};
|
use yaak_ws::{WebsocketManager, render_websocket_request};
|
||||||
|
|
||||||
pub async fn cmd_ws_delete_connections<R: Runtime>(
|
|
||||||
request_id: &str,
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
) -> Result<()> {
|
|
||||||
Ok(app_handle.db().delete_all_websocket_connections_for_request(
|
|
||||||
request_id,
|
|
||||||
&UpdateSource::from_window_label(window.label()),
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cmd_ws_send<R: Runtime>(
|
pub async fn cmd_ws_send<R: Runtime>(
|
||||||
connection_id: &str,
|
connection_id: &str,
|
||||||
environment_id: Option<&str>,
|
environment_id: Option<&str>,
|
||||||
@@ -86,7 +76,7 @@ async fn send_websocket_message<R: Runtime>(
|
|||||||
environment_id,
|
environment_id,
|
||||||
)?;
|
)?;
|
||||||
let (resolved_request, _auth_context_id) =
|
let (resolved_request, _auth_context_id) =
|
||||||
resolve_websocket_request(&window, &unrendered_request)?;
|
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
let 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());
|
||||||
let request = render_websocket_request(
|
let request = render_websocket_request(
|
||||||
@@ -165,7 +155,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
|
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
|
||||||
let settings = app_handle.db().get_settings();
|
let settings = app_handle.db().get_settings();
|
||||||
let (resolved_request, auth_context_id) =
|
let (resolved_request, auth_context_id) =
|
||||||
resolve_websocket_request(&window, &unrendered_request)?;
|
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
let 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());
|
||||||
let request = render_websocket_request(
|
let request = render_websocket_request(
|
||||||
@@ -465,23 +455,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
Ok(connection)
|
Ok(connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve inherited authentication and headers for a websocket request
|
|
||||||
fn resolve_websocket_request<R: Runtime>(
|
|
||||||
window: &WebviewWindow<R>,
|
|
||||||
request: &WebsocketRequest,
|
|
||||||
) -> Result<(WebsocketRequest, String)> {
|
|
||||||
let mut new_request = request.clone();
|
|
||||||
|
|
||||||
let (authentication_type, authentication, authentication_context_id) =
|
|
||||||
window.db().resolve_auth_for_websocket_request(request)?;
|
|
||||||
new_request.authentication_type = authentication_type;
|
|
||||||
new_request.authentication = authentication;
|
|
||||||
|
|
||||||
let headers = window.db().resolve_headers_for_websocket_request(request)?;
|
|
||||||
new_request.headers = headers;
|
|
||||||
|
|
||||||
Ok((new_request, authentication_context_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert WS URL to HTTP URL for cookie filtering
|
/// Convert WS URL to HTTP URL for cookie filtering
|
||||||
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
|
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dark-light = "2.0.0"
|
dark-light = "2.0.0"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
dispatch2 = "0.3.0"
|
||||||
|
objc2-app-kit = { version = "0.3.1", features = ["NSAppearance", "NSApplication", "NSResponder"] }
|
||||||
|
objc2-foundation = { version = "0.3.1", features = ["NSArray", "NSString", "NSUserDefaults"] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
tauri = { workspace = true }
|
tauri = { workspace = true }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user