mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-03 08:27:33 +02:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64b9479deb | ||
|
|
6796569466 | ||
|
|
72d3bda769 | ||
|
|
bd932ce85f | ||
|
|
d2d4b80a09 | ||
|
|
d461c982ec | ||
|
|
8eebee460e | ||
|
|
81a2a5d955 | ||
|
|
e63a87718c | ||
|
|
222ea9cb83 | ||
|
|
360c098b09 | ||
|
|
fce1039bac | ||
|
|
f18f93d613 | ||
|
|
661a384bed | ||
|
|
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 |
@@ -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
|
||||
- name: Run Rust Tests
|
||||
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
|
||||
|
||||
@@ -26,19 +26,19 @@ jobs:
|
||||
os: "macos"
|
||||
runtime: "wry"
|
||||
targets: "x86_64-apple-darwin"
|
||||
- platform: "ubuntu-24.04"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "x64"
|
||||
os: "ubuntu"
|
||||
runtime: "wry"
|
||||
targets: ""
|
||||
- platform: "ubuntu-24.04-arm"
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: '--config ./tauri.release.conf.json --config ''{"build":{"features":["updater","license","wry"]}}'''
|
||||
yaak_arch: "arm64"
|
||||
os: "ubuntu"
|
||||
runtime: "wry"
|
||||
targets: ""
|
||||
- platform: "ubuntu-24.04"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: >-
|
||||
--bundles deb
|
||||
--config ./tauri.release.conf.json
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
os: "ubuntu"
|
||||
runtime: "cef"
|
||||
targets: ""
|
||||
- platform: "ubuntu-24.04-arm"
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: >-
|
||||
--bundles deb
|
||||
--config ./tauri.release.conf.json
|
||||
@@ -103,6 +103,18 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
||||
# crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
|
||||
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
|
||||
# through to host glibc headers ("bits/libc-header-start.h" not found).
|
||||
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
|
||||
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
|
||||
# this compiler, so the shipped binary keeps 22.04's glibc floor.
|
||||
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
|
||||
chmod +x /tmp/llvm.sh
|
||||
sudo /tmp/llvm.sh 18
|
||||
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
|
||||
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
|
||||
@@ -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 }}
|
||||
@@ -1,3 +0,0 @@
|
||||
**/bindings/**
|
||||
**/routeTree.gen.ts
|
||||
crates/yaak-templates/pkg/**
|
||||
Generated
+71
-2
@@ -619,6 +619,8 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
@@ -627,10 +629,15 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -651,6 +658,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3276,6 +3284,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -9531,6 +9545,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9539,12 +9554,22 @@ version = "0.6.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -9569,6 +9594,7 @@ version = "0.1.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -11197,9 +11223,12 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"log 0.4.29",
|
||||
"md5 0.8.0",
|
||||
"rusqlite",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
@@ -11277,6 +11306,7 @@ dependencies = [
|
||||
"yaak-grpc",
|
||||
"yaak-http",
|
||||
"yaak-license",
|
||||
"yaak-lifecycle",
|
||||
"yaak-mac-window",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
@@ -11342,6 +11372,7 @@ dependencies = [
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-templates",
|
||||
@@ -11490,7 +11521,6 @@ dependencies = [
|
||||
"log 0.4.29",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"regex 1.11.1",
|
||||
"reqwest 0.12.20",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -11526,6 +11556,14 @@ dependencies = [
|
||||
"yaak-models",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-lifecycle"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"yaak-models",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-mac-window"
|
||||
version = "0.1.0"
|
||||
@@ -11559,8 +11597,10 @@ dependencies = [
|
||||
"sha2",
|
||||
"thiserror 2.0.17",
|
||||
"ts-rs",
|
||||
"urlencoding",
|
||||
"yaak-core",
|
||||
"yaak-database",
|
||||
"yaak-templates",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11686,7 +11726,10 @@ name = "yaak-system-appearance"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dark-light",
|
||||
"dispatch2",
|
||||
"log 0.4.29",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation 0.3.1",
|
||||
"tauri",
|
||||
]
|
||||
|
||||
@@ -11732,7 +11775,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-web"
|
||||
name = "yaak-wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
@@ -11745,6 +11788,32 @@ dependencies = [
|
||||
"sqlite-wasm-vfs",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
"yaak-templates",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"clap",
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"log 0.4.29",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"ts-rs",
|
||||
"url",
|
||||
"uuid",
|
||||
"yaak-http",
|
||||
"yaak-models",
|
||||
]
|
||||
|
||||
|
||||
+5
-1
@@ -14,6 +14,7 @@ members = [
|
||||
"crates/yaak-git",
|
||||
"crates/yaak-grpc",
|
||||
"crates/yaak-http",
|
||||
"crates/yaak-lifecycle",
|
||||
"crates/yaak-models",
|
||||
"crates/yaak-plugins",
|
||||
"crates/yaak-sse",
|
||||
@@ -21,11 +22,13 @@ members = [
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-tls",
|
||||
"crates/yaak-ws",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-wasm",
|
||||
"crates/yaak-api",
|
||||
"crates/yaak-proxy",
|
||||
# Proxy-specific crates
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
# Server crates (the browser tier's hosted send executor)
|
||||
"crates-server/yaak-web",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Tauri-specific crates
|
||||
@@ -77,6 +80,7 @@ yaak-crypto = { path = "crates/yaak-crypto" }
|
||||
yaak-git = { path = "crates/yaak-git" }
|
||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||
yaak-http = { path = "crates/yaak-http" }
|
||||
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
||||
yaak-models = { path = "crates/yaak-models" }
|
||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||
yaak-sse = { path = "crates/yaak-sse" }
|
||||
|
||||
@@ -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,33 +1,42 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { SettingsTab } from "../components/Settings/Settings";
|
||||
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { router } from "../lib/router";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
// Allow tab with optional subtab (e.g., "plugins:installed")
|
||||
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
||||
|
||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
|
||||
mutationKey: ["open_settings"],
|
||||
mutationFn: async (tab) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
const to = "/workspaces/$workspaceId/settings" as const;
|
||||
const params = { workspaceId };
|
||||
const search = { tab: (tab ?? undefined) as SettingsTab | undefined };
|
||||
|
||||
// Settings is its own window where the host has windows to give. Where it
|
||||
// doesn't — a browser tab — the same route opens in place, which is the
|
||||
// whole difference: it is already a route, not a separate app.
|
||||
// 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) {
|
||||
await router.navigate({ to, params, search });
|
||||
// Imported here so Settings stays out of the startup bundle, the way the
|
||||
// route that renders it on desktop already keeps it
|
||||
const { default: Settings } = await import("../components/Settings/Settings");
|
||||
showDialog({
|
||||
id: "settings",
|
||||
size: "md",
|
||||
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
|
||||
noPadding: true,
|
||||
noScroll: true,
|
||||
// Keyed so opening a specific tab while the dialog is already up moves to it
|
||||
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const location = router.buildLocation({ to, params, search });
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId/settings",
|
||||
params: { workspaceId },
|
||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||
});
|
||||
|
||||
await rpc("cmd_new_child_window", {
|
||||
url: location.href,
|
||||
|
||||
@@ -67,7 +67,14 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
||||
)}
|
||||
// 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
|
||||
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
||||
onClick={
|
||||
subEnvironments.length === 0
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
editEnvironment(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...buttonProps}
|
||||
>
|
||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { jsoncLanguage } from "@shopify/lang-jsonc";
|
||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
|
||||
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
handleRefresh,
|
||||
jsonCompletion,
|
||||
@@ -11,12 +10,20 @@ import {
|
||||
stateExtensions,
|
||||
updateSchema,
|
||||
} from "codemirror-json-schema";
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReflectResponseService } from "../hooks/useGrpc";
|
||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||
import { showAlert } from "../lib/alert";
|
||||
import { showConfirm } from "../lib/confirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
import { Button } from "./core/Button";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||
@@ -29,6 +36,11 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
|
||||
protoFiles: string[];
|
||||
};
|
||||
|
||||
type MethodSchema =
|
||||
| { type: "none" }
|
||||
| { type: "schema"; schema: JsonSchema }
|
||||
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
|
||||
|
||||
export function GrpcEditor({
|
||||
services,
|
||||
reflectionError,
|
||||
@@ -42,21 +54,16 @@ export function GrpcEditor({
|
||||
setEditorView(h);
|
||||
}, []);
|
||||
|
||||
// Find the schema for the selected service and method and update the editor
|
||||
useEffect(() => {
|
||||
if (
|
||||
editorView == null ||
|
||||
services === null ||
|
||||
request.service === null ||
|
||||
request.method === null
|
||||
) {
|
||||
return;
|
||||
// Find the schema for the selected service and method
|
||||
const methodSchema = useMemo<MethodSchema>(() => {
|
||||
if (services === null || request.service === null || request.method === null) {
|
||||
return { type: "none" };
|
||||
}
|
||||
|
||||
const s = services.find((s) => s.name === request.service);
|
||||
if (s == null) {
|
||||
console.log("Failed to find service", { service: request.service, services });
|
||||
showAlert({
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-service-error",
|
||||
title: "Couldn't Find Service",
|
||||
body: (
|
||||
@@ -64,14 +71,14 @@ export function GrpcEditor({
|
||||
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
log: ["Failed to find service", { service: request.service, services }],
|
||||
};
|
||||
}
|
||||
|
||||
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
||||
if (request.method != null && schema == null) {
|
||||
console.log("Failed to find method", { method: request.method, methods: s?.methods });
|
||||
showAlert({
|
||||
if (schema == null) {
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-schema-error",
|
||||
title: "Couldn't Find Method",
|
||||
body: (
|
||||
@@ -80,18 +87,15 @@ export function GrpcEditor({
|
||||
<InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema == null) {
|
||||
return;
|
||||
log: ["Failed to find method", { method: request.method, methods: s.methods }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
updateSchema(editorView, JSON.parse(schema));
|
||||
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
|
||||
} catch (err) {
|
||||
showAlert({
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-parse-schema-error",
|
||||
title: "Failed to Parse Schema",
|
||||
body: (
|
||||
@@ -103,9 +107,22 @@ export function GrpcEditor({
|
||||
<FormattedError>{String(err)}</FormattedError>
|
||||
</VStack>
|
||||
),
|
||||
});
|
||||
log: ["Failed to parse schema", err],
|
||||
};
|
||||
}
|
||||
}, [editorView, services, request.method, request.service]);
|
||||
}, [services, request.method, request.service]);
|
||||
|
||||
useEffect(() => {
|
||||
if (methodSchema.type !== "error") return;
|
||||
console.log(...methodSchema.log);
|
||||
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
|
||||
}, [methodSchema]);
|
||||
|
||||
// Update the editor whenever the schema changes
|
||||
useEffect(() => {
|
||||
if (editorView == null || methodSchema.type !== "schema") return;
|
||||
updateSchema(editorView, methodSchema.schema as JSONSchema7);
|
||||
}, [editorView, methodSchema]);
|
||||
|
||||
const extraExtensions = useMemo(
|
||||
() => [
|
||||
@@ -124,45 +141,145 @@ export function GrpcEditor({
|
||||
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
|
||||
reflectionError = reflectionUnavailable ? undefined : reflectionError;
|
||||
|
||||
const handleGenerateExample = useCallback(async () => {
|
||||
if (methodSchema.type !== "schema") return;
|
||||
|
||||
if (request.message.trim() !== "") {
|
||||
const confirmed = await showConfirm({
|
||||
id: "grpc-generate-example",
|
||||
title: "Generate Example",
|
||||
description: "The current message will be replaced with an example.",
|
||||
confirmText: "Generate",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
|
||||
await patchModel(request, { message });
|
||||
|
||||
// Force the editor to pick up the new message
|
||||
wasUpdatedExternally(request.id);
|
||||
}, [methodSchema, request]);
|
||||
|
||||
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
|
||||
// reaches it without threading a refetch down from the connection layout.
|
||||
const handleReloadSchema = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
|
||||
[request.id],
|
||||
);
|
||||
|
||||
const handleShowReflectionError = useCallback(() => {
|
||||
showDialog({
|
||||
id: "grpc-reflection-error",
|
||||
title: "Reflection Failed",
|
||||
size: "sm",
|
||||
render: ({ hide }) => (
|
||||
<>
|
||||
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
|
||||
<div className="w-full my-4">
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
hide();
|
||||
await handleReloadSchema();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}, [handleReloadSchema, reflectionError]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
|
||||
<Button
|
||||
size="xs"
|
||||
color={
|
||||
reflectionLoading
|
||||
? "secondary"
|
||||
: reflectionUnavailable
|
||||
? "info"
|
||||
: reflectionError
|
||||
? "danger"
|
||||
: "secondary"
|
||||
}
|
||||
isLoading={reflectionLoading}
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
id: "reflection-failed",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
}}
|
||||
// Matches the GraphQL editor: one always-visible control labelled by schema state,
|
||||
// with everything schema-related behind it.
|
||||
<div key="schema" className="opacity-100!">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
// Hidden for servers without reflection, which isn't an error
|
||||
hidden: !reflectionError,
|
||||
type: "content",
|
||||
label: (
|
||||
<Banner color="danger">
|
||||
<p className="mb-1">Reflection failed</p>
|
||||
<Button
|
||||
size="xs"
|
||||
color="danger"
|
||||
variant="border"
|
||||
onClick={handleShowReflectionError}
|
||||
>
|
||||
View Error
|
||||
</Button>
|
||||
</Banner>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Generate Example Message",
|
||||
leftSlot: <Icon icon="magic_wand" />,
|
||||
disabled: methodSchema.type !== "schema",
|
||||
onSelect: handleGenerateExample,
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Reload Schema",
|
||||
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: handleReloadSchema,
|
||||
},
|
||||
{
|
||||
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => {
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
id: "grpc-configure-schema",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{reflectionLoading
|
||||
? "Inspecting Schema"
|
||||
: reflectionUnavailable
|
||||
? "Select Proto Files"
|
||||
: reflectionError
|
||||
? "Server Error"
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount("File", protoFiles.length)
|
||||
: services != null && protoFiles.length === 0
|
||||
? "Schema Detected"
|
||||
: "Select Schema"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
title="Schema"
|
||||
forDropdown
|
||||
isLoading={reflectionLoading}
|
||||
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
|
||||
>
|
||||
{reflectionLoading
|
||||
? "Inspecting Schema"
|
||||
: reflectionUnavailable
|
||||
? "Select Proto Files"
|
||||
: reflectionError
|
||||
? "Server Error"
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount("File", protoFiles.length)
|
||||
: services != null
|
||||
? "Schema Detected"
|
||||
: "Select Schema"}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>,
|
||||
],
|
||||
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
|
||||
[
|
||||
handleGenerateExample,
|
||||
handleReloadSchema,
|
||||
handleShowReflectionError,
|
||||
methodSchema.type,
|
||||
protoFiles.length,
|
||||
reflectionError,
|
||||
reflectionLoading,
|
||||
reflectionUnavailable,
|
||||
services,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -194,13 +194,6 @@ export function GrpcRequestPane({
|
||||
type: "default",
|
||||
shortLabel: o.label,
|
||||
}))}
|
||||
itemsAfter={[
|
||||
{
|
||||
label: "Refresh",
|
||||
type: "default",
|
||||
leftSlot: <Icon size="sm" icon="refresh" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
import {
|
||||
type Folder,
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
type ImportPlanItem,
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { pluralize } from "../lib/pluralize";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
||||
import { CheckboxTree } from "./core/CheckboxTree";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
|
||||
interface Props {
|
||||
importFile: (filePath: string) => Promise<void>;
|
||||
importUrl: (url: string) => Promise<void>;
|
||||
currentWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
selectedFolder: Folder | null;
|
||||
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
listSources: (workspaceId: string) => Promise<ImportSource[]>;
|
||||
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
|
||||
commit: (plan: ImportPlan) => Promise<void>;
|
||||
cancel: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,10 +54,74 @@ function fileName(path: string): string {
|
||||
return path.split(/[/\\]/).at(-1) || path;
|
||||
}
|
||||
|
||||
export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
/**
|
||||
* Loads the current workspace's linked sources before rendering the dialog, so the inner
|
||||
* component can construct its initial state (prefilled path, destination) in one pass instead of
|
||||
* patching it in with effects after the first paint.
|
||||
*/
|
||||
export function ImportDataDialog(props: Props) {
|
||||
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
|
||||
const { currentWorkspace, listSources } = props;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
|
||||
load
|
||||
.then((sources) => {
|
||||
if (!cancelled) setInitialSources(sources);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setInitialSources([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentWorkspace, listSources]);
|
||||
|
||||
if (initialSources == null) return null;
|
||||
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
|
||||
}
|
||||
|
||||
function latestSource(sources: ImportSource[]): ImportSource | null {
|
||||
return sources.reduce<ImportSource | null>(
|
||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
function LoadedImportDataDialog({
|
||||
currentWorkspace,
|
||||
workspaces,
|
||||
selectedFolder,
|
||||
planFile,
|
||||
planUrl,
|
||||
listSources,
|
||||
findSourcesForOrigin,
|
||||
commit,
|
||||
cancel,
|
||||
onError,
|
||||
initialSources,
|
||||
}: Props & { initialSources: ImportSource[] }) {
|
||||
// A workspace with a linked source is probably being re-imported, so start from that source
|
||||
const prefill = latestSource(initialSources);
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
||||
const [items, setItems] = useState<ImportPlanItem[]>([]);
|
||||
// null means no explicit choice yet, so the default below applies
|
||||
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
|
||||
null,
|
||||
);
|
||||
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
|
||||
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
|
||||
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
|
||||
prefill != null ? initialSources : [],
|
||||
);
|
||||
const [originSources, setOriginSources] = useState<ImportSource[]>(
|
||||
prefill != null ? [prefill] : [],
|
||||
);
|
||||
// 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 [source, setSource] = useState<string | null>(prefill?.origin ?? null);
|
||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -65,25 +152,277 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
});
|
||||
}, [isHovering, setSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trimmedSource === "") {
|
||||
setOriginSources([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
|
||||
.then((sources) => {
|
||||
if (!cancelled) setOriginSources(sources);
|
||||
})
|
||||
.catch(() => setOriginSources([]));
|
||||
}, 300);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [trimmedSource, filePath, findSourcesForOrigin]);
|
||||
|
||||
// The one workspace this file is linked to, if there is exactly one.
|
||||
const linkedWorkspace = useMemo(() => {
|
||||
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
|
||||
if (ids.length !== 1) return null;
|
||||
return workspaces.find((w) => w.id === ids[0]) ?? null;
|
||||
}, [originSources, workspaces]);
|
||||
|
||||
// A file linked to the current workspace defaults back into it, so re-importing doesn't
|
||||
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
|
||||
// silently targeting a workspace that is neither new nor current is too surprising. An
|
||||
// explicit choice always wins.
|
||||
const destinationKind =
|
||||
destinationChoice ??
|
||||
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
|
||||
|
||||
const destinationWorkspaceId =
|
||||
destinationKind === "current"
|
||||
? (currentWorkspace?.id ?? null)
|
||||
: destinationKind === "other"
|
||||
? otherWorkspaceId
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (destinationWorkspaceId == null) {
|
||||
setLinkedSources([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
listSources(destinationWorkspaceId)
|
||||
.then((sources) => {
|
||||
if (!cancelled) setLinkedSources(sources);
|
||||
})
|
||||
.catch(() => setLinkedSources([]));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [destinationWorkspaceId, listSources]);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
||||
if (selected == null) return;
|
||||
selectSource(selected);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
|
||||
// is also the destination.
|
||||
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
|
||||
|
||||
const destination = (): ImportDestination => {
|
||||
if (destinationWorkspaceId == null) {
|
||||
return { type: "new_workspace" };
|
||||
}
|
||||
return {
|
||||
type: "existing_workspace",
|
||||
workspaceId: destinationWorkspaceId,
|
||||
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (filePath != null) {
|
||||
await importFile(filePath);
|
||||
} else {
|
||||
await importUrl(trimmedSource);
|
||||
}
|
||||
const nextPlan =
|
||||
filePath != null
|
||||
? await planFile(filePath, destination())
|
||||
: await planUrl(trimmedSource, destination());
|
||||
setPlan(nextPlan);
|
||||
setItems(nextPlan.items);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (plan == null) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await commit({ ...plan, items });
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const itemTree = useMemo(() => buildItemTree(items), [items]);
|
||||
|
||||
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
|
||||
// updates toggle together, while removals only ever cascade beneath a removed folder. Checking
|
||||
// anything also brings back the folders it needs to live in.
|
||||
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
|
||||
const targets = new Set(
|
||||
collectItems(node)
|
||||
.filter((i) => togglesWith(node.data, i))
|
||||
.map((i) => i.modelId),
|
||||
);
|
||||
if (checked) {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const ancestor of ancestorsOf(node.data, byId)) {
|
||||
if (ancestor.action === "not_imported") targets.add(ancestor.modelId);
|
||||
}
|
||||
}
|
||||
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
|
||||
};
|
||||
|
||||
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
|
||||
setItems((prev) =>
|
||||
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
// A row the user can't meaningfully toggle on its own: a planned resource inside a deselected
|
||||
// new folder can't exist, and a removed folder takes its contents with it.
|
||||
const disabledIds = useMemo(() => {
|
||||
const disabled = new Set<string>();
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const item of items) {
|
||||
for (const parent of ancestorsOf(item, byId)) {
|
||||
if (parent.model !== "folder") break;
|
||||
const missing =
|
||||
(parent.action === "create" || parent.action === "not_imported") && !parent.selected;
|
||||
// A not-imported row stays checkable: checking it brings its folders back with it
|
||||
if (missing && item.action !== "delete" && item.action !== "not_imported") {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
if (parent.action === "delete" && parent.selected && item.action === "delete") {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
}, [items]);
|
||||
|
||||
if (plan != null) {
|
||||
const unchanged = items.filter((i) => i.action === "unchanged");
|
||||
const footerNote =
|
||||
unchanged.length > 0
|
||||
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
|
||||
: "";
|
||||
const changeCount = items.filter((item) => {
|
||||
if (disabledIds.has(item.modelId)) return false;
|
||||
if (item.action === "conflict") return item.resolution === "take_source";
|
||||
if (item.action === "unchanged") return false;
|
||||
return item.selected;
|
||||
}).length;
|
||||
|
||||
const destinationLabel = (() => {
|
||||
if (plan.destination.type === "new_workspace") return "New workspace";
|
||||
const { workspaceId, folderId } = plan.destination;
|
||||
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
||||
return folderId != null && folderId === selectedFolder?.id
|
||||
? `${name} / ${selectedFolder.name}`
|
||||
: name;
|
||||
})();
|
||||
|
||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
||||
// it — so its checkbox only aggregates the subtree.
|
||||
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => {
|
||||
const planned = plan.resources.workspaces[0];
|
||||
const planDestination = plan.destination;
|
||||
const existing =
|
||||
planDestination.type === "existing_workspace"
|
||||
? workspaces.find((w) => w.id === planDestination.workspaceId)
|
||||
: null;
|
||||
return {
|
||||
key: existing?.id ?? planned?.id ?? "workspace",
|
||||
data: {
|
||||
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
|
||||
model: "workspace",
|
||||
modelId: existing?.id ?? planned?.id ?? "workspace",
|
||||
name: existing?.name ?? planned?.name ?? "New workspace",
|
||||
selected: true,
|
||||
changedFields: [],
|
||||
},
|
||||
children: itemTree,
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<VStack space={4} className="pb-4">
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
<PreviewRow label="Detected format" value={plan.importer} />
|
||||
<PreviewRow label="Destination" value={destinationLabel} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
|
||||
<CheckboxTree
|
||||
node={workspaceRoot}
|
||||
checked={nodeCheckedStatus}
|
||||
onCheck={toggleNode}
|
||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||
isCollapsedByDefault={(n) => n.data.action === "not_imported"}
|
||||
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
|
||||
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{plan.warnings.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-1">Import details</div>
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
{plan.warnings.map((warning) => (
|
||||
<div
|
||||
key={`${warning.title}:${warning.detail}`}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5"
|
||||
>
|
||||
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HStack space={2} alignItems="center" className="mt-3">
|
||||
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="secondary"
|
||||
variant="border"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setPlan(null);
|
||||
setItems([]);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
|
||||
{isLoading
|
||||
? "Importing"
|
||||
: changeCount > 0
|
||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
||||
: "Done"}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const lastImported =
|
||||
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
|
||||
linkedSources.reduce<ImportSource | null>(
|
||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack ref={ref} space={4} className="pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
@@ -94,7 +433,9 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
className={classNames(
|
||||
"w-full rounded-lg border border-dashed px-4 py-6",
|
||||
"flex flex-col items-center gap-1 text-center",
|
||||
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
|
||||
isHovering
|
||||
? "border-notice bg-surface-highlight"
|
||||
: "border-border hover:border-text-subtle",
|
||||
)}
|
||||
>
|
||||
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
||||
@@ -115,25 +456,285 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
/>
|
||||
|
||||
<VStack space={2}>
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
<Select
|
||||
name="import-destination-kind"
|
||||
label="Import location"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
value={destinationKind}
|
||||
onChange={setDestinationChoice}
|
||||
options={[
|
||||
{ value: "new", label: "New Workspace" },
|
||||
...(currentWorkspace != null
|
||||
? [{ value: "current" as const, label: "Current Workspace" }]
|
||||
: []),
|
||||
{ value: "other", label: "Other Workspace" },
|
||||
]}
|
||||
/>
|
||||
{destinationKind === "other" && (
|
||||
<Select
|
||||
name="import-destination-workspace"
|
||||
label="Workspace"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={otherWorkspaceId ?? ""}
|
||||
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
|
||||
filterable
|
||||
options={[
|
||||
{ value: "", label: "Select a workspace" },
|
||||
...workspaces
|
||||
.filter((w) => w.id !== currentWorkspace?.id)
|
||||
.map((w) => ({ value: w.id, label: w.name })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{lastImported != null && destinationWorkspaceId != null ? (
|
||||
<div className="text-xs text-text-subtle">
|
||||
Last imported from {lastImported.originLabel} ·{" "}
|
||||
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
|
||||
</div>
|
||||
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
|
||||
<div className="text-xs text-text-subtle">
|
||||
This file was last imported into{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline hocus:text-text"
|
||||
onClick={() => {
|
||||
if (linkedWorkspace.id === currentWorkspace?.id) {
|
||||
setDestinationChoice("current");
|
||||
} else {
|
||||
setDestinationChoice("other");
|
||||
setOtherWorkspaceId(linkedWorkspace.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{linkedWorkspace.name}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{canTargetSelectedFolder && (
|
||||
<Checkbox
|
||||
checked={targetSelectedFolder}
|
||||
title={`Place root resources in selected folder “${selectedFolder.name}”`}
|
||||
onChange={setTargetSelectedFolder}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={trimmedSource === "" || isLoading}
|
||||
disabled={
|
||||
trimmedSource === "" ||
|
||||
isLoading ||
|
||||
(destinationKind === "other" && otherWorkspaceId == null)
|
||||
}
|
||||
isLoading={isLoading}
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
{isLoading ? "Importing" : "Import"}
|
||||
{isLoading ? "Analyzing" : "Preview Import"}
|
||||
</Button>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportTreeRow({
|
||||
item,
|
||||
onResolveConflict,
|
||||
}: {
|
||||
item: ImportPlanItem;
|
||||
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{item.name}</div>
|
||||
{item.action === "conflict" ? (
|
||||
<div className="shrink-0">
|
||||
<SegmentedControl
|
||||
name={`conflict-${item.modelId}`}
|
||||
label={`Resolve conflict for ${item.name}`}
|
||||
hideLabel
|
||||
size="2xs"
|
||||
help={actionHelp(item)}
|
||||
value={item.resolution ?? "keep_mine"}
|
||||
onChange={(v) => onResolveConflict(item.modelId, v)}
|
||||
options={[
|
||||
{ value: "keep_mine", label: "Keep mine" },
|
||||
{ value: "take_source", label: "Take source" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
actionLabel(item) && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
|
||||
"inline-flex items-center justify-center gap-1.5",
|
||||
item.action === "create" && "text-success",
|
||||
item.action === "update" && "text-info",
|
||||
item.action === "delete" && "text-danger",
|
||||
item.action === "keep_local" && item.selected && "text-warning",
|
||||
item.action === "not_imported" && "text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
{actionLabel(item)}
|
||||
<IconTooltip content={actionHelp(item)} iconSize="xs" />
|
||||
</InlineCode>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function actionLabel(item: ImportPlanItem): string | null {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "new";
|
||||
case "update":
|
||||
return "updated";
|
||||
case "delete":
|
||||
return "removed";
|
||||
case "keep_local":
|
||||
return "edited";
|
||||
case "not_imported":
|
||||
return "not imported";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function actionHelp(item: ImportPlanItem): string | null {
|
||||
const help = (text: string) =>
|
||||
item.changedFields.length > 0
|
||||
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
|
||||
: text;
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "Added since the last import";
|
||||
case "update":
|
||||
return help("Changed since the last import");
|
||||
case "delete":
|
||||
return item.reason === "moved_into_not_imported_folder"
|
||||
? "Moved into a folder that isn't imported. Import that folder instead to follow the move"
|
||||
: "Deleted since the last import";
|
||||
case "keep_local":
|
||||
return help("Local edits made since the last import. Importing will revert them if checked");
|
||||
case "conflict":
|
||||
return help("Changed both here and in the file since the last import");
|
||||
case "not_imported":
|
||||
return "In the file, but not imported. Check it to import it";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fieldLabel(field: string): string {
|
||||
return field.replace(/([A-Z])/g, " $1").toLowerCase();
|
||||
}
|
||||
|
||||
/** Every plan item above `item`, nearest first. */
|
||||
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
|
||||
const ancestors: ImportPlanItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let parentId = item.parentId;
|
||||
while (parentId != null && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (parent == null) break;
|
||||
ancestors.push(parent);
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
const childrenOf = new Map<string, ImportPlanItem[]>();
|
||||
const roots: ImportPlanItem[] = [];
|
||||
for (const item of items) {
|
||||
if (item.parentId != null && byId.has(item.parentId)) {
|
||||
const siblings = childrenOf.get(item.parentId) ?? [];
|
||||
siblings.push(item);
|
||||
childrenOf.set(item.parentId, siblings);
|
||||
} else {
|
||||
roots.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const foldersFirst = (list: ImportPlanItem[]) => [
|
||||
...list.filter((i) => i.model === "folder"),
|
||||
...list.filter((i) => i.model !== "folder"),
|
||||
];
|
||||
|
||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
|
||||
key: item.modelId,
|
||||
data: item,
|
||||
children: seen.has(item.modelId)
|
||||
? []
|
||||
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
toNode(c, new Set([...seen, item.modelId])),
|
||||
),
|
||||
});
|
||||
|
||||
return foldersFirst(roots).map((r) => toNode(r, new Set()));
|
||||
}
|
||||
|
||||
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
|
||||
return [node.data, ...node.children.flatMap(collectItems)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
|
||||
* (deletions, reverting local edits) never ride along with a parent toggle.
|
||||
*/
|
||||
function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
|
||||
if (item.model === "workspace") return false;
|
||||
if (root.action === "delete") return item.action === "delete";
|
||||
if (item.action === "keep_local") {
|
||||
return root.modelId === item.modelId && item.model !== "folder";
|
||||
}
|
||||
return item.action === "create" || item.action === "update" || item.action === "not_imported";
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(
|
||||
node: CheckboxTreeNode<ImportPlanItem>,
|
||||
): boolean | "indeterminate" | "hidden" {
|
||||
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
|
||||
if (covered.length === 0) return "hidden";
|
||||
const selected = covered.filter((i) => i.selected).length;
|
||||
if (selected === covered.length) return true;
|
||||
if (selected === 0) return false;
|
||||
return "indeterminate";
|
||||
}
|
||||
|
||||
function PreviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
|
||||
<span className="text-text-subtle">{label}</span>
|
||||
<span className="text-right font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
HttpVersion,
|
||||
InheritedBoolSetting,
|
||||
InheritedHttpVersionSetting,
|
||||
InheritedIntSetting,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
modelSupportsSetting,
|
||||
type RequestSettingDefinition,
|
||||
SETTING_FOLLOW_REDIRECTS,
|
||||
SETTING_HTTP_VERSION,
|
||||
SETTING_REQUEST_MESSAGE_SIZE,
|
||||
SETTING_REQUEST_TIMEOUT,
|
||||
SETTING_SEND_COOKIES,
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
} from "../lib/requestSettings";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import {
|
||||
SettingOverrideRow,
|
||||
SettingRow,
|
||||
@@ -38,37 +42,21 @@ interface Props {
|
||||
model: ModelWithSettings;
|
||||
}
|
||||
|
||||
type ModelWithSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||
type ModelWithTlsSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type ModelWithCookieSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest;
|
||||
type ModelWithMessageSizeSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
|
||||
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
|
||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
||||
type IntegerSetting = number | InheritedIntSetting;
|
||||
type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
|
||||
type CookieSettingsPatch = {
|
||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
||||
};
|
||||
type HttpSettingsPatch = {
|
||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
||||
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
|
||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
||||
};
|
||||
type TlsSettingsPatch = {
|
||||
@@ -78,10 +66,7 @@ type MessageSizeSettingsPatch = {
|
||||
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
||||
};
|
||||
|
||||
export function ModelSettingsEditor({
|
||||
model,
|
||||
showSectionTitles = false,
|
||||
}: Props) {
|
||||
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
||||
const ancestors = useModelAncestors(model);
|
||||
const supportsHttpSettings = modelSupportsHttpSettings(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>
|
||||
)}
|
||||
{supportsCookieSettings && (
|
||||
<SettingsSection
|
||||
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
|
||||
>
|
||||
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_SEND_COOKIES}
|
||||
setting={model.settingSendCookies}
|
||||
@@ -195,7 +194,7 @@ export function ModelSettingsEditor({
|
||||
}
|
||||
|
||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
||||
const settings: (BooleanSetting | IntegerSetting)[] = [];
|
||||
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
|
||||
|
||||
if (modelSupportsCookieSettings(model)) {
|
||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
||||
@@ -204,22 +203,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
|
||||
settings.push(model.settingValidateCertificates);
|
||||
|
||||
if (modelSupportsHttpSettings(model)) {
|
||||
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
||||
settings.push(
|
||||
model.settingFollowRedirects,
|
||||
model.settingRequestTimeout,
|
||||
model.settingHttpVersion,
|
||||
);
|
||||
}
|
||||
|
||||
if (modelSupportsMessageSizeSettings(model)) {
|
||||
settings.push(model.settingRequestMessageSize);
|
||||
}
|
||||
|
||||
return settings.filter(
|
||||
(setting) => isInheritedSetting(setting) && setting.enabled === true,
|
||||
).length;
|
||||
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
||||
.length;
|
||||
}
|
||||
|
||||
function patchCookieSettings(
|
||||
model: ModelWithCookieSettings,
|
||||
patch: Partial<CookieSettingsPatch>,
|
||||
) {
|
||||
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
@@ -232,10 +231,7 @@ function patchCookieSettings(
|
||||
}
|
||||
}
|
||||
|
||||
function patchHttpSettings(
|
||||
model: ModelWithHttpSettings,
|
||||
patch: Partial<HttpSettingsPatch>,
|
||||
) {
|
||||
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
@@ -246,10 +242,7 @@ function patchHttpSettings(
|
||||
}
|
||||
}
|
||||
|
||||
function patchTlsSettings(
|
||||
model: ModelWithTlsSettings,
|
||||
patch: Partial<TlsSettingsPatch>,
|
||||
) {
|
||||
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
@@ -280,21 +273,15 @@ function patchMessageSizeSettings(
|
||||
}
|
||||
}
|
||||
|
||||
function modelSupportsHttpSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithHttpSettings {
|
||||
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
|
||||
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
||||
}
|
||||
|
||||
function modelSupportsCookieSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithCookieSettings {
|
||||
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
|
||||
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
||||
}
|
||||
|
||||
function modelSupportsTlsSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithTlsSettings {
|
||||
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
|
||||
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
||||
}
|
||||
|
||||
@@ -317,11 +304,7 @@ function BooleanSettingRow({
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited
|
||||
? overridden
|
||||
? setting.value
|
||||
: inheritedValue
|
||||
: setting;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
|
||||
if (!inherited) {
|
||||
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({
|
||||
inheritedValue,
|
||||
setting,
|
||||
@@ -365,18 +405,11 @@ function IntegerSettingRow({
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited
|
||||
? overridden
|
||||
? setting.value
|
||||
: inheritedValue
|
||||
: setting;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
>
|
||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||
<NumberUnitInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
@@ -429,20 +462,13 @@ function MessageSizeSettingRow({
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited
|
||||
? overridden
|
||||
? setting.value
|
||||
: inheritedValue
|
||||
: setting;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
const displayValue = formatMegabytes(value);
|
||||
const placeholder = "0";
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
>
|
||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||
<MessageSizeInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
@@ -567,13 +593,18 @@ function resolveInheritedValue(
|
||||
key: BooleanWorkspaceSettingKey,
|
||||
fallback: BooleanSetting,
|
||||
): boolean;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: "settingHttpVersion",
|
||||
fallback: HttpVersionSetting,
|
||||
): HttpVersion;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: keyof WorkspaceSettings,
|
||||
fallback: BooleanSetting | IntegerSetting,
|
||||
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
|
||||
) {
|
||||
for (const ancestor of ancestors) {
|
||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
|
||||
if (isInheritedSetting(setting)) {
|
||||
if (setting.enabled === true) {
|
||||
return setting.value;
|
||||
@@ -589,6 +620,7 @@ function resolveInheritedValue(
|
||||
type WorkspaceSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingHttpVersion"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
@@ -598,14 +630,12 @@ type WorkspaceSettings = Pick<
|
||||
|
||||
type BooleanWorkspaceSettingKey = Exclude<
|
||||
keyof WorkspaceSettings,
|
||||
"settingRequestTimeout" | "settingRequestMessageSize"
|
||||
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
|
||||
>;
|
||||
|
||||
function formatMegabytes(bytes: number) {
|
||||
const megabytes = bytes / BYTES_PER_MB;
|
||||
return Number.isInteger(megabytes)
|
||||
? `${megabytes}`
|
||||
: megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function parseMegabytes(value: string) {
|
||||
@@ -626,9 +656,5 @@ function isValidInteger(value: string) {
|
||||
function isValidMegabytes(value: string) {
|
||||
if (value === "") return true;
|
||||
const megabytes = Number(value);
|
||||
return (
|
||||
Number.isFinite(megabytes) &&
|
||||
megabytes >= 0 &&
|
||||
megabytes <= MAX_MESSAGE_SIZE_MB
|
||||
);
|
||||
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||
@@ -20,6 +19,8 @@ import { SettingsProxy } from "./SettingsProxy";
|
||||
import { SettingsTheme } from "./SettingsTheme";
|
||||
|
||||
interface Props {
|
||||
tab?: SettingsTabWithSubtab | null;
|
||||
/** Set when Settings is in a dialog rather than owning a window. */
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
@@ -42,25 +43,19 @@ const tabs = [
|
||||
TAB_LICENSE,
|
||||
] as const;
|
||||
export type SettingsTab = (typeof tabs)[number];
|
||||
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
|
||||
|
||||
export default function Settings({ hide }: Props) {
|
||||
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
|
||||
export default function Settings({ tab, hide }: Props) {
|
||||
// Parse tab and subtab (e.g., "plugins:installed")
|
||||
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
|
||||
const [mainTab, subtab] = tab?.split(":") ?? [];
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const licenseCheck = useLicense();
|
||||
|
||||
// Close settings window on escape
|
||||
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
|
||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||
useKeyPressEvent("Escape", async () => {
|
||||
if (hide != null) {
|
||||
// It's being shown in a dialog, so close the dialog
|
||||
hide();
|
||||
} else {
|
||||
// It's being shown in a window, so close the window
|
||||
await platform.window.close();
|
||||
}
|
||||
if (hide == null) await platform.window.close();
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -90,7 +85,7 @@ export default function Settings({ hide }: Props) {
|
||||
)}
|
||||
<Tabs
|
||||
layout="horizontal"
|
||||
defaultValue={mainTab || tabFromQuery}
|
||||
defaultValue={mainTab}
|
||||
addBorders
|
||||
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||
label="Settings"
|
||||
|
||||
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action]}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import type { CheckboxProps } from "./Checkbox";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
|
||||
export interface CheckboxTreeNode<T> {
|
||||
key: string;
|
||||
data: T;
|
||||
children: CheckboxTreeNode<T>[];
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
node: CheckboxTreeNode<T>;
|
||||
depth?: number;
|
||||
/** Return "hidden" to render row alignment space instead of a checkbox */
|
||||
checked: (node: CheckboxTreeNode<T>) => CheckboxProps["checked"] | "hidden";
|
||||
onCheck: (node: CheckboxTreeNode<T>, checked: boolean) => void;
|
||||
checkboxTitle?: (node: CheckboxTreeNode<T>) => string;
|
||||
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** An irrelevant row is hidden unless one of its descendants is relevant */
|
||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
|
||||
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
|
||||
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
|
||||
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
isRowSelected?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
}
|
||||
|
||||
export function CheckboxTree<T>(props: Props<T>) {
|
||||
const { node, depth = 0 } = props;
|
||||
const [collapsed, setCollapsed] = useState<boolean>(
|
||||
() => props.isCollapsedByDefault?.(node) ?? false,
|
||||
);
|
||||
if (!hasRelevantNode(node, props.isRelevant)) return null;
|
||||
|
||||
const checked = props.checked(node);
|
||||
const selected = props.isRowSelected?.(node) ?? false;
|
||||
const selectable = props.onSelectRow != null && (props.canSelectRow?.(node) ?? true);
|
||||
const hasVisibleChildren = node.children.some((c) => hasRelevantNode(c, props.isRelevant));
|
||||
const rowContent = (
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left">
|
||||
{props.renderRow(node)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"relative flex gap-1 w-full h-xs items-center",
|
||||
selected ? "text-text" : "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
||||
)}
|
||||
{hasVisibleChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={collapsed ? "Expand" : "Collapse"}
|
||||
aria-expanded={!collapsed}
|
||||
className="shrink-0 text-text-subtlest hocus:text-text"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
<Icon size="sm" icon={collapsed ? "chevron_right" : "chevron_down"} />
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden className="w-4 shrink-0" />
|
||||
)}
|
||||
{checked === "hidden" ? (
|
||||
<span aria-hidden className="w-4 mr-0.5 shrink-0" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
title={props.checkboxTitle?.(node) ?? "Toggle"}
|
||||
hideLabel
|
||||
disabled={props.isCheckboxDisabled?.(node)}
|
||||
onChange={(checked) => props.onCheck(node, checked)}
|
||||
/>
|
||||
)}
|
||||
{selectable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 flex text-left"
|
||||
onClick={() => props.onSelectRow?.(node)}
|
||||
>
|
||||
{rowContent}
|
||||
</button>
|
||||
) : (
|
||||
rowContent
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed &&
|
||||
node.children.map((child) => (
|
||||
<CheckboxTree key={child.key} {...props} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasRelevantNode<T>(
|
||||
node: CheckboxTreeNode<T>,
|
||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean,
|
||||
): boolean {
|
||||
return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
|
||||
}
|
||||
@@ -10,11 +10,13 @@ export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
disableBackdropClose?: boolean;
|
||||
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
|
||||
disableClose?: boolean;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
size?: DialogSize;
|
||||
/** Hide the built-in close button without changing backdrop or Escape behavior. */
|
||||
hideX?: boolean;
|
||||
noPadding?: boolean;
|
||||
noScroll?: boolean;
|
||||
@@ -27,7 +29,7 @@ export function Dialog({
|
||||
size = "full",
|
||||
open,
|
||||
onClose,
|
||||
disableBackdropClose,
|
||||
disableClose,
|
||||
title,
|
||||
description,
|
||||
hideX,
|
||||
@@ -42,7 +44,7 @@ export function Dialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
|
||||
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
|
||||
<div
|
||||
role="dialog"
|
||||
className={classNames(
|
||||
@@ -58,7 +60,7 @@ export function Dialog({
|
||||
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
|
||||
// dialogs and can be intercepted by children if needed.
|
||||
if (e.key === "Escape") {
|
||||
onClose?.();
|
||||
if (!disableClose) onClose?.();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -110,7 +112,7 @@ export function Dialog({
|
||||
</div>
|
||||
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!hideX && (
|
||||
{!disableClose && !hideX && (
|
||||
<div className="ml-auto absolute right-1 top-1">
|
||||
<IconButton
|
||||
className="opacity-70 hover:opacity-100"
|
||||
|
||||
@@ -601,6 +601,8 @@ function getExtensions({
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: "off",
|
||||
autocorrect: "off",
|
||||
// Keeps macOS Writing Tools from offering to write code for us
|
||||
writingsuggestions: "false",
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { SearchQuery } from "@codemirror/search";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
currentMatch,
|
||||
literalSearch,
|
||||
MAX_COUNT,
|
||||
MatchCounter,
|
||||
normalizeDoc,
|
||||
normalizeSearch,
|
||||
scanNormalized,
|
||||
scanQuery,
|
||||
} from "./searchMatchCount";
|
||||
|
||||
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
|
||||
|
||||
const stateOf = (doc: string) => EditorState.create({ doc });
|
||||
|
||||
/**
|
||||
* The matches the counter finds, having checked them against the search panel's own cursor.
|
||||
*
|
||||
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
|
||||
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
|
||||
*/
|
||||
function matchesOf(doc: string, config: QueryConfig) {
|
||||
const state = stateOf(doc);
|
||||
const query = new SearchQuery(config);
|
||||
const matches = new MatchCounter().matches(state, query);
|
||||
expect(matches).toEqual(scanQuery(state, query));
|
||||
return matches;
|
||||
}
|
||||
|
||||
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
|
||||
|
||||
describe("counting", () => {
|
||||
test("counts every match, whatever the case", () => {
|
||||
expect(countOf("one Two three two", { search: "two" })).toBe(2);
|
||||
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
|
||||
});
|
||||
|
||||
test("skips matches overlapping an earlier one", () => {
|
||||
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
|
||||
expect(countOf("ababa", { search: "aba" })).toBe(1);
|
||||
});
|
||||
|
||||
test("treats a query as text, not as a pattern", () => {
|
||||
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
|
||||
});
|
||||
|
||||
test("unquotes escapes unless the query is literal", () => {
|
||||
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
|
||||
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
|
||||
});
|
||||
|
||||
test("counts regexp and whole word queries through the cursor", () => {
|
||||
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
|
||||
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
|
||||
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
|
||||
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
|
||||
});
|
||||
|
||||
test("stops counting at the cap", () => {
|
||||
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
|
||||
});
|
||||
|
||||
test("reports where the matches are", () => {
|
||||
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
|
||||
{ from: 0, to: 2 },
|
||||
{ from: 4, to: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds nothing to match with an empty needle", () => {
|
||||
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalization", () => {
|
||||
test("finds what a character decomposes into", () => {
|
||||
// The é is one character holding an `e`, and the match covers the whole of it
|
||||
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
|
||||
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
|
||||
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
|
||||
expect(countOf("one two", { search: "one two" })).toBe(1);
|
||||
expect(countOf("full width", { search: "full" })).toBe(1);
|
||||
});
|
||||
|
||||
test("matches a decomposed query against composed text, and the reverse", () => {
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps offsets straight after an expansion", () => {
|
||||
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
|
||||
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
|
||||
});
|
||||
|
||||
test("normalizes the query whole, the document by character", () => {
|
||||
expect(normalizeSearch("CAFÉ", false)).toBe("café");
|
||||
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
|
||||
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
|
||||
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
|
||||
});
|
||||
|
||||
test("leaves a document that normalizes to itself untouched", () => {
|
||||
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
|
||||
expect(text).toBe("plain 日本 🎉 text");
|
||||
expect(expansions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("current match", () => {
|
||||
const matches = [
|
||||
{ from: 0, to: 2 },
|
||||
{ from: 4, to: 6 },
|
||||
{ from: 8, to: 10 },
|
||||
];
|
||||
|
||||
test("counts from one, and reports 0 off a match", () => {
|
||||
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
|
||||
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
|
||||
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
|
||||
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
|
||||
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
|
||||
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
test("moving the selection doesn't scan again", () => {
|
||||
const state = stateOf("a1 b2 c3");
|
||||
const query = new SearchQuery({ search: "\\d", regexp: true });
|
||||
const counter = new MatchCounter();
|
||||
const found = counter.matches(state, query);
|
||||
|
||||
// The document a selection-only transaction leaves behind is the one already scanned
|
||||
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
|
||||
expect(counter.matches(moved, query)).toBe(found);
|
||||
expect(currentMatch(found, moved.selection.main)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The mapping from normalized offsets back to document offsets is the part of this that can go
|
||||
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
|
||||
*/
|
||||
describe("against the cursor, on awkward text", () => {
|
||||
const ALPHABET = [
|
||||
..."abcABC .\\\n".split(""),
|
||||
"é",
|
||||
"é",
|
||||
"fi",
|
||||
"…",
|
||||
" ",
|
||||
"İ",
|
||||
"Σ",
|
||||
"ς",
|
||||
"日",
|
||||
"🎉",
|
||||
"Ⅻ",
|
||||
"f",
|
||||
"①",
|
||||
"́",
|
||||
];
|
||||
|
||||
/** Seeded, so a failure is the same failure next run */
|
||||
function random(seed: number) {
|
||||
let state = seed;
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
for (const caseSensitive of [false, true]) {
|
||||
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
|
||||
const next = random(caseSensitive ? 20260831 : 7);
|
||||
|
||||
for (let round = 0; round < 400; round++) {
|
||||
const doc = Array.from(
|
||||
{ length: 2 + Math.floor(next() * 60) },
|
||||
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||
).join("");
|
||||
|
||||
// Half the queries are lifted out of the document, so matches are actually found
|
||||
const start = Math.floor(next() * doc.length);
|
||||
const search =
|
||||
next() < 0.5
|
||||
? doc.slice(start, start + 1 + Math.floor(next() * 3))
|
||||
: Array.from(
|
||||
{ length: 1 + Math.floor(next() * 2) },
|
||||
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||
).join("");
|
||||
if (search === "") continue;
|
||||
|
||||
const state = stateOf(doc);
|
||||
const query = new SearchQuery({ search, caseSensitive });
|
||||
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
|
||||
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,232 @@
|
||||
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
|
||||
import type { EditorState, Extension, Text } from "@codemirror/state";
|
||||
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
|
||||
/** Matches are counted no further than this, since an exact total stops being useful long before */
|
||||
export const MAX_COUNT = 9999;
|
||||
|
||||
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
|
||||
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
|
||||
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
|
||||
|
||||
export interface Match {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/** A character whose normalized form is a different length, shifting every offset past it */
|
||||
interface Expansion {
|
||||
normFrom: number;
|
||||
normTo: number;
|
||||
docFrom: number;
|
||||
docTo: number;
|
||||
}
|
||||
|
||||
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
|
||||
export interface NormalizedDoc {
|
||||
text: string;
|
||||
expansions: Expansion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
|
||||
* case-sensitive — in a single pass rather than one call per code point.
|
||||
*
|
||||
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
|
||||
* makes counting matches in a large response slow. Doing it a character at a time still matters
|
||||
* for the result, since it keeps NFKD from reordering marks across characters, so the
|
||||
* granularity stays and only the repeated work goes: each distinct character is normalized once
|
||||
* and the answer reused, and ASCII runs never reach ICU at all.
|
||||
*/
|
||||
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
|
||||
const rewritten = new Map<string, string>();
|
||||
const expansions: Expansion[] = [];
|
||||
let shift = 0;
|
||||
|
||||
const normalized = text.replace(
|
||||
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
|
||||
(chunk: string, at: number) => {
|
||||
// An ASCII run only ever folds case, which can't change its length
|
||||
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
|
||||
|
||||
let out = rewritten.get(chunk);
|
||||
if (out === undefined) {
|
||||
out = chunk.normalize("NFKD");
|
||||
if (!caseSensitive) out = out.toLowerCase();
|
||||
rewritten.set(chunk, out);
|
||||
}
|
||||
|
||||
if (out.length !== chunk.length) {
|
||||
expansions.push({
|
||||
normFrom: at + shift,
|
||||
normTo: at + shift + out.length,
|
||||
docFrom: at,
|
||||
docTo: at + chunk.length,
|
||||
});
|
||||
shift += out.length - chunk.length;
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
);
|
||||
|
||||
return { text: normalized, expansions };
|
||||
}
|
||||
|
||||
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
|
||||
export function normalizeSearch(search: string, caseSensitive: boolean): string {
|
||||
const normalized = search.normalize("NFKD");
|
||||
return caseSensitive ? normalized : normalized.toLowerCase();
|
||||
}
|
||||
|
||||
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
|
||||
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
|
||||
const matches: Match[] = [];
|
||||
if (needle === "") return matches;
|
||||
|
||||
const { text } = doc;
|
||||
let pos = text.indexOf(needle);
|
||||
while (pos >= 0) {
|
||||
let end = pos + needle.length;
|
||||
// However the query was cut, a match ends on a whole code point, as the cursor's do
|
||||
if (isLowSurrogate(text.charCodeAt(end))) end++;
|
||||
|
||||
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
|
||||
if (matches.length > MAX_COUNT) break;
|
||||
|
||||
pos = text.indexOf(needle, resumeAfter(doc, end));
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** The same through the query's own cursor, which handles regexps and whole words */
|
||||
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
|
||||
const matches: Match[] = [];
|
||||
const cursor = query.getCursor(state);
|
||||
|
||||
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||
matches.push({ from: result.value.from, to: result.value.to });
|
||||
if (matches.length > MAX_COUNT) break;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
|
||||
|
||||
/** The last character expansion beginning at or before `offset`, if there is one */
|
||||
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
|
||||
let low = 0;
|
||||
let high = expansions.length - 1;
|
||||
let found: Expansion | null = null;
|
||||
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (expansions[mid]!.normFrom <= offset) {
|
||||
found = expansions[mid]!;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function docStart(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
if (expansion == null) return offset;
|
||||
// A match starting inside a character's expansion starts at the character
|
||||
return offset < expansion.normTo
|
||||
? expansion.docFrom
|
||||
: offset - (expansion.normTo - expansion.docTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where scanning picks up after a match ending at `offset`.
|
||||
*
|
||||
* The cursor moves through the document a character at a time, so once a match ends inside a
|
||||
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
|
||||
* ever counts as one match of ".".
|
||||
*/
|
||||
function resumeAfter(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
|
||||
? expansion.normTo
|
||||
: offset;
|
||||
}
|
||||
|
||||
function docEnd(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
if (expansion == null) return offset;
|
||||
if (offset <= expansion.normFrom) return expansion.docFrom;
|
||||
// A match ending inside a character's expansion covers the whole character
|
||||
return offset < expansion.normTo
|
||||
? expansion.docTo
|
||||
: offset - (expansion.normTo - expansion.docTo);
|
||||
}
|
||||
|
||||
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
|
||||
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
|
||||
let index = 0;
|
||||
for (const match of matches) {
|
||||
index++;
|
||||
if (match.from <= selection.from && match.to >= selection.to) return index;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The text a plain query looks for, or null when only the cursor can answer it */
|
||||
export function literalSearch(query: SearchQuery): string | null {
|
||||
if (query.regexp || query.wholeWord || query.test != null) return null;
|
||||
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
|
||||
return query.literal
|
||||
? query.search
|
||||
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
|
||||
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the matches for the search panel, keeping the normalized document and the matches it
|
||||
* last found, so neither moving the selection nor typing another character starts over.
|
||||
*/
|
||||
export class MatchCounter {
|
||||
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
|
||||
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
|
||||
|
||||
matches(state: EditorState, query: SearchQuery): Match[] {
|
||||
const last = this.last;
|
||||
if (last != null && last.doc === state.doc && last.query.eq(query)) {
|
||||
return last.matches;
|
||||
}
|
||||
|
||||
const matches = this.scan(state, query);
|
||||
this.last = { doc: state.doc, query, matches };
|
||||
return matches;
|
||||
}
|
||||
|
||||
private scan(state: EditorState, query: SearchQuery): Match[] {
|
||||
const search = literalSearch(query);
|
||||
if (search == null) return scanQuery(state, query);
|
||||
|
||||
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
|
||||
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
|
||||
}
|
||||
|
||||
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
|
||||
const cached = this.doc;
|
||||
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
|
||||
return cached.normalized;
|
||||
}
|
||||
|
||||
const normalized = normalizeDoc(doc.toString(), caseSensitive);
|
||||
this.doc = { doc, caseSensitive, normalized };
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A CodeMirror extension that displays the total number of search matches
|
||||
* inside the built-in search panel.
|
||||
@@ -10,6 +235,7 @@ export function searchMatchCount(): Extension {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
private countEl: HTMLElement | null = null;
|
||||
private counter = new MatchCounter();
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.updateCount();
|
||||
@@ -38,38 +264,21 @@ export function searchMatchCount(): Extension {
|
||||
}
|
||||
|
||||
this.ensureCountEl();
|
||||
if (this.countEl == null) return;
|
||||
|
||||
if (!query.search) {
|
||||
if (this.countEl) {
|
||||
this.countEl.textContent = "0/0";
|
||||
}
|
||||
this.countEl.textContent = "0/0";
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = state.selection.main;
|
||||
let count = 0;
|
||||
let currentIndex = 0;
|
||||
const MAX_COUNT = 9999;
|
||||
const cursor = query.getCursor(state);
|
||||
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||
count++;
|
||||
const match = result.value;
|
||||
if (match.from <= selection.from && match.to >= selection.to) {
|
||||
currentIndex = count;
|
||||
}
|
||||
if (count > MAX_COUNT) break;
|
||||
}
|
||||
|
||||
if (this.countEl) {
|
||||
if (count > MAX_COUNT) {
|
||||
this.countEl.textContent = `${MAX_COUNT}+`;
|
||||
} else if (count === 0) {
|
||||
this.countEl.textContent = "0/0";
|
||||
} else if (currentIndex > 0) {
|
||||
this.countEl.textContent = `${currentIndex}/${count}`;
|
||||
} else {
|
||||
this.countEl.textContent = `0/${count}`;
|
||||
}
|
||||
const matches = this.counter.matches(state, query);
|
||||
if (matches.length > MAX_COUNT) {
|
||||
this.countEl.textContent = `${MAX_COUNT}+`;
|
||||
} else if (matches.length === 0) {
|
||||
this.countEl.textContent = "0/0";
|
||||
} else {
|
||||
const current = currentMatch(matches, state.selection.main);
|
||||
this.countEl.textContent = `${current}/${matches.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,15 @@ function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): P
|
||||
/** Max number of pairs to show before prompting the user to reveal the rest */
|
||||
const MAX_INITIAL_PAIRS = 30;
|
||||
|
||||
export function PairEditor({
|
||||
// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
|
||||
// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
|
||||
// fast path below would swap in the new data without rebuilding the row editors, leaving any
|
||||
// still-focused input showing the old owner's text.
|
||||
export function PairEditor(props: PairEditorProps) {
|
||||
return <PairEditorInner key={props.stateKey} {...props} />;
|
||||
}
|
||||
|
||||
function PairEditorInner({
|
||||
allowFileValues,
|
||||
allowMultilineValues,
|
||||
className,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||
import { IconTooltip } from "./IconTooltip";
|
||||
import { Label } from "./Label";
|
||||
|
||||
interface Props<T extends string> {
|
||||
@@ -36,11 +37,15 @@ export function SegmentedControl<T extends string>({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const id = useRef(`input-${generateId()}`);
|
||||
|
||||
// A visually hidden label has nowhere to show the help, so the last option carries it
|
||||
const inlineHelp =
|
||||
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
|
||||
|
||||
return (
|
||||
<div className="w-full grid">
|
||||
<Label
|
||||
htmlFor={id.current}
|
||||
help={help}
|
||||
help={hideLabel ? undefined : help}
|
||||
visuallyHidden={hideLabel}
|
||||
className={classNames(labelClassName)}
|
||||
>
|
||||
@@ -78,9 +83,10 @@ export function SegmentedControl<T extends string>({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.map((o) => {
|
||||
{options.map((o, i) => {
|
||||
const isSelected = selectedValue === o.value;
|
||||
const isActive = value === o.value;
|
||||
const rightSlot = i === options.length - 1 ? inlineHelp : null;
|
||||
if (o.icon == null) {
|
||||
return (
|
||||
<Button
|
||||
@@ -95,6 +101,7 @@ export function SegmentedControl<T extends string>({
|
||||
isActive && "text-text!",
|
||||
"focus:ring-1 focus:ring-border-focus",
|
||||
)}
|
||||
rightSlot={rightSlot}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
@@ -117,6 +124,7 @@ export function SegmentedControl<T extends string>({
|
||||
)}
|
||||
title={o.label}
|
||||
icon={o.icon}
|
||||
rightSlot={rightSlot}
|
||||
onClick={() => onChange(o.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -119,9 +119,13 @@ export function Select<T extends string>({
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="w-full text-sm font-mono"
|
||||
className={classNames(
|
||||
"w-full text-sm font-mono",
|
||||
disabled && "border-dotted",
|
||||
isInvalidSelection && "border-danger",
|
||||
)}
|
||||
justify="start"
|
||||
variant="border"
|
||||
variant="input"
|
||||
size={size}
|
||||
leftSlot={leftSlot}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { Button } from "../core/Button";
|
||||
import type { CheckboxProps } from "../core/Checkbox";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import type { CheckboxTreeNode } from "../core/CheckboxTree";
|
||||
import { CheckboxTree } from "../core/CheckboxTree";
|
||||
import { DiffViewer } from "../core/Editor/DiffViewer";
|
||||
import { Input } from "../core/Input";
|
||||
import { Separator } from "../core/Separator";
|
||||
@@ -43,10 +45,7 @@ interface CommitTreeNode {
|
||||
|
||||
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
const callbacks = useGitCallbacks(syncDir);
|
||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
|
||||
syncDir,
|
||||
callbacks,
|
||||
);
|
||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [commitError, setCommitError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
@@ -143,6 +142,15 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
return next(workspace, []);
|
||||
}, [workspace, internalEntries]);
|
||||
|
||||
const treeNode: CheckboxTreeNode<CommitTreeNode> | null = useMemo(() => {
|
||||
const toTreeNode = (n: CommitTreeNode): CheckboxTreeNode<CommitTreeNode> => ({
|
||||
key: n.status.relaPath + n.status.status + n.status.staged,
|
||||
data: n,
|
||||
children: n.children.map(toTreeNode),
|
||||
});
|
||||
return tree == null ? null : toTreeNode(tree);
|
||||
}, [tree]);
|
||||
|
||||
const checkNode = useCallback(
|
||||
(treeNode: CommitTreeNode) => {
|
||||
const checked = nodeCheckedStatus(treeNode);
|
||||
@@ -190,7 +198,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
[restore],
|
||||
);
|
||||
|
||||
if (tree == null) {
|
||||
if (tree == null || treeNode == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -221,12 +229,18 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
style={innerStyle}
|
||||
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
|
||||
>
|
||||
<TreeNodeChildren
|
||||
node={tree}
|
||||
depth={0}
|
||||
onCheck={checkNode}
|
||||
onSelect={handleSelectChild}
|
||||
selectedPath={selectedEntry?.relaPath ?? null}
|
||||
<CheckboxTree
|
||||
node={treeNode}
|
||||
checked={(n) => nodeCheckedStatus(n.data)}
|
||||
onCheck={(n) => checkNode(n.data)}
|
||||
checkboxTitle={(n) =>
|
||||
nodeCheckedStatus(n.data) ? "Unstage change" : "Stage change"
|
||||
}
|
||||
isRelevant={(n) => n.data.status.status !== "current"}
|
||||
canSelectRow={(n) => n.data.status.status !== "current"}
|
||||
onSelectRow={(n) => handleSelectChild(n.data.status)}
|
||||
isRowSelected={(n) => selectedEntry?.relaPath === n.data.status.relaPath}
|
||||
renderRow={(n) => <CommitTreeRow node={n.data} />}
|
||||
/>
|
||||
{externalEntries.find((e) => e.status !== "current") && (
|
||||
<>
|
||||
@@ -244,10 +258,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
</div>
|
||||
)}
|
||||
secondSlot={({ style: innerStyle }) => (
|
||||
<div
|
||||
style={innerStyle}
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
|
||||
>
|
||||
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
||||
<Input
|
||||
className="text-base! font-sans rounded-md"
|
||||
placeholder="Commit message..."
|
||||
@@ -301,96 +312,39 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNodeChildren({
|
||||
node,
|
||||
depth,
|
||||
onCheck,
|
||||
onSelect,
|
||||
selectedPath,
|
||||
}: {
|
||||
node: CommitTreeNode | null;
|
||||
depth: number;
|
||||
onCheck: (node: CommitTreeNode, checked: boolean) => void;
|
||||
onSelect: (entry: GitStatusEntry) => void;
|
||||
selectedPath: string | null;
|
||||
}) {
|
||||
if (node === null) return null;
|
||||
if (!isNodeRelevant(node)) return null;
|
||||
|
||||
const checked = nodeCheckedStatus(node);
|
||||
const isSelected = selectedPath === node.status.relaPath;
|
||||
|
||||
function CommitTreeRow({ node }: { node: CommitTreeNode }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"relative flex gap-1 w-full h-xs items-center",
|
||||
isSelected ? "text-text" : "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
||||
)}
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
title={checked ? "Unstage change" : "Stage change"}
|
||||
hideLabel
|
||||
onChange={(checked) => onCheck(node, checked)}
|
||||
<>
|
||||
{node.model.model !== "http_request" &&
|
||||
node.model.model !== "grpc_request" &&
|
||||
node.model.model !== "websocket_request" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
node.model.model === "folder"
|
||||
? "folder"
|
||||
: node.model.model === "environment"
|
||||
? "variable"
|
||||
: "house"
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames("flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left")}
|
||||
onClick={() => node.status.status !== "current" && onSelect(node.status)}
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
||||
{node.status.status !== "current" && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-24 text-center shrink-0",
|
||||
node.status.status === "modified" && "text-info",
|
||||
node.status.status === "untracked" && "text-success",
|
||||
node.status.status === "removed" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{node.model.model !== "http_request" &&
|
||||
node.model.model !== "grpc_request" &&
|
||||
node.model.model !== "websocket_request" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
node.model.model === "folder"
|
||||
? "folder"
|
||||
: node.model.model === "environment"
|
||||
? "variable"
|
||||
: "house"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
||||
{node.status.status !== "current" && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-24 text-center shrink-0",
|
||||
node.status.status === "modified" && "text-info",
|
||||
node.status.status === "untracked" && "text-success",
|
||||
node.status.status === "removed" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{node.children.map((childNode) => {
|
||||
return (
|
||||
<TreeNodeChildren
|
||||
key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}
|
||||
node={childNode}
|
||||
depth={depth + 1}
|
||||
onCheck={onCheck}
|
||||
onSelect={onSelect}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -495,15 +449,6 @@ function setCheckedAndChildren(
|
||||
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
|
||||
}
|
||||
|
||||
function isNodeRelevant(node: CommitTreeNode): boolean {
|
||||
if (node.status.status !== "current") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Recursively check children
|
||||
return node.children.some((c) => isNodeRelevant(c));
|
||||
}
|
||||
|
||||
function DiffPanel({
|
||||
entry,
|
||||
onDiscardChanges,
|
||||
@@ -526,13 +471,11 @@ function DiffPanel({
|
||||
size="2xs"
|
||||
variant="border"
|
||||
onClick={() => onDiscardChanges(entry)}
|
||||
>Discard Changes</Button>
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</div>
|
||||
<DiffViewer
|
||||
original={prevYaml ?? ""}
|
||||
modified={nextYaml ?? ""}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
|
||||
showDialog({
|
||||
id: "git-diverged",
|
||||
title: "Branches Diverged",
|
||||
hideX: true,
|
||||
size: "sm",
|
||||
disableBackdropClose: true,
|
||||
onClose: () => resolve("cancel"),
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
DivergedDialog({
|
||||
remote,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { CsvViewerInner } from "./CsvViewer";
|
||||
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
|
||||
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
|
||||
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
|
||||
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
|
||||
}));
|
||||
|
||||
describe("CsvViewer", () => {
|
||||
test("renders columns that extend beyond the first row", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CsvViewerInner
|
||||
text={[
|
||||
"startDate,2026-02-03T00:00-03:00",
|
||||
"endDate,2026-02-03T23:59:59-03:00",
|
||||
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
|
||||
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
|
||||
].join("\n")}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("ID de usuario");
|
||||
expect(markup).toContain("42041");
|
||||
expect(markup.match(/<td>/g)).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
@@ -26,27 +26,33 @@ export function CsvViewer({ text, className }: Props) {
|
||||
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
||||
const parsed = useMemo(() => {
|
||||
if (text == null) return null;
|
||||
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
|
||||
return Papa.parse<string[]>(text, { skipEmptyLines: true });
|
||||
}, [text]);
|
||||
|
||||
if (parsed === null) return null;
|
||||
|
||||
const header = parsed.data[0] ?? [];
|
||||
const rows = parsed.data.slice(1);
|
||||
const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
|
||||
const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<Table className={classNames(className, "text-sm")}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{parsed.meta.fields?.map((field) => (
|
||||
<TableHeaderCell key={field}>{field}</TableHeaderCell>
|
||||
{columnIndexes.map((columnIndex) => (
|
||||
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{parsed.data.map((row, i) => (
|
||||
{rows.map((row, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableRow key={i}>
|
||||
{parsed.meta.fields?.map((key) => (
|
||||
<TableCell key={key}>{row[key] ?? ""}</TableCell>
|
||||
{row.map((cell, columnIndex) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableCell key={columnIndex}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => {
|
||||
// Merge default hotkeys with custom hotkeys from settings
|
||||
// Custom hotkeys override defaults for the same action
|
||||
// An empty array means the hotkey is intentionally disabled
|
||||
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
|
||||
const merged: Partial<Record<HotkeyAction, string[]>> = {};
|
||||
for (const action of hotkeyActions) {
|
||||
merged[action] = defaultHotkeys[action];
|
||||
}
|
||||
for (const [action, keys] of Object.entries(customHotkeys)) {
|
||||
if (action in defaultHotkeys && Array.isArray(keys)) {
|
||||
if (action in merged && Array.isArray(keys)) {
|
||||
merged[action as HotkeyAction] = keys;
|
||||
}
|
||||
}
|
||||
@@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => {
|
||||
});
|
||||
|
||||
/** Helper function to get current hotkeys from the store */
|
||||
function getHotkeys(): Record<HotkeyAction, string[]> {
|
||||
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
|
||||
return jotaiStore.get(hotkeysAtom);
|
||||
}
|
||||
|
||||
@@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [
|
||||
"Space",
|
||||
];
|
||||
|
||||
/** Zoom is the browser's own on these keys, so the app has no such action there. */
|
||||
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
|
||||
|
||||
/**
|
||||
* The actions this host actually has. An action left out of here has no keys in
|
||||
* `hotkeysAtom`, so it never matches and never claims the keystroke.
|
||||
*/
|
||||
export const hotkeyActions: HotkeyAction[] = (
|
||||
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
||||
).sort((a, b) => {
|
||||
const scopeA = a.split(".")[0] || "";
|
||||
const scopeB = b.split(".")[0] || "";
|
||||
if (scopeA !== scopeB) {
|
||||
return scopeA.localeCompare(scopeB);
|
||||
}
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
)
|
||||
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
|
||||
.sort((a, b) => {
|
||||
const scopeA = a.split(".")[0] || "";
|
||||
const scopeB = b.split(".")[0] || "";
|
||||
if (scopeA !== scopeB) {
|
||||
return scopeA.localeCompare(scopeB);
|
||||
}
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
|
||||
export type HotKeyOptions = {
|
||||
enable?: boolean | (() => boolean);
|
||||
@@ -333,7 +345,9 @@ export function formatHotkeyString(trigger: string): string[] {
|
||||
} else if (p === "Alt") {
|
||||
labelParts.push("⌥");
|
||||
} else if (p === "Enter") {
|
||||
labelParts.push("↩");
|
||||
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
|
||||
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
|
||||
labelParts.push("↩︎");
|
||||
} else if (p === "Tab") {
|
||||
labelParts.push("⇥");
|
||||
} else if (p === "Backspace") {
|
||||
|
||||
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
|
||||
showDialog({
|
||||
id,
|
||||
title,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Alert({ onHide: hide, body }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ export async function showConfirm({
|
||||
return new Promise((onResult: ConfirmProps["onResult"]) => {
|
||||
showDialog({
|
||||
...extraProps,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||
import {
|
||||
type BatchUpsertResult,
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
type ImportSource,
|
||||
workspacesAtom,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showAlert } from "./alert";
|
||||
import { showDialog } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { pluralizeCount } from "./pluralize";
|
||||
import { router } from "./router";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
|
||||
// fetches) every time the dialog container re-renders.
|
||||
const planFile = (filePath: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
|
||||
const planUrl = (url: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_url", { url, destination });
|
||||
const listSources = (workspaceId: string) =>
|
||||
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
|
||||
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
|
||||
rpc<ImportSource[]>("cmd_import_sources_for_origin", args);
|
||||
|
||||
export const importData = createFastMutation({
|
||||
mutationKey: ["import_data"],
|
||||
onError: (err: string) => {
|
||||
@@ -21,29 +41,41 @@ export const importData = createFastMutation({
|
||||
},
|
||||
mutationFn: async () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const selectedFolder = jotaiStore.get(activeFolderAtom);
|
||||
showDialog({
|
||||
id: "import",
|
||||
title: "Import Data",
|
||||
size: "sm",
|
||||
size: "lg",
|
||||
disableClose: true,
|
||||
render: ({ hide }) => {
|
||||
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
|
||||
try {
|
||||
await finishImport(await runImport());
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
const cancel = () => {
|
||||
hide();
|
||||
resolve();
|
||||
};
|
||||
const fail = (err: unknown) => {
|
||||
hide();
|
||||
reject(err);
|
||||
};
|
||||
const commit = async (plan: ImportPlan) => {
|
||||
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
|
||||
hide();
|
||||
await finishImport(imported);
|
||||
resolve();
|
||||
};
|
||||
return (
|
||||
<ImportDataDialog
|
||||
importFile={(filePath) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
|
||||
}
|
||||
importUrl={(url) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
|
||||
}
|
||||
currentWorkspace={currentWorkspace}
|
||||
workspaces={workspaces}
|
||||
selectedFolder={selectedFolder}
|
||||
planFile={planFile}
|
||||
planUrl={planUrl}
|
||||
listSources={listSources}
|
||||
findSourcesForOrigin={findSourcesForOrigin}
|
||||
commit={commit}
|
||||
cancel={cancel}
|
||||
onError={fail}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
UpdateResponse,
|
||||
YaakNotification,
|
||||
} 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 { Button } from "../components/core/Button";
|
||||
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
||||
@@ -180,9 +180,65 @@ function showUpdateInstalledToast(version: string) {
|
||||
|
||||
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||
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
|
||||
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
||||
@@ -215,17 +271,7 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||
>
|
||||
{downloaded ? "Install Now" : "Download and Install"}
|
||||
</ButtonInfiniteLoading>
|
||||
<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>
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import type { JsonSchema } from "./jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "./jsonSchemaExample";
|
||||
|
||||
describe("buildExampleFromSchema", () => {
|
||||
test("fills scalar fields with placeholders", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number", format: "int32" },
|
||||
active: { type: "boolean" },
|
||||
data: { type: "string", format: "byte" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
name: "",
|
||||
age: 0,
|
||||
active: false,
|
||||
data: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("encodes 64-bit integers as strings", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "int64" },
|
||||
count: { type: "string", format: "uint64" },
|
||||
offset: { type: "string", format: "sfixed64" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
|
||||
});
|
||||
|
||||
test("fills date-time with a parseable timestamp", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { createdAt: { type: "string", format: "date-time" } },
|
||||
};
|
||||
|
||||
const example = buildExampleFromSchema(schema) as { createdAt: string };
|
||||
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
|
||||
});
|
||||
|
||||
test("fills a duration with a value that parses", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { timeout: { type: "string", format: "duration" } },
|
||||
};
|
||||
|
||||
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
|
||||
});
|
||||
|
||||
test("expands nested messages through $defs", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { user: { $ref: "#/$defs/example.User" } },
|
||||
$defs: {
|
||||
"example.User": {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
address: { $ref: "#/$defs/example.Address" },
|
||||
},
|
||||
},
|
||||
"example.Address": {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
user: { name: "", address: { city: "" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("gives repeated fields a single placeholder item", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
|
||||
unknown: { type: "array" },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
tags: [""],
|
||||
users: [{ name: "" }],
|
||||
unknown: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the first value of an enum", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
|
||||
empty: { type: "string", enum: [] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
|
||||
});
|
||||
|
||||
test("gives maps a single placeholder entry", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
labels: { type: "object", additionalProperties: { type: "string" } },
|
||||
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
labels: { key: "" },
|
||||
users: { key: { name: "" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("stops at the root self-reference", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
children: { type: "array", items: { $ref: "#" } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
|
||||
});
|
||||
|
||||
test("stops at a cycle between messages", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||
$defs: {
|
||||
"example.Node": {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
parent: { $ref: "#/$defs/example.Node" },
|
||||
leaf: { $ref: "#/$defs/example.Leaf" },
|
||||
},
|
||||
},
|
||||
"example.Leaf": {
|
||||
type: "object",
|
||||
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
node: { name: "", parent: {}, leaf: { node: {} } },
|
||||
});
|
||||
});
|
||||
|
||||
test("expands the same message twice when it is not on the same path", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
from: { $ref: "#/$defs/example.User" },
|
||||
to: { $ref: "#/$defs/example.User" },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
|
||||
});
|
||||
|
||||
test("fills every branch of a flattened oneof", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
text: { type: "string" },
|
||||
image: { $ref: "#/$defs/example.Image" },
|
||||
},
|
||||
$defs: {
|
||||
"example.Image": { type: "object", properties: { url: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
id: "",
|
||||
text: "",
|
||||
image: { url: "" },
|
||||
});
|
||||
});
|
||||
|
||||
test("stops expanding once the node budget runs out", () => {
|
||||
// Every level references the next one twice, so an unbounded walk would build 2^depth
|
||||
// nodes without ever repeating a ref on the same path.
|
||||
const depth = 16;
|
||||
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
|
||||
for (let i = 0; i < depth; i++) {
|
||||
$defs[`d${i}`] = {
|
||||
type: "object",
|
||||
properties: {
|
||||
a: { $ref: `#/$defs/d${i + 1}` },
|
||||
b: { $ref: `#/$defs/d${i + 1}` },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const example = buildExampleFromSchema({
|
||||
type: "object",
|
||||
properties: { root: { $ref: "#/$defs/d0" } },
|
||||
$defs,
|
||||
});
|
||||
|
||||
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
|
||||
expect(countNodes(example)).toBeLessThan(10_000);
|
||||
});
|
||||
|
||||
test("handles messages without a known type", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
empty: {},
|
||||
struct: { type: "object" },
|
||||
missing: { $ref: "#/$defs/example.Nope" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
|
||||
});
|
||||
});
|
||||
|
||||
function countNodes(value: unknown): number {
|
||||
if (Array.isArray(value)) {
|
||||
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
|
||||
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
|
||||
*/
|
||||
export type JsonSchema = {
|
||||
type?: string;
|
||||
format?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema;
|
||||
additionalProperties?: JsonSchema;
|
||||
enum?: unknown[];
|
||||
$defs?: Record<string, JsonSchema>;
|
||||
$ref?: string;
|
||||
};
|
||||
|
||||
const DEFS_PREFIX = "#/$defs/";
|
||||
const ROOT_REF = "#";
|
||||
|
||||
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
|
||||
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
|
||||
|
||||
// Refs on sibling branches each expand their own subtree, so a schema that references the
|
||||
// same messages repeatedly can produce exponentially many nodes without ever cycling.
|
||||
const MAX_NODES = 5000;
|
||||
|
||||
type Budget = { remaining: number };
|
||||
|
||||
/** Build a sample message with placeholder values for every field in the schema */
|
||||
export function buildExampleFromSchema(schema: JsonSchema): unknown {
|
||||
// The root is already being built, so a `#` ref anywhere below it is a cycle
|
||||
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
|
||||
}
|
||||
|
||||
function buildValue(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
budget.remaining -= 1;
|
||||
|
||||
if (typeof schema.$ref === "string") {
|
||||
if (refPath.has(schema.$ref)) {
|
||||
return {};
|
||||
}
|
||||
const resolved = resolveRef(schema.$ref, root);
|
||||
if (resolved == null) {
|
||||
return {};
|
||||
}
|
||||
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return schema.enum[0] ?? "";
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "object":
|
||||
return buildObject(schema, root, refPath, budget);
|
||||
case "array":
|
||||
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
|
||||
case "string":
|
||||
return buildString(schema.format);
|
||||
case "number":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildObject(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema.properties != null && typeof schema.properties === "object") {
|
||||
const example: Record<string, unknown> = {};
|
||||
for (const [name, propertySchema] of Object.entries(schema.properties)) {
|
||||
example[name] = buildValue(propertySchema, root, refPath, budget);
|
||||
}
|
||||
return example;
|
||||
}
|
||||
|
||||
// Maps have no properties, only a value schema
|
||||
if (schema.additionalProperties != null) {
|
||||
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildString(format: string | undefined): string {
|
||||
if (format === "date-time") {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
|
||||
if (format === "duration") {
|
||||
return "0s";
|
||||
}
|
||||
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
|
||||
return "0";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
|
||||
if (ref === ROOT_REF) {
|
||||
return root;
|
||||
}
|
||||
if (!ref.startsWith(DEFS_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
|
||||
}
|
||||
@@ -25,13 +25,8 @@ export async function showPromptForm({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
hideX: true,
|
||||
size: size ?? "sm",
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
onClose: () => {
|
||||
// Click backdrop, close, or escape
|
||||
resolve(null);
|
||||
},
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
Prompt({
|
||||
onCancel: () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ type ModelType = AnyModel["model"];
|
||||
type WorkspaceRequestSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingHttpVersion"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
@@ -18,9 +19,7 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
|
||||
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
||||
}[ModelType];
|
||||
|
||||
export type RequestSettingDefinition<
|
||||
K extends RequestSettingKey = RequestSettingKey,
|
||||
> = {
|
||||
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
|
||||
defaultValue: WorkspaceRequestSettings[K];
|
||||
description: string;
|
||||
modelKey: K;
|
||||
@@ -46,8 +45,7 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
|
||||
|
||||
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
|
||||
defaultValue: 64 * 1024 * 1024,
|
||||
description:
|
||||
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
||||
description: "Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
||||
modelKey: "settingRequestMessageSize",
|
||||
models: ["workspace", "folder", "websocket_request", "grpc_request"],
|
||||
title: "Message Size Limit",
|
||||
@@ -57,13 +55,7 @@ export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
|
||||
defaultValue: true,
|
||||
description: "When disabled, skip validation of server certificates.",
|
||||
modelKey: "settingValidateCertificates",
|
||||
models: [
|
||||
"workspace",
|
||||
"folder",
|
||||
"http_request",
|
||||
"websocket_request",
|
||||
"grpc_request",
|
||||
],
|
||||
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
|
||||
title: "Validate TLS certificates",
|
||||
});
|
||||
|
||||
@@ -75,10 +67,17 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
|
||||
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({
|
||||
defaultValue: true,
|
||||
description:
|
||||
"Attach matching cookies from the active cookie jar to outgoing requests.",
|
||||
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
|
||||
modelKey: "settingSendCookies",
|
||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||
title: "Automatically send cookies",
|
||||
@@ -86,8 +85,7 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
|
||||
|
||||
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
||||
defaultValue: true,
|
||||
description:
|
||||
"Save cookies from Set-Cookie response headers to the active cookie jar.",
|
||||
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
|
||||
modelKey: "settingStoreCookies",
|
||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||
title: "Automatically store cookies",
|
||||
|
||||
@@ -14,5 +14,6 @@ export const Route = createFileRoute("/workspaces/$workspaceId/settings")({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <Settings />;
|
||||
const { tab } = Route.useSearch();
|
||||
return <Settings tab={tab} />;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ yaak-api = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
|
||||
@@ -5,16 +5,20 @@ use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak::import;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{
|
||||
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
|
||||
};
|
||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
|
||||
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
|
||||
match import(ctx, args).await {
|
||||
Ok(result) => {
|
||||
Ok((result, items)) => {
|
||||
println!("Imported {}", format_counts(&result));
|
||||
if let Some(skipped) = format_skipped(&items) {
|
||||
println!("Skipped {skipped}");
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -37,7 +41,10 @@ pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> {
|
||||
async fn import(
|
||||
ctx: &CliContext,
|
||||
args: ImportArgs,
|
||||
) -> CommandResult<(BatchUpsertResult, Vec<ImportPlanItem>)> {
|
||||
if let Some(workspace_id) = args.workspace_id.as_deref() {
|
||||
ctx.db()
|
||||
.get_workspace(workspace_id)
|
||||
@@ -51,6 +58,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
|
||||
.import_data(&plugin_context, &file_contents)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
let importer = import_result.importer;
|
||||
let resources = import_result.resources;
|
||||
let workspace_id = args.workspace_id;
|
||||
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
|
||||
@@ -59,15 +67,62 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id,
|
||||
environment_id: None,
|
||||
cookie_jar_id: None,
|
||||
request_id: None,
|
||||
let destination = match workspace_id {
|
||||
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
||||
None => ImportDestination::NewWorkspace,
|
||||
};
|
||||
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
|
||||
let plan = import::plan_import_resources(
|
||||
ctx.query_manager(),
|
||||
importer,
|
||||
destination,
|
||||
resources,
|
||||
import_result.source_keys,
|
||||
Some(file_origin(&args.file)),
|
||||
)
|
||||
.map_err(|e| format!("Failed to plan import: {e}"))?;
|
||||
let items = plan.items.clone();
|
||||
let imported = import::commit_import_plan(ctx.query_manager(), plan)
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
Ok(imported)
|
||||
Ok((imported, items))
|
||||
}
|
||||
|
||||
fn file_origin(path: &std::path::Path) -> ImportOrigin {
|
||||
let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let label = canonical
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
|
||||
}
|
||||
|
||||
/// Summarize what the default selection left untouched during a merging re-import.
|
||||
fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
|
||||
let count = |action: ImportPlanAction| items.iter().filter(|i| i.action == action).count();
|
||||
let plural = |n: usize| if n == 1 { "" } else { "s" };
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let deletions = count(ImportPlanAction::Delete);
|
||||
if deletions > 0 {
|
||||
parts.push(format!("{deletions} removed from source (not deleted locally)"));
|
||||
}
|
||||
let conflicts = count(ImportPlanAction::Conflict);
|
||||
if conflicts > 0 {
|
||||
parts.push(format!("{conflicts} conflict{} (kept local changes)", plural(conflicts)));
|
||||
}
|
||||
let keep_local = count(ImportPlanAction::KeepLocal);
|
||||
if keep_local > 0 {
|
||||
parts.push(format!("{keep_local} with local edits"));
|
||||
}
|
||||
let not_imported = count(ImportPlanAction::NotImported);
|
||||
if not_imported > 0 {
|
||||
parts.push(format!("{not_imported} previously not imported"));
|
||||
}
|
||||
let unchanged = count(ImportPlanAction::Unchanged);
|
||||
if unchanged > 0 {
|
||||
parts.push(format!("{unchanged} unchanged"));
|
||||
}
|
||||
|
||||
if parts.is_empty() { None } else { Some(parts.join(", ")) }
|
||||
}
|
||||
|
||||
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
|
||||
|
||||
@@ -435,15 +435,12 @@ fn create(
|
||||
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
|
||||
let name = name.unwrap_or_default();
|
||||
let url = url.unwrap_or_default();
|
||||
let method = method.unwrap_or_else(|| "GET".to_string());
|
||||
|
||||
let request = HttpRequest {
|
||||
workspace_id,
|
||||
name,
|
||||
method: method.to_uppercase(),
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
let mut request = HttpRequest { workspace_id, name, url, ..Default::default() };
|
||||
// Only override the method when one was given; `HttpRequest::default()` is the
|
||||
// single place the fallback ("GET") is defined.
|
||||
if let Some(method) = method {
|
||||
request.method = method.to_uppercase();
|
||||
}
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
|
||||
@@ -49,6 +49,14 @@ impl CliContext {
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Guest: the desktop may have this DB open, so only what's safe beside a live session
|
||||
let _ = yaak_lifecycle::on_launch(
|
||||
&yaak_lifecycle::Host::guest(),
|
||||
&query_manager.connect(),
|
||||
&blob_manager,
|
||||
);
|
||||
|
||||
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
||||
|
||||
Self {
|
||||
|
||||
@@ -13,7 +13,7 @@ use tokio::task::JoinHandle;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::render::{render_grpc_request, render_http_request};
|
||||
use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
|
||||
@@ -4,6 +4,7 @@ use common::{cli_cmd, parse_created_id, query_manager, seed_request};
|
||||
use predicates::str::contains;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
#[test]
|
||||
fn export_writes_yaak_workspace_file() {
|
||||
@@ -81,14 +82,21 @@ fn import_reads_yaak_workspace_file() {
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(
|
||||
db.get_workspace("wrk_import").expect("workspace imported").name,
|
||||
"Imported Workspace"
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_http_request("req_import").expect("request imported").url,
|
||||
"https://example.com"
|
||||
);
|
||||
let workspaces = db.list_workspaces().expect("list imported workspaces");
|
||||
let workspace = workspaces
|
||||
.iter()
|
||||
.find(|workspace| workspace.name == "Imported Workspace")
|
||||
.expect("workspace imported");
|
||||
assert_ne!(workspace.id, "wrk_import");
|
||||
|
||||
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
|
||||
let request = requests
|
||||
.iter()
|
||||
.find(|request| request.name == "Imported Request")
|
||||
.expect("request imported");
|
||||
assert_ne!(request.id, "req_import");
|
||||
assert_eq!(request.workspace_id, workspace.id);
|
||||
assert_eq!(request.url, "https://example.com");
|
||||
}
|
||||
|
||||
fn write_postman_environment_fixture(path: &std::path::Path) {
|
||||
@@ -160,3 +168,150 @@ fn import_postman_environment_uses_workspace_id() {
|
||||
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
|
||||
assert_eq!(imported_environment.workspace_id, workspace_id);
|
||||
}
|
||||
|
||||
fn write_linked_fixture(path: &std::path::Path, requests: &[(&str, &str, &str)]) {
|
||||
let requests = requests
|
||||
.iter()
|
||||
.map(|(id, name, url)| {
|
||||
format!(
|
||||
r#"{{ "model": "http_request", "id": "{id}", "workspaceId": "wrk_link",
|
||||
"name": "{name}", "method": "GET", "url": "{url}" }}"#
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
std::fs::write(
|
||||
path,
|
||||
format!(
|
||||
r#"{{
|
||||
"yaakVersion": "test",
|
||||
"yaakSchema": 4,
|
||||
"resources": {{
|
||||
"workspaces": [{{ "model": "workspace", "id": "wrk_link", "name": "Linked Workspace" }}],
|
||||
"httpRequests": [{requests}]
|
||||
}}
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("write linked fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_import_merges_into_linked_workspace() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let import_path = temp_dir.path().join("linked.json");
|
||||
|
||||
write_linked_fixture(
|
||||
&import_path,
|
||||
&[
|
||||
("req_a", "Request A", "https://example.com/a"),
|
||||
("req_b", "Request B", "https://example.com/b"),
|
||||
],
|
||||
);
|
||||
cli_cmd(data_dir)
|
||||
.args(["import", import_path.to_str().expect("import path is utf-8")])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 1 workspace, 2 HTTP requests"));
|
||||
|
||||
let workspace_id = {
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
db.list_workspaces()
|
||||
.expect("list workspaces")
|
||||
.into_iter()
|
||||
.find(|w| w.name == "Linked Workspace")
|
||||
.expect("workspace imported")
|
||||
.id
|
||||
};
|
||||
|
||||
// The source doc changes A, drops B, and adds C. The default selection applies the
|
||||
// update and the create but leaves the removal as an offer.
|
||||
write_linked_fixture(
|
||||
&import_path,
|
||||
&[
|
||||
("req_a", "Request A", "https://example.com/a-v2"),
|
||||
("req_c", "Request C", "https://example.com/c"),
|
||||
],
|
||||
);
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
"--workspace-id",
|
||||
&workspace_id,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 2 HTTP requests"))
|
||||
.stdout(contains("Skipped 1 removed from source"));
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
let requests = db.list_http_requests(&workspace_id).expect("list requests");
|
||||
assert_eq!(requests.len(), 3, "merge must not duplicate: {requests:?}");
|
||||
assert_eq!(
|
||||
requests.iter().find(|r| r.name == "Request A").expect("request A").url,
|
||||
"https://example.com/a-v2"
|
||||
);
|
||||
assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply");
|
||||
assert!(requests.iter().any(|r| r.name == "Request C"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_import_leaves_deleted_resources_alone() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let import_path = temp_dir.path().join("linked.json");
|
||||
|
||||
write_linked_fixture(
|
||||
&import_path,
|
||||
&[
|
||||
("req_a", "Request A", "https://example.com/a"),
|
||||
("req_b", "Request B", "https://example.com/b"),
|
||||
],
|
||||
);
|
||||
cli_cmd(data_dir)
|
||||
.args(["import", import_path.to_str().expect("import path is utf-8")])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let workspace_id = {
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
let workspace_id = db
|
||||
.list_workspaces()
|
||||
.expect("list workspaces")
|
||||
.into_iter()
|
||||
.find(|w| w.name == "Linked Workspace")
|
||||
.expect("workspace imported")
|
||||
.id;
|
||||
let request_b = db
|
||||
.list_http_requests(&workspace_id)
|
||||
.expect("list requests")
|
||||
.into_iter()
|
||||
.find(|r| r.name == "Request B")
|
||||
.expect("request B imported");
|
||||
db.delete_http_request_by_id(&request_b.id, &UpdateSource::Sync)
|
||||
.expect("delete request B");
|
||||
workspace_id
|
||||
};
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
"--workspace-id",
|
||||
&workspace_id,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Skipped 1 previously not imported"));
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let requests =
|
||||
query_manager.connect().list_http_requests(&workspace_id).expect("list requests");
|
||||
assert_eq!(requests.len(), 1, "a deleted request must not come back: {requests:?}");
|
||||
assert_eq!(requests[0].name, "Request A");
|
||||
}
|
||||
|
||||
@@ -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>>,
|
||||
},
|
||||
}
|
||||
@@ -88,6 +88,7 @@ yaak-grpc = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-license = { workspace = true, optional = true }
|
||||
yaak-mac-window = { workspace = true }
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-sse = { workspace = true }
|
||||
|
||||
+28
-6
@@ -1,15 +1,37 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
|
||||
export type 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, };
|
||||
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 UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, };
|
||||
/**
|
||||
* 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 UpdateResponseAction = "install" | "skip";
|
||||
|
||||
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 };
|
||||
|
||||
@@ -3,11 +3,10 @@ use std::collections::BTreeMap;
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use KeyAndValueRef::{Ascii, Binary};
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use tauri::{Runtime, WebviewWindow};
|
||||
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
||||
use yaak_models::models::GrpcRequest;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
|
||||
let mut entries = BTreeMap::new();
|
||||
@@ -26,7 +25,7 @@ pub(crate) async fn build_metadata<R: Runtime>(
|
||||
request: &GrpcRequest,
|
||||
authentication_context_id: &str,
|
||||
) -> Result<BTreeMap<String, String>> {
|
||||
let plugin_manager = window.state::<PluginManager>();
|
||||
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
|
||||
let mut metadata = BTreeMap::new();
|
||||
|
||||
// Add the rest of metadata
|
||||
|
||||
@@ -14,7 +14,6 @@ use yaak_http::manager::HttpConnectionManager;
|
||||
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
/// Context for managing response state during HTTP transactions.
|
||||
/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only).
|
||||
@@ -149,7 +148,7 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
response_ctx: &mut ResponseContext<R>,
|
||||
) -> Result<SentHttpRequest> {
|
||||
let app_handle = window.app_handle().clone();
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let connection_manager = app_handle.state::<HttpConnectionManager>();
|
||||
let environment_id = environment.map(|e| e.id);
|
||||
|
||||
@@ -4,53 +4,84 @@ use crate::models_ext::QueryManagerExt;
|
||||
use std::fs::read_to_string;
|
||||
use std::io::ErrorKind;
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak::import::{self, ImportDataParams};
|
||||
use yaak::import::{self, PlanImportDataParams};
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
|
||||
|
||||
pub(crate) async fn import_data<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
origin: Option<ImportOrigin>,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let contents = read_import_file(file_path)?;
|
||||
import_contents(window, &contents).await
|
||||
let plan =
|
||||
plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?;
|
||||
commit_import(window, plan)
|
||||
}
|
||||
|
||||
pub(crate) async fn import_url<R: Runtime>(
|
||||
pub(crate) async fn plan_import_data<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
destination: ImportDestination,
|
||||
) -> Result<ImportPlan> {
|
||||
let contents = read_import_file(file_path)?;
|
||||
plan_import_contents(window, &contents, destination, Some(file_origin(file_path))).await
|
||||
}
|
||||
|
||||
pub(crate) async fn plan_import_url<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
url: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let contents = fetch_import_url(window, url).await?;
|
||||
import_contents(window, &contents).await
|
||||
destination: ImportDestination,
|
||||
) -> Result<ImportPlan> {
|
||||
let url = normalize_import_url(url)?;
|
||||
let contents = fetch_import_url(window, &url).await?;
|
||||
plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await
|
||||
}
|
||||
|
||||
async fn import_contents<R: Runtime>(
|
||||
async fn plan_import_contents<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
contents: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let plugin_manager = window.state::<PluginManager>();
|
||||
destination: ImportDestination,
|
||||
origin: Option<ImportOrigin>,
|
||||
) -> Result<ImportPlan> {
|
||||
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
|
||||
let query_manager = window.db_manager();
|
||||
let plugin_context = window.plugin_context();
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id: window.workspace_id(),
|
||||
environment_id: window.environment_id(),
|
||||
cookie_jar_id: window.cookie_jar_id(),
|
||||
request_id: None,
|
||||
};
|
||||
|
||||
Ok(import::import_data(ImportDataParams {
|
||||
Ok(import::plan_import_data(PlanImportDataParams {
|
||||
query_manager: &query_manager,
|
||||
plugin_manager: &plugin_manager,
|
||||
plugin_context: &plugin_context,
|
||||
workspace_context,
|
||||
destination,
|
||||
contents,
|
||||
origin,
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Canonicalize so re-importing the same file through a different spelling of its path still
|
||||
/// matches the linked source.
|
||||
pub(crate) fn file_origin(file_path: &str) -> ImportOrigin {
|
||||
let path = std::path::Path::new(file_path);
|
||||
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let label = canonical
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| file_path.to_string());
|
||||
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
|
||||
}
|
||||
|
||||
pub(crate) fn url_origin(url: &str) -> ImportOrigin {
|
||||
ImportOrigin { origin: url.to_string(), label: url.to_string() }
|
||||
}
|
||||
|
||||
pub(crate) fn commit_import<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
plan: ImportPlan,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
|
||||
}
|
||||
|
||||
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
|
||||
/// pipeline as a file on disk.
|
||||
///
|
||||
@@ -79,7 +110,7 @@ async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> R
|
||||
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
|
||||
}
|
||||
|
||||
fn normalize_import_url(url: &str) -> Result<String> {
|
||||
pub(crate) 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()));
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::grpc::{build_metadata, metadata_to_map};
|
||||
use crate::http_request::send_http_request;
|
||||
use crate::import::{import_data, import_url};
|
||||
use crate::import::{commit_import, plan_import_data, plan_import_url};
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::render::{render_grpc_request, render_template};
|
||||
@@ -40,12 +40,11 @@ use yaak_models::models::{
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
||||
RenderPurpose, ShowToastRequest,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
@@ -68,6 +67,7 @@ mod notifications;
|
||||
mod plugin_events;
|
||||
mod plugins_ext;
|
||||
mod render;
|
||||
mod restart;
|
||||
mod rpc_ext;
|
||||
mod sync_ext;
|
||||
mod updates;
|
||||
@@ -249,7 +249,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
||||
let resolved_settings =
|
||||
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
||||
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let req = render_grpc_request(
|
||||
&resolved_request,
|
||||
@@ -309,7 +309,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
let resolved_settings =
|
||||
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
||||
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_grpc_request(
|
||||
&resolved_request,
|
||||
@@ -904,7 +904,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
}
|
||||
|
||||
async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
|
||||
app_handle.request_restart();
|
||||
restart::request_restart(&app_handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -961,7 +961,6 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
|
||||
|
||||
async fn cmd_http_response_body<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
response_id: &str,
|
||||
filter: Option<&str>,
|
||||
) -> YaakResult<FilterResponse> {
|
||||
@@ -976,7 +975,8 @@ async fn cmd_http_response_body<R: Runtime>(
|
||||
.ok_or(GenericError("Failed to find response body".to_string()))?;
|
||||
|
||||
match filter {
|
||||
Some(filter) if !filter.is_empty() => Ok(plugin_manager
|
||||
Some(filter) if !filter.is_empty() => Ok(plugins_ext::plugin_manager(&window)
|
||||
.await?
|
||||
.filter_data(&window.plugin_context(), filter, &body, content_type)
|
||||
.await?),
|
||||
_ => Ok(FilterResponse { content: body, error: None }),
|
||||
@@ -1013,15 +1013,24 @@ async fn cmd_get_sse_events<R: Runtime>(
|
||||
async fn cmd_import_data<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
) -> YaakResult<BatchUpsertResult> {
|
||||
import_data(&window, file_path).await
|
||||
destination: ImportDestination,
|
||||
) -> YaakResult<ImportPlan> {
|
||||
plan_import_data(&window, file_path, destination).await
|
||||
}
|
||||
|
||||
async fn cmd_import_url<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
url: &str,
|
||||
destination: ImportDestination,
|
||||
) -> YaakResult<ImportPlan> {
|
||||
plan_import_url(&window, url, destination).await
|
||||
}
|
||||
|
||||
async fn cmd_commit_import<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plan: ImportPlan,
|
||||
) -> YaakResult<BatchUpsertResult> {
|
||||
import_url(&window, url).await
|
||||
commit_import(&window, plan)
|
||||
}
|
||||
|
||||
|
||||
@@ -1258,6 +1267,14 @@ pub fn run() {
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
let lifecycle_host = yaak_lifecycle::Host::owner()
|
||||
.with_responses_dir(app.path().app_data_dir()?.join("responses"));
|
||||
if let Err(e) =
|
||||
yaak_lifecycle::on_launch(&lifecycle_host, &app.db(), &app.blob_manager())
|
||||
{
|
||||
error!("on_launch hook failed: {e:?}");
|
||||
}
|
||||
|
||||
// The RPC command registry — every frontend command dispatches
|
||||
// through this via the single `rpc` Tauri command
|
||||
app.manage(rpc_ext::build_rpc_router::<TauriRuntime>());
|
||||
@@ -1357,15 +1374,16 @@ pub fn run() {
|
||||
let info = history::get_or_upsert_launch_info(&h);
|
||||
debug!("Launched Yaak {:?}", info);
|
||||
});
|
||||
|
||||
// Cancel pending requests
|
||||
let h = app_handle.clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let db = h.db();
|
||||
let _ = db.cancel_pending_http_responses();
|
||||
let _ = db.cancel_pending_grpc_connections();
|
||||
let _ = db.cancel_pending_websocket_connections();
|
||||
});
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::ThemeChanged(_), .. } => {
|
||||
// On macOS this is how OS appearance changes arrive: tao observes
|
||||
// AppleInterfaceThemeChangedNotification and emits it for every window
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
if let Some(state) =
|
||||
app_handle.try_state::<yaak_system_appearance::SystemAppearanceState>()
|
||||
{
|
||||
yaak_system_appearance::emit_change(app_handle, &state);
|
||||
}
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
@@ -1409,6 +1427,7 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
}
|
||||
RunEvent::Exit => restart::relaunch_if_requested(),
|
||||
_ => {}
|
||||
};
|
||||
});
|
||||
@@ -1430,7 +1449,10 @@ fn safe_uri(endpoint: &str) -> String {
|
||||
fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
let app_handle = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let plugin_manager: State<'_, PluginManager> = app_handle.state();
|
||||
let plugin_manager = match plugins_ext::plugin_manager(&app_handle).await {
|
||||
Ok(pm) => pm,
|
||||
Err(_) => return, // The runtime failed to boot; there are no events
|
||||
};
|
||||
let (rx_id, mut rx) = plugin_manager.subscribe("app").await;
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
@@ -1471,9 +1493,13 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
}
|
||||
};
|
||||
|
||||
let plugin_manager: State<'_, PluginManager> = app_handle.state();
|
||||
if let Err(e) = plugin_manager.reply(&event, &ev).await {
|
||||
warn!("Failed to reply to plugin manager: {:?}", e)
|
||||
match plugins_ext::plugin_manager(&app_handle).await {
|
||||
Ok(pm) => {
|
||||
if let Err(e) = pm.reply(&event, &ev).await {
|
||||
warn!("Failed to reply to plugin manager: {:?}", e)
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to get plugin manager for reply: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ use yaak_models::error::Result;
|
||||
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;
|
||||
|
||||
@@ -152,30 +151,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
}
|
||||
};
|
||||
|
||||
let db = query_manager.connect();
|
||||
if let Err(err) = db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
|
||||
{
|
||||
error!("Failed to prune model_changes rows on startup: {err:?}");
|
||||
}
|
||||
// Only stream writes that happen after this app launch.
|
||||
let cursor = ModelChangeCursor::from_launch_time();
|
||||
|
||||
let poll_query_manager = query_manager.clone();
|
||||
|
||||
// GC response bodies orphaned by cascade deletes, which historically
|
||||
// didn't clean the blob DB or responses directory
|
||||
let gc_query_manager = query_manager.clone();
|
||||
let gc_blob_manager = blob_manager.clone();
|
||||
let gc_responses_dir = app_path.join("responses");
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let db = gc_query_manager.connect();
|
||||
match db.delete_orphaned_response_bodies(&gc_blob_manager, &gc_responses_dir) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => log::info!("Deleted {n} orphaned response bodies"),
|
||||
Err(e) => error!("Failed to delete orphaned response bodies: {e:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
app_handle.manage(query_manager);
|
||||
app_handle.manage(blob_manager);
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ use yaak_plugins::events::{
|
||||
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
|
||||
WorkspaceInfo,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_handle::PluginHandle;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
@@ -205,7 +204,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
req.grpc_request.folder_id.as_deref(),
|
||||
environment_id.as_deref(),
|
||||
)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
@@ -231,7 +230,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
req.http_request.folder_id.as_deref(),
|
||||
environment_id.as_deref(),
|
||||
)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
@@ -267,7 +266,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
folder_id.as_deref(),
|
||||
environment_id.as_deref(),
|
||||
)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
|
||||
@@ -31,10 +31,43 @@ use yaak_plugins::api::{
|
||||
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
|
||||
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::error::Error::PluginErr;
|
||||
use yaak_plugins::plugin_meta::get_plugin_meta;
|
||||
|
||||
static EXITING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Manager Handle
|
||||
// ============================================================================
|
||||
|
||||
/// The plugin runtime boots in the background so startup doesn't wait on it.
|
||||
/// This handle is the only way to reach the manager: [`PluginManagerHandle::get`]
|
||||
/// resolves once boot completes, so callers can never observe a
|
||||
/// partially-initialized runtime.
|
||||
#[derive(Clone)]
|
||||
pub struct PluginManagerHandle {
|
||||
rx: tokio::sync::watch::Receiver<Option<std::result::Result<PluginManager, String>>>,
|
||||
}
|
||||
|
||||
impl PluginManagerHandle {
|
||||
pub async fn get(&self) -> yaak_plugins::error::Result<PluginManager> {
|
||||
let mut rx = self.rx.clone();
|
||||
let result = rx
|
||||
.wait_for(|v| v.is_some())
|
||||
.await
|
||||
.map_err(|_| PluginErr("Plugin runtime boot task died".to_string()))?;
|
||||
result.clone().unwrap().map_err(PluginErr)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the plugin runtime to finish booting and return the manager.
|
||||
pub async fn plugin_manager<R: Runtime>(
|
||||
manager: &impl Manager<R>,
|
||||
) -> yaak_plugins::error::Result<PluginManager> {
|
||||
let handle = manager.state::<PluginManagerHandle>().inner().clone();
|
||||
handle.get().await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Updater
|
||||
// ============================================================================
|
||||
@@ -146,7 +179,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
|
||||
name: &str,
|
||||
version: Option<String>,
|
||||
) -> Result<()> {
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(plugin_manager(&window).await?);
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||
@@ -167,6 +200,9 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
directory: &str,
|
||||
) -> Result<Plugin> {
|
||||
// Resolve the manager before writing the row so startup's plugin snapshot
|
||||
// can't include it and boot it a second time
|
||||
let plugin_manager = Arc::new(plugin_manager(&window).await?);
|
||||
let plugin = window.db().upsert_plugin(
|
||||
&Plugin {
|
||||
directory: directory.into(),
|
||||
@@ -178,7 +214,6 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;
|
||||
|
||||
Ok(plugin)
|
||||
@@ -188,7 +223,7 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
|
||||
plugin_id: &str,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<Plugin> {
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(plugin_manager(&window).await?);
|
||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
|
||||
@@ -217,7 +252,7 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(plugin_manager(&window).await?);
|
||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
|
||||
@@ -300,20 +335,38 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
let query_manager =
|
||||
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
|
||||
|
||||
// Create plugin manager asynchronously
|
||||
// Boot the plugin runtime in the background so the window shows
|
||||
// immediately. Everything that needs plugins resolves the handle,
|
||||
// which waits for this task to finish.
|
||||
let (tx, rx) = tokio::sync::watch::channel(None);
|
||||
app_handle.manage(PluginManagerHandle { rx });
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let manager = PluginManager::new(
|
||||
vendored_plugin_dir,
|
||||
installed_plugin_dir,
|
||||
node_bin_path,
|
||||
plugin_runtime_main,
|
||||
&query_manager,
|
||||
&PluginContext::new_empty(),
|
||||
dev_mode,
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
PluginManager::new(
|
||||
vendored_plugin_dir,
|
||||
installed_plugin_dir,
|
||||
node_bin_path,
|
||||
plugin_runtime_main,
|
||||
&query_manager,
|
||||
&PluginContext::new_empty(),
|
||||
dev_mode,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start plugin runtime");
|
||||
.unwrap_or_else(|_| Err(yaak_plugins::error::Error::PluginErr(
|
||||
"Timed out starting the plugin runtime".to_string(),
|
||||
)));
|
||||
|
||||
let manager = match result {
|
||||
Ok(manager) => manager,
|
||||
Err(e) => {
|
||||
error!("Failed to start plugin runtime: {e:?}");
|
||||
let _ = tx.send(Some(Err(e.to_string())));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Surface unexpected runtime crashes to the user
|
||||
let mut crash_rx = manager.runtime_crash_rx();
|
||||
@@ -339,7 +392,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
}
|
||||
});
|
||||
|
||||
app_handle_clone.manage(manager);
|
||||
let _ = tx.send(Some(Ok(manager)));
|
||||
});
|
||||
|
||||
let plugin_updater = PluginUpdater::new();
|
||||
@@ -355,8 +408,14 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
api.prevent_exit();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
info!("Exiting plugin runtime due to app exit");
|
||||
let manager: State<PluginManager> = app.state();
|
||||
manager.terminate().await;
|
||||
// Bound the wait in case the exit comes while boot is still
|
||||
// in flight
|
||||
let get_manager = plugin_manager(app);
|
||||
if let Ok(Ok(manager)) =
|
||||
tokio::time::timeout(Duration::from_secs(5), get_manager).await
|
||||
{
|
||||
manager.terminate().await;
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
//! `yaak-commands` when the template commands did. Callers in this crate do not
|
||||
//! need to track which is which.
|
||||
|
||||
pub use yaak::render::{render_grpc_request, render_http_request};
|
||||
pub use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
pub use yaak_commands::render::{render_json_value, render_template};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,11 @@ use yaak_grpc::ServiceDefinition;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{
|
||||
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportPlan};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
|
||||
@@ -109,10 +110,11 @@ impl<R: Runtime> Host for ClientCtx<R> {
|
||||
}
|
||||
|
||||
impl<R: Runtime> ClientCtx<R> {
|
||||
/// The plugin runtime this window talks to. Only the `PluginHost` impl
|
||||
/// below uses it; everything else goes through the trait.
|
||||
fn pm(&self) -> State<'_, PluginManager> {
|
||||
self.window.state::<PluginManager>()
|
||||
/// The plugin runtime this window talks to, once it finishes booting.
|
||||
/// Only the `PluginHost` impl below uses it; everything else goes through
|
||||
/// the trait.
|
||||
async fn pm(&self) -> yaak_plugins::error::Result<PluginManager> {
|
||||
crate::plugins_ext::plugin_manager(&self.window).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,35 +124,40 @@ impl<R: Runtime> ClientCtx<R> {
|
||||
/// providing them.
|
||||
impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
async fn loaded_plugin_metadata(&self, directory: &str) -> Option<PluginMetadata> {
|
||||
let handle = self.pm().get_plugin_by_dir(directory).await?;
|
||||
let handle = self.pm().await.ok()?.get_plugin_by_dir(directory).await?;
|
||||
Some(handle.info())
|
||||
}
|
||||
|
||||
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
|
||||
self.pm().take_init_errors().await
|
||||
match self.pm().await {
|
||||
Ok(pm) => pm.take_init_errors().await,
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
|
||||
self.pm().resolve_plugins_for_runtime_from_db(plugins).await
|
||||
match self.pm().await {
|
||||
Ok(pm) => pm.resolve_plugins_for_runtime_from_db(plugins).await,
|
||||
Err(_) => plugins,
|
||||
}
|
||||
}
|
||||
|
||||
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback {
|
||||
PluginTemplateCallback::new(
|
||||
Arc::new((*self.pm()).clone()),
|
||||
async fn template_callback(
|
||||
&self,
|
||||
purpose: RenderPurpose,
|
||||
) -> yaak_commands::Result<impl TemplateCallback> {
|
||||
Ok(PluginTemplateCallback::new(
|
||||
Arc::new(self.pm().await?),
|
||||
Arc::new(self.encryption_manager().clone()),
|
||||
&self.plugin_context(),
|
||||
purpose,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
async fn template_function_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(self
|
||||
.window
|
||||
.state::<PluginManager>()
|
||||
.get_template_function_summaries(&self.plugin_context())
|
||||
.await?)
|
||||
Ok(self.pm().await?.get_template_function_summaries(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn template_function_config(
|
||||
@@ -160,81 +167,81 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
model_id: &str,
|
||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||
Ok(self
|
||||
.window
|
||||
.state::<PluginManager>()
|
||||
.pm()
|
||||
.await?
|
||||
.get_template_function_config(&self.plugin_context(), function_name, values, model_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
|
||||
Ok(self.pm().get_themes(&self.plugin_context()).await?)
|
||||
Ok(self.pm().await?.get_themes(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn http_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(self.pm().get_http_request_actions(&self.plugin_context()).await?)
|
||||
Ok(self.pm().await?.get_http_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn websocket_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(self.pm().get_websocket_request_actions(&self.plugin_context()).await?)
|
||||
Ok(self.pm().await?.get_websocket_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn grpc_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(self.pm().get_grpc_request_actions(&self.plugin_context()).await?)
|
||||
Ok(self.pm().await?.get_grpc_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(self.pm().get_workspace_actions(&self.plugin_context()).await?)
|
||||
Ok(self.pm().await?.get_workspace_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(self.pm().get_folder_actions(&self.plugin_context()).await?)
|
||||
Ok(self.pm().await?.get_folder_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn call_http_request_action(
|
||||
&self,
|
||||
req: CallHttpRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_http_request_action(&self.plugin_context(), req).await?)
|
||||
Ok(self.pm().await?.call_http_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_grpc_request_action(
|
||||
&self,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_grpc_request_action(&self.plugin_context(), req).await?)
|
||||
Ok(self.pm().await?.call_grpc_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_websocket_request_action(
|
||||
&self,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_websocket_request_action(&self.plugin_context(), req).await?)
|
||||
Ok(self.pm().await?.call_websocket_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_workspace_action(
|
||||
&self,
|
||||
req: CallWorkspaceActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_workspace_action(&self.plugin_context(), req).await?)
|
||||
Ok(self.pm().await?.call_workspace_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_folder_action(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_folder_action(&self.plugin_context(), req).await?)
|
||||
Ok(self.pm().await?.call_folder_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results = self.pm().get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||
let results = self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
@@ -246,6 +253,7 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
|
||||
Ok(self
|
||||
.pm()
|
||||
.await?
|
||||
.get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id)
|
||||
.await?)
|
||||
}
|
||||
@@ -259,6 +267,7 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self
|
||||
.pm()
|
||||
.await?
|
||||
.call_http_authentication_action(
|
||||
&self.plugin_context(),
|
||||
auth_name,
|
||||
@@ -270,15 +279,18 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
}
|
||||
|
||||
async fn import_data(&self, content: &str) -> yaak_commands::Result<ImportResponse> {
|
||||
Ok(self.pm().import_data(&self.plugin_context(), content).await?)
|
||||
Ok(self.pm().await?.import_data(&self.plugin_context(), content).await?)
|
||||
}
|
||||
|
||||
async fn reload_plugins(&self, plugins: Vec<Plugin>) -> Vec<(String, String)> {
|
||||
self.pm().initialize_all_plugins(plugins, &self.plugin_context()).await
|
||||
match self.pm().await {
|
||||
Ok(pm) => pm.initialize_all_plugins(plugins, &self.plugin_context()).await,
|
||||
Err(e) => vec![("*".to_string(), e.to_string())],
|
||||
}
|
||||
}
|
||||
|
||||
async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> {
|
||||
let plugin_manager = Arc::new((*self.pm()).clone());
|
||||
let plugin_manager = Arc::new(self.pm().await?);
|
||||
let encryption_manager = Arc::new(self.encryption_manager().clone());
|
||||
Ok(encrypt_secure_template_function(
|
||||
plugin_manager,
|
||||
@@ -422,7 +434,7 @@ async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphq
|
||||
}
|
||||
|
||||
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
|
||||
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.response_id, req.filter.as_deref()).await?)
|
||||
Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
|
||||
@@ -441,12 +453,34 @@ async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGet
|
||||
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
|
||||
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
|
||||
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
|
||||
}
|
||||
|
||||
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?)
|
||||
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
|
||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
|
||||
}
|
||||
|
||||
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
|
||||
}
|
||||
|
||||
async fn cmd_list_import_sources<R: Runtime>(ctx: ClientCtx<R>, req: CmdListImportSourcesReq) -> Result<Vec<ImportSource>> {
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
|
||||
}
|
||||
|
||||
async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportSourcesForOriginReq) -> Result<Vec<ImportSource>> {
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
let origin = match (req.file_path, req.url) {
|
||||
(Some(file_path), _) => crate::import::file_origin(&file_path).origin,
|
||||
(None, Some(url)) => match crate::import::normalize_import_url(&url) {
|
||||
Ok(url) => crate::import::url_origin(&url).origin,
|
||||
Err(_) => return Ok(Vec::new()),
|
||||
},
|
||||
(None, None) => return Ok(Vec::new()),
|
||||
};
|
||||
Ok(ctx.window.db().list_import_sources_by_origin(&origin)?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
@@ -813,7 +847,7 @@ async fn cmd_ws_close<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsCloseReq) -> Resu
|
||||
}
|
||||
|
||||
async fn cmd_ws_connect<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsConnectReq) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||
Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_search<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsSearchReq) -> Result<PluginSearchResponse> {
|
||||
@@ -843,4 +877,3 @@ async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpda
|
||||
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use crate::restart;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
|
||||
@@ -13,7 +14,6 @@ use tokio::task::block_in_place;
|
||||
use tokio::time::sleep;
|
||||
use ts_rs::TS;
|
||||
use yaak_models::util::generate_id;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
use url::Url;
|
||||
use yaak_api::get_system_proxy_url;
|
||||
@@ -75,14 +75,6 @@ impl YaakUpdater {
|
||||
auto_download: bool,
|
||||
update_trigger: UpdateTrigger,
|
||||
) -> 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 update_key = format!("{:x}", md5::compute(settings.id));
|
||||
self.last_check = Some(Instant::now());
|
||||
@@ -105,8 +97,9 @@ impl YaakUpdater {
|
||||
block_in_place(|| {
|
||||
tauri::async_runtime::block_on(async move {
|
||||
info!("Shutting down plugin manager before update");
|
||||
let plugin_manager = w.state::<PluginManager>();
|
||||
plugin_manager.terminate().await;
|
||||
if let Ok(plugin_manager) = crate::plugins_ext::plugin_manager(&w).await {
|
||||
plugin_manager.terminate().await;
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -129,6 +122,18 @@ impl YaakUpdater {
|
||||
Some(update) => {
|
||||
let w = window.clone();
|
||||
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)
|
||||
let native_install_mode =
|
||||
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
|
||||
@@ -206,6 +211,23 @@ struct UpdateInfo {
|
||||
reply_event_id: String,
|
||||
version: String,
|
||||
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)]
|
||||
@@ -271,8 +293,12 @@ async fn start_integrated_update<R: Runtime>(
|
||||
let _guard = Unlisten { win: window, id: event_id };
|
||||
|
||||
// 2) Emit the event now that listener is in place
|
||||
let info =
|
||||
UpdateInfo { version: update.version.to_string(), downloaded, reply_event_id: reply_id };
|
||||
let info = UpdateInfo {
|
||||
version: update.version.to_string(),
|
||||
downloaded,
|
||||
install: UpdateInstall::Integrated,
|
||||
reply_event_id: reply_id,
|
||||
};
|
||||
window
|
||||
.emit_to(window.label(), "update_available", &info)
|
||||
.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) {
|
||||
// If the frontend doesn't respond, fallback to native dialogs
|
||||
let confirmed = window
|
||||
@@ -332,7 +376,7 @@ async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Upd
|
||||
))
|
||||
.blocking_show()
|
||||
{
|
||||
window.app_handle().request_restart();
|
||||
restart::request_restart(window.app_handle());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -375,7 +419,137 @@ fn detect_install_mode() -> Option<&'static str> {
|
||||
return Some("nsis");
|
||||
}
|
||||
#[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>(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use crate::import::import_data;
|
||||
use crate::import::{file_origin, import_data, url_origin};
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use log::{info, warn};
|
||||
use std::collections::HashMap;
|
||||
@@ -12,7 +12,6 @@ use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_models::util::generate_id;
|
||||
use yaak_plugins::events::{Color, ShowToastRequest};
|
||||
use yaak_plugins::install::download_and_install;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
@@ -44,7 +43,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(window).await?);
|
||||
let query_manager = app_handle.db_manager();
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
@@ -70,6 +69,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||
}
|
||||
"import-data" => {
|
||||
let mut file_path = query_map.get("path").map(|s| s.to_owned());
|
||||
let mut origin = None;
|
||||
let name = query_map.get("name").map(|s| s.to_owned()).unwrap_or("data".to_string());
|
||||
_ = window.set_focus();
|
||||
|
||||
@@ -99,6 +99,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||
.to_string();
|
||||
fs::write(&p, json)?;
|
||||
file_path = Some(p);
|
||||
origin = Some(url_origin(file_url));
|
||||
}
|
||||
|
||||
let file_path = match file_path {
|
||||
@@ -117,7 +118,8 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||
}
|
||||
};
|
||||
|
||||
let results = import_data(window, &file_path).await?;
|
||||
let origin = origin.unwrap_or_else(|| file_origin(&file_path));
|
||||
let results = import_data(window, &file_path, Some(origin)).await?;
|
||||
window.emit(
|
||||
"show_toast",
|
||||
ShowToastRequest {
|
||||
|
||||
@@ -22,7 +22,6 @@ use yaak_models::models::{
|
||||
};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
@@ -77,7 +76,7 @@ async fn send_websocket_message<R: Runtime>(
|
||||
)?;
|
||||
let (resolved_request, _auth_context_id) =
|
||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
&resolved_request,
|
||||
@@ -142,7 +141,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
cookie_jar_id: Option<&str>,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
_plugin_manager: State<'_, PluginManager>,
|
||||
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
||||
) -> Result<WebsocketConnection> {
|
||||
let unrendered_request = app_handle.db().get_websocket_request(request_id)?;
|
||||
@@ -156,7 +154,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
let settings = app_handle.db().get_settings();
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
&resolved_request,
|
||||
|
||||
@@ -4,9 +4,14 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
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]
|
||||
log = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
@@ -11,7 +11,7 @@ use tauri::{AppHandle, Runtime};
|
||||
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
|
||||
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -47,14 +47,12 @@ pub fn initialization_script(appearance: Appearance) -> String {
|
||||
|
||||
/// Detect the appearance the OS prefers, independent of any appearance that has
|
||||
/// been forced onto app windows (which is what the webview itself reports).
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn system_appearance() -> Option<Appearance> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(appearance) = gsettings_system_appearance() {
|
||||
return Some(appearance);
|
||||
}
|
||||
|
||||
// On macOS this reads AppleInterfaceStyle from the global user defaults
|
||||
match dark_light::detect() {
|
||||
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
|
||||
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
|
||||
@@ -66,11 +64,69 @@ pub fn system_appearance() -> Option<Appearance> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the appearance the OS prefers, independent of any appearance that has
|
||||
/// been forced onto app windows (which is what the webview itself reports).
|
||||
///
|
||||
/// This asks AppKit for the application's effective appearance, the same source tauri
|
||||
/// uses for `window.theme()`, instead of reading `AppleInterfaceStyle` from the user
|
||||
/// defaults: macOS 27 no longer reliably writes that key when dark mode is on, so anything
|
||||
/// reading it sees light mode. Appearances forced per window (yaak-mac-window) don't reach
|
||||
/// `NSApp`, so this is the OS preference.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn system_appearance() -> Option<Appearance> {
|
||||
use objc2_app_kit::{NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication};
|
||||
use objc2_foundation::NSArray;
|
||||
|
||||
// AppKit is main-thread only. Every caller runs there today; this keeps it correct if
|
||||
// one ever doesn't.
|
||||
dispatch2::run_on_main(|mtm| {
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
|
||||
// An appearance forced on the whole app (tauri's `set_theme` does this) would make
|
||||
// the effective appearance report the override instead of the OS preference. Nothing
|
||||
// in Yaak does that, but fall back to the user defaults if something ever does.
|
||||
//
|
||||
// SAFETY: Called on the main thread with the shared application
|
||||
if unsafe { app.appearance() }.is_some() {
|
||||
return defaults_appearance();
|
||||
}
|
||||
|
||||
// SAFETY: The appearance names are AppKit constants that live for the whole process
|
||||
let (dark, light) = unsafe { (NSAppearanceNameDarkAqua, NSAppearanceNameAqua) };
|
||||
let names = NSArray::from_slice(&[dark, light]);
|
||||
let best = app.effectiveAppearance().bestMatchFromAppearancesWithNames(&names)?;
|
||||
|
||||
// SAFETY: Both are valid strings
|
||||
let is_dark = unsafe { best.isEqualToString(dark) };
|
||||
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
|
||||
})
|
||||
}
|
||||
|
||||
/// The appearance macOS persists to the global user defaults. Absent means light, except
|
||||
/// on macOS 27, which stopped reliably writing the key. Only used when the effective
|
||||
/// appearance is forced and can't be trusted.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn defaults_appearance() -> Option<Appearance> {
|
||||
use objc2_foundation::{NSUserDefaults, ns_string};
|
||||
|
||||
// SAFETY: The standard defaults are a process-wide singleton and the key is a valid string
|
||||
let style = unsafe {
|
||||
NSUserDefaults::standardUserDefaults().stringForKey(ns_string!("AppleInterfaceStyle"))
|
||||
};
|
||||
|
||||
// SAFETY: Both are valid strings
|
||||
let is_dark = style.is_some_and(|style| unsafe { style.isEqualToString(ns_string!("Dark")) });
|
||||
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
pub fn system_appearance() -> Option<Appearance> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Start tracking the OS appearance. Linux polls for changes. macOS gets them from tauri's
|
||||
/// `WindowEvent::ThemeChanged` (tao observes `AppleInterfaceThemeChangedNotification`), which
|
||||
/// the app forwards to [`emit_change`], so no thread is needed there.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
|
||||
let last_appearance = system_appearance();
|
||||
@@ -80,13 +136,19 @@ pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceSta
|
||||
}
|
||||
|
||||
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
|
||||
let thread_state = state.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
|
||||
emit_change(&app_handle, &thread_state);
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let thread_state = state.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
|
||||
emit_change(&app_handle, &thread_state);
|
||||
}
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = app_handle;
|
||||
|
||||
Some(state)
|
||||
}
|
||||
|
||||
+466
-60
@@ -1,116 +1,522 @@
|
||||
// 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 AnyModel =
|
||||
| CookieJar
|
||||
| Environment
|
||||
| Folder
|
||||
| GraphQlIntrospection
|
||||
| GrpcConnection
|
||||
| GrpcEvent
|
||||
| GrpcRequest
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| 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 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 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 CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||
|
||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
||||
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 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 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 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 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 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 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;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
|
||||
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 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 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 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 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 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, };
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
export type HttpResponse = {
|
||||
model: "http_response";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
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, };
|
||||
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 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 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 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 HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
||||
export type ImportSource = {
|
||||
model: "import_source";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
importer: string;
|
||||
origin: string;
|
||||
originLabel: string;
|
||||
lastImportedAt: string;
|
||||
};
|
||||
|
||||
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
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 InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
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 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 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 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 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 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 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 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 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 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 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;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
||||
export type WorkspaceMeta = {
|
||||
model: "workspace_meta";
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
encryptionKey: EncryptedKey | null;
|
||||
settingSyncDir: string | null;
|
||||
};
|
||||
|
||||
+11
-5
File diff suppressed because one or more lines are too long
+100
-2
@@ -1,4 +1,102 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
||||
import type {
|
||||
Environment,
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "./gen_models";
|
||||
|
||||
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
export type BatchUpsertResult = {
|
||||
workspaces: Array<Workspace>;
|
||||
environments: Array<Environment>;
|
||||
folders: Array<Folder>;
|
||||
httpRequests: Array<HttpRequest>;
|
||||
grpcRequests: Array<GrpcRequest>;
|
||||
websocketRequests: Array<WebsocketRequest>;
|
||||
};
|
||||
|
||||
export type ImportConflictResolution = "keep_mine" | "take_source";
|
||||
|
||||
/**
|
||||
* Where a staged import will be committed.
|
||||
*
|
||||
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
|
||||
* the exact destination that confirmation will use.
|
||||
*/
|
||||
export type ImportDestination =
|
||||
| { type: "new_workspace" }
|
||||
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
|
||||
|
||||
/**
|
||||
* Where an import's contents came from, used to link the committed workspace back to it.
|
||||
*/
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ImportPlan = {
|
||||
importer: string;
|
||||
destination: ImportDestination;
|
||||
resources: BatchUpsertResult;
|
||||
warnings: Array<ImportPlanWarning>;
|
||||
/**
|
||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string };
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>;
|
||||
origin?: ImportOrigin;
|
||||
};
|
||||
|
||||
export type ImportPlanAction =
|
||||
| "create"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "unchanged"
|
||||
| "keep_local"
|
||||
| "conflict"
|
||||
| "not_imported";
|
||||
|
||||
export type ImportPlanItem = {
|
||||
action: ImportPlanAction;
|
||||
model: ImportResourceType;
|
||||
modelId: string;
|
||||
name: string;
|
||||
/**
|
||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
||||
*/
|
||||
parentId?: string;
|
||||
selected: boolean;
|
||||
resolution?: ImportConflictResolution;
|
||||
reason?: ImportPlanReason;
|
||||
/**
|
||||
* Fields where the source and the local copy disagree, so the preview can say why
|
||||
*/
|
||||
changedFields: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra context for an action that would otherwise be indistinguishable from its plain form.
|
||||
*/
|
||||
export type ImportPlanReason = "moved_into_not_imported_folder";
|
||||
|
||||
export type ImportPlanWarning = { title: string; detail: string };
|
||||
|
||||
/**
|
||||
* The model types an import plan can contain.
|
||||
*/
|
||||
export type ImportResourceType =
|
||||
| "environment"
|
||||
| "folder"
|
||||
| "grpc_request"
|
||||
| "http_request"
|
||||
| "websocket_request"
|
||||
| "workspace";
|
||||
|
||||
@@ -21,9 +21,10 @@ use yaak_git::{
|
||||
use yaak_grpc::ServiceDefinition;
|
||||
use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
@@ -229,6 +230,7 @@ pub struct CmdGetHttpResponseEventsReq {
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdImportDataReq {
|
||||
pub file_path: String,
|
||||
pub destination: ImportDestination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
@@ -236,6 +238,31 @@ pub struct CmdImportDataReq {
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdImportUrlReq {
|
||||
pub url: String,
|
||||
pub destination: ImportDestination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdCommitImportReq {
|
||||
pub plan: ImportPlan,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdListImportSourcesReq {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdImportSourcesForOriginReq {
|
||||
#[ts(optional)]
|
||||
pub file_path: Option<String>,
|
||||
#[ts(optional)]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
@@ -909,8 +936,11 @@ macro_rules! with_commands {
|
||||
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
|
||||
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
|
||||
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
|
||||
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
|
||||
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
|
||||
cmd_import_data(CmdImportDataReq) -> ImportPlan,
|
||||
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
|
||||
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
|
||||
cmd_list_import_sources(CmdListImportSourcesReq) -> Vec<ImportSource>,
|
||||
cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec<ImportSource>,
|
||||
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
|
||||
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
|
||||
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
|
||||
|
||||
@@ -6,14 +6,11 @@
|
||||
//! own environment chain before a plugin sees them, or an auth plugin receives
|
||||
//! `${[ api_key ]}` where it expected a key.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::error::Result;
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_json_value;
|
||||
use std::collections::HashMap;
|
||||
use yaak_models::models::AnyModel;
|
||||
use crate::render::render_form_values;
|
||||
use yaak_plugins::events::{
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, RenderPurpose,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::RenderOptions;
|
||||
@@ -31,7 +28,7 @@ pub async fn cmd_get_http_authentication_config<H: PluginHost>(
|
||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
// A config form is being displayed, so a template that cannot resolve
|
||||
// should show as blank rather than refuse to open the form.
|
||||
let values = render_auth_values(
|
||||
let values = render_form_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
@@ -50,7 +47,7 @@ pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
||||
) -> Result<()> {
|
||||
// An action actually uses these values, so an unresolvable template is an
|
||||
// error rather than an empty string that would silently authenticate wrong.
|
||||
let values = render_auth_values(
|
||||
let values = render_form_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
@@ -63,40 +60,3 @@ pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
||||
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Render the form's values against the environment chain the model sits in.
|
||||
///
|
||||
/// The chain depends on where the model lives — a request inherits through its
|
||||
/// folder, a workspace has only its own — so the model is what decides which
|
||||
/// variables are in scope.
|
||||
async fn render_auth_values<H: PluginHost>(
|
||||
host: &H,
|
||||
model: &AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
purpose: RenderPurpose,
|
||||
options: &RenderOptions,
|
||||
) -> Result<HashMap<String, JsonPrimitive>> {
|
||||
let (workspace_id, folder_id) = match model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
other => {
|
||||
return Err(Error::Generic(format!(
|
||||
"Cannot resolve authentication for a {}",
|
||||
other.model()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||
|
||||
let cb = host.template_callback(purpose);
|
||||
let rendered =
|
||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||
|
||||
Ok(serde_json::from_value(rendered)?)
|
||||
}
|
||||
|
||||
@@ -121,7 +121,12 @@ pub trait PluginHost: Host {
|
||||
/// a render — the variables come from the environment chain, which is an
|
||||
/// ordinary database read — so handing back the callback keeps the rest of
|
||||
/// rendering shared instead of pushing whole commands behind this trait.
|
||||
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback;
|
||||
/// Async so hosts that finish booting their plugin runtime in the
|
||||
/// background can wait for it here.
|
||||
fn template_callback(
|
||||
&self,
|
||||
purpose: RenderPurpose,
|
||||
) -> impl Future<Output = crate::Result<impl TemplateCallback>>;
|
||||
|
||||
/// Every template function the installed plugins expose, for the
|
||||
/// autocomplete menu.
|
||||
@@ -130,6 +135,7 @@ pub trait PluginHost: Host {
|
||||
) -> impl Future<Output = crate::Result<Vec<GetTemplateFunctionSummaryResponse>>>;
|
||||
|
||||
/// The form a template function wants to show for the given values.
|
||||
/// `values` arrive already rendered.
|
||||
fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! Rendering a template against an environment chain.
|
||||
//!
|
||||
//! The variables come from the chain, the functions come from the host's
|
||||
//! template callback. Neither of these knows which host it is running under —
|
||||
//! that is the whole point of taking the callback as a parameter.
|
||||
//! template callback. `render_template` and `render_json_value` know nothing
|
||||
//! about which host they run under — that is the whole point of taking the
|
||||
//! callback as a parameter. `render_form_values` sits one level up: resolving
|
||||
//! the chain a model sits in is an ordinary database read, so it takes the
|
||||
//! host and does that read before rendering.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::PluginHost;
|
||||
use serde_json::Value;
|
||||
use yaak_models::models::Environment;
|
||||
use std::collections::HashMap;
|
||||
use yaak_models::models::{AnyModel, Environment};
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_plugins::events::{JsonPrimitive, RenderPurpose};
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
pub async fn render_template<T: TemplateCallback>(
|
||||
@@ -28,3 +35,40 @@ pub async fn render_json_value<T: TemplateCallback>(
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
|
||||
/// Render a config form's values against the environment chain the model sits in.
|
||||
///
|
||||
/// The chain depends on where the model lives — a request inherits through its
|
||||
/// folder, a workspace has only its own — so the model is what decides which
|
||||
/// variables are in scope.
|
||||
pub(crate) async fn render_form_values<H: PluginHost>(
|
||||
host: &H,
|
||||
model: &AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
purpose: RenderPurpose,
|
||||
options: &RenderOptions,
|
||||
) -> Result<HashMap<String, JsonPrimitive>> {
|
||||
let (workspace_id, folder_id) = match model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
other => {
|
||||
return Err(Error::Generic(format!(
|
||||
"Cannot resolve environments for a {}",
|
||||
other.model()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||
|
||||
let cb = host.template_callback(purpose).await?;
|
||||
let rendered =
|
||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||
|
||||
Ok(serde_json::from_value(rendered)?)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_template;
|
||||
use crate::render::{render_form_values, render_template};
|
||||
use yaak_plugins::events::{
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
RenderPurpose,
|
||||
@@ -21,7 +21,7 @@ pub async fn cmd_render_template<H: PluginHost>(
|
||||
) -> Result<String> {
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
|
||||
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview));
|
||||
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview)).await?;
|
||||
let options = RenderOptions {
|
||||
// A preview that throws would show the user an error where they expect
|
||||
// to see the value so far, so callers rendering *into the UI* ask for
|
||||
@@ -41,7 +41,7 @@ pub async fn cmd_template_tokens_to_string<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateTokensToStringReq,
|
||||
) -> Result<String> {
|
||||
let cb = host.template_callback(RenderPurpose::Preview);
|
||||
let cb = host.template_callback(RenderPurpose::Preview).await?;
|
||||
Ok(transform_args(req.tokens, &cb)?.to_string())
|
||||
}
|
||||
|
||||
@@ -56,7 +56,19 @@ pub async fn cmd_template_function_config<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateFunctionConfigReq,
|
||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
host.template_function_config(&req.function_name, req.values, req.model.id()).await
|
||||
// A config form is being displayed, so a template that cannot resolve
|
||||
// should show as blank rather than refuse to open the form.
|
||||
let values = render_form_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
req.values,
|
||||
RenderPurpose::Preview,
|
||||
&RenderOptions::return_empty(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
host.template_function_config(&req.function_name, values, req.model.id()).await
|
||||
}
|
||||
|
||||
pub async fn cmd_get_themes<H: PluginHost>(
|
||||
|
||||
@@ -18,7 +18,7 @@ use yaak_commands::models::{
|
||||
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
|
||||
models_workspace_models,
|
||||
};
|
||||
use yaak_commands::templates::cmd_render_template;
|
||||
use yaak_commands::templates::{cmd_render_template, cmd_template_function_config};
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
@@ -172,6 +172,8 @@ struct SingleThreadedHost {
|
||||
/// The values the last auth-config call arrived with, so a test can check
|
||||
/// they were rendered before the host ever saw them.
|
||||
auth_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||
/// Same, for the last template-function-config call.
|
||||
fn_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||
}
|
||||
|
||||
impl Host for SingleThreadedHost {
|
||||
@@ -248,8 +250,11 @@ impl PluginHost for SingleThreadedHost {
|
||||
Err(yaak_commands::Error::Generic("no plugin runtime on this host".into()))
|
||||
}
|
||||
|
||||
fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback {
|
||||
NoTemplateFunctions
|
||||
async fn template_callback(
|
||||
&self,
|
||||
_purpose: RenderPurpose,
|
||||
) -> yaak_commands::Result<impl TemplateCallback> {
|
||||
Ok(NoTemplateFunctions)
|
||||
}
|
||||
|
||||
async fn template_function_summaries(
|
||||
@@ -261,9 +266,10 @@ impl PluginHost for SingleThreadedHost {
|
||||
async fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
_values: HashMap<String, JsonPrimitive>,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||
*self.fn_values.borrow_mut() = Some(values);
|
||||
Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()")))
|
||||
}
|
||||
|
||||
@@ -376,6 +382,7 @@ async fn a_single_threaded_host_can_implement_the_trait() {
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
fn_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
|
||||
@@ -448,6 +455,7 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
fn_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = host
|
||||
@@ -499,3 +507,65 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
|
||||
seen.get("password"),
|
||||
);
|
||||
}
|
||||
|
||||
/// Same contract as auth: template function argument values may contain
|
||||
/// templates (the 1Password token argument defaults to `${[1PASSWORD_TOKEN]}`),
|
||||
/// and the shared handler renders them before the host is called.
|
||||
#[tokio::test]
|
||||
async fn template_function_values_are_rendered_before_the_host_sees_them() {
|
||||
let TestHost { inner } = TestHost::new();
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
fn_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = host
|
||||
.db()
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Functions".to_string(), ..Default::default() },
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("workspace");
|
||||
host.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "1PASSWORD_TOKEN".to_string(),
|
||||
value: "ops_abc123".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("environment");
|
||||
let environment =
|
||||
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
|
||||
|
||||
let mut values = HashMap::new();
|
||||
values.insert("token".to_string(), JsonPrimitive::String("${[1PASSWORD_TOKEN]}".to_string()));
|
||||
|
||||
// The host refuses the call itself — it has no plugins — but only after the
|
||||
// handler has rendered and handed over the values, which is what matters.
|
||||
let _ = cmd_template_function_config(
|
||||
host.clone(),
|
||||
yaak_rpc_schema::CmdTemplateFunctionConfigReq {
|
||||
function_name: "1password.item".to_string(),
|
||||
values,
|
||||
model: AnyModel::Workspace(workspace),
|
||||
environment_id: Some(environment.id),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let seen = host.fn_values.borrow().clone().expect("the host should have been called");
|
||||
assert!(
|
||||
matches!(seen.get("token"), Some(JsonPrimitive::String(v)) if v == "ops_abc123"),
|
||||
"the template should have been rendered before reaching the host, got {:?}",
|
||||
seen.get("token"),
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+7
@@ -47,6 +47,7 @@ export type Folder = {
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GrpcRequest = {
|
||||
@@ -99,6 +100,7 @@ export type HttpRequest = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
@@ -114,8 +116,12 @@ export type HttpUrlParameter = {
|
||||
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 };
|
||||
|
||||
export type SyncModel =
|
||||
@@ -169,4 +175,5 @@ export type Workspace = {
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
@@ -210,7 +210,7 @@ fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEn
|
||||
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
|
||||
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
|
||||
"google.protobuf.Duration" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.Duration" => JsonSchemaEntry::string_with_format("duration"),
|
||||
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
|
||||
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
|
||||
|
||||
@@ -19,7 +19,6 @@ hyper-util = { version = "0.1.17", default-features = false, features = ["client
|
||||
log = { workspace = true }
|
||||
mime_guess = "2.0.5"
|
||||
native-tls = { version = "0.2", features = ["alpn"] }
|
||||
regex = "1.11.1"
|
||||
reqwest = { workspace = true, features = [
|
||||
"rustls-tls-manual-roots-no-provider",
|
||||
"native-tls",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::dns::LocalhostResolver;
|
||||
use crate::dns::{AddressFilter, LocalhostResolver};
|
||||
use crate::error::Result;
|
||||
use log::{debug, info, warn};
|
||||
use reqwest::{Client, ClientBuilder, Proxy, redirect};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use yaak_models::models::DnsOverride;
|
||||
use yaak_models::models::{DnsOverride, HttpVersion};
|
||||
use yaak_tls::{
|
||||
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
|
||||
};
|
||||
@@ -39,6 +39,7 @@ impl ConfiguredClient {
|
||||
/// supports TLS 1.0+ for legacy servers.
|
||||
fn build_native_tls_connector(
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
http_version: HttpVersion,
|
||||
) -> Result<native_tls::TlsConnector> {
|
||||
let mut builder = native_tls::TlsConnector::builder();
|
||||
builder.danger_accept_invalid_certs(true);
|
||||
@@ -46,7 +47,11 @@ fn build_native_tls_connector(
|
||||
builder.min_protocol_version(Some(native_tls::Protocol::Tlsv10));
|
||||
// reqwest cannot add ALPN to a connector it did not build, so without this
|
||||
// the native path would silently negotiate HTTP/1.1 for every request.
|
||||
builder.request_alpns(&["h2", "http/1.1"]);
|
||||
match http_version {
|
||||
HttpVersion::Auto => builder.request_alpns(&["h2", "http/1.1"]),
|
||||
HttpVersion::Http1 => builder.request_alpns(&["http/1.1"]),
|
||||
HttpVersion::Http2 => builder.request_alpns(&["h2"]),
|
||||
};
|
||||
|
||||
if let Some(identity) = build_native_tls_identity(client_cert)? {
|
||||
builder.identity(identity);
|
||||
@@ -100,16 +105,22 @@ pub enum HttpConnectionProxySetting {
|
||||
pub struct HttpConnectionOptions {
|
||||
pub id: String,
|
||||
pub validate_certificates: bool,
|
||||
pub http_version: HttpVersion,
|
||||
pub proxy: HttpConnectionProxySetting,
|
||||
pub client_certificate: Option<ClientCertificateConfig>,
|
||||
pub dns_overrides: Vec<DnsOverride>,
|
||||
/// Refuse connections to addresses a hostname resolves to. `None` means
|
||||
/// every resolved address is connectable, which is what the desktop wants:
|
||||
/// a user sending to their own machine or their own network is the point.
|
||||
/// A hosted sender is the caller that supplies one.
|
||||
pub address_filter: Option<AddressFilter>,
|
||||
}
|
||||
|
||||
impl HttpConnectionOptions {
|
||||
/// Build a reqwest Client and return it along with the DNS resolver.
|
||||
/// The resolver is returned separately so it can be configured per-request
|
||||
/// to emit DNS timing events to the appropriate channel.
|
||||
pub(crate) fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
|
||||
pub fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
|
||||
let mut client = client_builder()
|
||||
.connection_verbose(true)
|
||||
.redirect(redirect::Policy::none())
|
||||
@@ -123,19 +134,36 @@ impl HttpConnectionOptions {
|
||||
// This is needed so we can emit DNS timing events for each request
|
||||
.pool_max_idle_per_host(0);
|
||||
|
||||
match self.http_version {
|
||||
HttpVersion::Auto => {}
|
||||
HttpVersion::Http1 => client = client.http1_only(),
|
||||
HttpVersion::Http2 => client = client.http2_prior_knowledge(),
|
||||
}
|
||||
|
||||
// Configure TLS
|
||||
if self.validate_certificates {
|
||||
// Use rustls with platform certificate verification (TLS 1.2+ only)
|
||||
let config = get_tls_config(true, true, self.client_certificate.clone())?;
|
||||
let mut config = get_tls_config(true, true, self.client_certificate.clone())?;
|
||||
// A forced version must also constrain ALPN, or the server may
|
||||
// negotiate a protocol the client then refuses to speak
|
||||
match self.http_version {
|
||||
HttpVersion::Auto => {}
|
||||
HttpVersion::Http1 => config.alpn_protocols = vec![b"http/1.1".to_vec()],
|
||||
HttpVersion::Http2 => config.alpn_protocols = vec![b"h2".to_vec()],
|
||||
}
|
||||
client = client.use_preconfigured_tls(config);
|
||||
} else {
|
||||
// Use native TLS for maximum compatibility (supports TLS 1.0+)
|
||||
let connector = build_native_tls_connector(self.client_certificate.clone())?;
|
||||
let connector =
|
||||
build_native_tls_connector(self.client_certificate.clone(), self.http_version)?;
|
||||
client = client.use_preconfigured_tls(connector);
|
||||
}
|
||||
|
||||
// Configure DNS resolver - keep a reference to configure per-request
|
||||
let resolver = LocalhostResolver::new(self.dns_overrides.clone());
|
||||
let resolver = LocalhostResolver::with_address_filter(
|
||||
self.dns_overrides.clone(),
|
||||
self.address_filter.clone(),
|
||||
);
|
||||
client = client.dns_resolver(resolver.clone());
|
||||
|
||||
// Configure proxy
|
||||
|
||||
@@ -20,15 +20,32 @@ pub struct ResolvedOverride {
|
||||
pub ipv6: Vec<Ipv6Addr>,
|
||||
}
|
||||
|
||||
/// A veto on the addresses a hostname resolves to, consulted after resolution
|
||||
/// and before any connection is made. Returning `Err` refuses the whole lookup
|
||||
/// with that message; a hostname is never partially allowed.
|
||||
///
|
||||
/// A hosted sender uses this to refuse private and metadata ranges no matter
|
||||
/// what name they hide behind. Checking here rather than on the URL is what
|
||||
/// catches a public hostname that resolves to an internal address.
|
||||
pub type AddressFilter = Arc<dyn Fn(IpAddr) -> std::result::Result<(), String> + Send + Sync>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalhostResolver {
|
||||
fallback: HyperGaiResolver,
|
||||
event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,
|
||||
overrides: Arc<HashMap<String, ResolvedOverride>>,
|
||||
address_filter: Option<AddressFilter>,
|
||||
}
|
||||
|
||||
impl LocalhostResolver {
|
||||
pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> {
|
||||
Self::with_address_filter(dns_overrides, None)
|
||||
}
|
||||
|
||||
pub fn with_address_filter(
|
||||
dns_overrides: Vec<DnsOverride>,
|
||||
address_filter: Option<AddressFilter>,
|
||||
) -> Arc<Self> {
|
||||
let resolver = HyperGaiResolver::new();
|
||||
|
||||
// Pre-parse DNS overrides into a lookup map
|
||||
@@ -55,9 +72,25 @@ impl LocalhostResolver {
|
||||
fallback: resolver,
|
||||
event_tx: Arc::new(RwLock::new(None)),
|
||||
overrides: Arc::new(overrides),
|
||||
address_filter,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the address filter, if any, to a resolved address list.
|
||||
fn filter_addrs(
|
||||
filter: &Option<AddressFilter>,
|
||||
addrs: &[SocketAddr],
|
||||
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if let Some(filter) = filter {
|
||||
for addr in addrs {
|
||||
if let Err(reason) = filter(addr.ip()) {
|
||||
return Err(Box::new(std::io::Error::other(reason)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the event sender for the current request.
|
||||
/// This should be called before each request to direct DNS events
|
||||
/// to the appropriate channel.
|
||||
@@ -72,6 +105,7 @@ impl Resolve for LocalhostResolver {
|
||||
let host = name.as_str().to_lowercase();
|
||||
let event_tx = self.event_tx.clone();
|
||||
let overrides = self.overrides.clone();
|
||||
let address_filter = self.address_filter.clone();
|
||||
|
||||
info!("DNS resolve called for: {}", host);
|
||||
|
||||
@@ -94,6 +128,8 @@ impl Resolve for LocalhostResolver {
|
||||
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
|
||||
|
||||
return Box::pin(async move {
|
||||
Self::filter_addrs(&address_filter, &addrs)?;
|
||||
|
||||
// Emit DNS event for override
|
||||
let guard = event_tx.read().await;
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
@@ -125,6 +161,8 @@ impl Resolve for LocalhostResolver {
|
||||
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
|
||||
|
||||
return Box::pin(async move {
|
||||
Self::filter_addrs(&address_filter, &addrs)?;
|
||||
|
||||
// Emit DNS event for localhost resolution
|
||||
let guard = event_tx.read().await;
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
@@ -161,6 +199,7 @@ impl Resolve for LocalhostResolver {
|
||||
Ok(addrs) => {
|
||||
// Collect addresses for event emission
|
||||
let addr_vec: Vec<SocketAddr> = addrs.collect();
|
||||
Self::filter_addrs(&address_filter, &addr_vec)?;
|
||||
let addresses: Vec<String> =
|
||||
addr_vec.iter().map(|a| a.ip().to_string()).collect();
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ pub mod decompress;
|
||||
pub mod dns;
|
||||
pub mod error;
|
||||
pub mod manager;
|
||||
pub mod path_placeholders;
|
||||
mod proto;
|
||||
pub mod sender;
|
||||
pub mod tee_reader;
|
||||
pub mod transaction;
|
||||
pub mod types;
|
||||
|
||||
// Moved to yaak-models so the browser's wasm host can render requests with the
|
||||
// same code; re-exported here so existing callers keep their path.
|
||||
pub use yaak_models::path_placeholders;
|
||||
|
||||
@@ -28,7 +28,10 @@ impl HttpConnectionManager {
|
||||
|
||||
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> {
|
||||
let mut connections = self.connections.write().await;
|
||||
let id = opt.id.clone();
|
||||
// The key must include any per-request option that changes how the
|
||||
// client is built, or a send after a settings change reuses a client
|
||||
// built with the old value for up to the cache TTL.
|
||||
let id = format!("{}::{}::{}", opt.id, opt.validate_certificates, opt.http_version);
|
||||
|
||||
// Clean old connections
|
||||
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
|
||||
|
||||
+136
-11
@@ -78,8 +78,19 @@ impl SendableHttpRequest {
|
||||
}
|
||||
|
||||
pub fn insert_header(&mut self, header: (String, String)) {
|
||||
if header.0.eq_ignore_ascii_case("cookie") {
|
||||
if let Some(existing) =
|
||||
self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case("cookie"))
|
||||
{
|
||||
existing.1 = format!("{}; {}", existing.1, header.1);
|
||||
} else {
|
||||
self.headers.push(header);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(existing) =
|
||||
self.headers.iter_mut().find(|h| h.0.to_lowercase() == header.0.to_lowercase())
|
||||
self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case(&header.0))
|
||||
{
|
||||
existing.1 = header.1;
|
||||
} else {
|
||||
@@ -205,16 +216,23 @@ fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Va
|
||||
}
|
||||
|
||||
fn build_headers(r: &HttpRequest) -> Vec<(String, String)> {
|
||||
r.headers
|
||||
.iter()
|
||||
.filter_map(|h| {
|
||||
if h.enabled && !h.name.is_empty() {
|
||||
Some((h.name.clone(), h.value.clone()))
|
||||
} else {
|
||||
None
|
||||
// RFC 6265 allows only one Cookie field, so enabled Cookie rows fold into
|
||||
// the first one
|
||||
let mut headers: Vec<(String, String)> = Vec::new();
|
||||
for h in &r.headers {
|
||||
if !h.enabled || h.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if h.name.eq_ignore_ascii_case("cookie") {
|
||||
if let Some(existing) = headers.iter_mut().find(|e| e.0.eq_ignore_ascii_case("cookie"))
|
||||
{
|
||||
existing.1 = format!("{}; {}", existing.1, h.value);
|
||||
continue;
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
headers.push((h.name.clone(), h.value.clone()));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
async fn build_body(
|
||||
@@ -494,7 +512,114 @@ mod tests {
|
||||
use bytes::Bytes;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use yaak_models::models::{HttpRequest, HttpUrlParameter};
|
||||
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sendable_request_preserves_independent_cookie_enabled_states() {
|
||||
let request = HttpRequest {
|
||||
url: "https://example.com/api".to_string(),
|
||||
headers: vec![
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "Cookie".to_string(),
|
||||
value: "session=abc".to_string(),
|
||||
id: None,
|
||||
},
|
||||
HttpRequestHeader {
|
||||
enabled: false,
|
||||
name: "Cookie".to_string(),
|
||||
value: "debug=verbose".to_string(),
|
||||
id: None,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sendable =
|
||||
SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(sendable.headers, vec![("Cookie".to_string(), "session=abc".to_string())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sendable_request_merges_enabled_cookie_rows_into_one_field() {
|
||||
let request = HttpRequest {
|
||||
url: "https://example.com/api".to_string(),
|
||||
headers: vec![
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "Cookie".to_string(),
|
||||
value: "session=abc".to_string(),
|
||||
id: None,
|
||||
},
|
||||
HttpRequestHeader {
|
||||
enabled: false,
|
||||
name: "Cookie".to_string(),
|
||||
value: "debug=verbose".to_string(),
|
||||
id: None,
|
||||
},
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "cookie".to_string(),
|
||||
value: "theme=dark".to_string(),
|
||||
id: None,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sendable =
|
||||
SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sendable.headers,
|
||||
vec![("Cookie".to_string(), "session=abc; theme=dark".to_string())],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_header_appends_authentication_cookie() {
|
||||
let mut request = SendableHttpRequest {
|
||||
headers: vec![
|
||||
("Cookie".to_string(), "session=abc".to_string()),
|
||||
("Cookie".to_string(), "theme=dark".to_string()),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
request.insert_header(("cookie".to_string(), "api_key=secret".to_string()));
|
||||
|
||||
assert_eq!(
|
||||
request.headers,
|
||||
vec![
|
||||
("Cookie".to_string(), "session=abc; api_key=secret".to_string()),
|
||||
("Cookie".to_string(), "theme=dark".to_string()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sendable_request_preserves_serialized_path_delimiters() {
|
||||
let request = HttpRequest {
|
||||
url: "https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2"
|
||||
.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sendable =
|
||||
SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sendable.url,
|
||||
"https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_params() {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "yaak-lifecycle"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Lifecycle hooks shared by every host (desktop, browser, CLI). The hooks say
|
||||
//! what happens at each moment; the host decides when and on which thread.
|
||||
//!
|
||||
//! Builds for wasm32, so it can depend on `yaak-models` but not on the send
|
||||
//! engine or plugin runtime.
|
||||
|
||||
use log::info;
|
||||
use std::path::PathBuf;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::error::Result;
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// Has the database for the life of the app (desktop, browser worker)
|
||||
Owner,
|
||||
/// Short-lived, and an owner may be using the database right now (CLI).
|
||||
/// Must not touch anything in flight.
|
||||
Guest,
|
||||
}
|
||||
|
||||
/// Paths are `None` on hosts without a filesystem (the browser).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Host {
|
||||
pub role: Role,
|
||||
pub responses_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn owner() -> Self {
|
||||
Self { role: Role::Owner, responses_dir: None }
|
||||
}
|
||||
|
||||
pub fn guest() -> Self {
|
||||
Self { role: Role::Guest, responses_dir: None }
|
||||
}
|
||||
|
||||
pub fn with_responses_dir(mut self, dir: impl Into<PathBuf>) -> Self {
|
||||
self.responses_dir = Some(dir.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Run once after the database is open, before the host answers anything.
|
||||
pub fn on_launch(host: &Host, db: &ClientDb, blobs: &BlobManager) -> Result<()> {
|
||||
db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)?;
|
||||
|
||||
if host.role == Role::Owner {
|
||||
// Anything still in flight was left by the last session
|
||||
db.cancel_pending_http_responses()?;
|
||||
db.cancel_pending_grpc_connections()?;
|
||||
db.cancel_pending_websocket_connections()?;
|
||||
|
||||
// Cascaded deletes never cleaned up response bodies
|
||||
let deleted = match host.responses_dir.as_deref() {
|
||||
Some(dir) => db.delete_orphaned_response_bodies(blobs, dir)?,
|
||||
None => db.delete_orphaned_response_body_blobs(blobs)?,
|
||||
};
|
||||
if deleted > 0 {
|
||||
info!("Deleted {deleted} orphaned response bodies");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use yaak_models::blob_manager::BodyChunk;
|
||||
use yaak_models::init_in_memory;
|
||||
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseState, Workspace};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
#[test]
|
||||
fn only_the_owner_closes_what_the_last_session_left_open() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let source = &UpdateSource::Background;
|
||||
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Hooks".to_string(), ..Default::default() },
|
||||
source,
|
||||
)
|
||||
.unwrap();
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
source,
|
||||
)
|
||||
.unwrap();
|
||||
let pending = db
|
||||
.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
state: HttpResponseState::Connected,
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
&blob_manager,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
on_launch(&Host::guest(), &db, &blob_manager).unwrap();
|
||||
let response = db.get_http_response(&pending.id).unwrap();
|
||||
assert!(matches!(response.state, HttpResponseState::Connected));
|
||||
|
||||
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
|
||||
let response = db.get_http_response(&pending.id).unwrap();
|
||||
assert!(matches!(response.state, HttpResponseState::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_without_a_filesystem_still_sweeps_blobs() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
{
|
||||
let blob_ctx = blob_manager.connect();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
|
||||
}
|
||||
|
||||
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
|
||||
|
||||
assert!(!blob_manager.connect().body_exists("rs_gone").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,10 @@ serde_json = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
urlencoding = "2.1.3"
|
||||
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-templates = { path = "../yaak-templates", default-features = false }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
r2d2 = "0.8.10"
|
||||
|
||||
+62
-1
@@ -12,6 +12,7 @@ export type AnyModel =
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
@@ -110,6 +111,7 @@ export type Folder = {
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingRequestMessageSize: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type GraphQlIntrospection = {
|
||||
@@ -214,6 +216,7 @@ export type HttpRequest = {
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
@@ -304,6 +307,23 @@ export type HttpResponseHeader = { name: string; value: string };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
/**
|
||||
* The resolved send settings, values only: what an executor has to obey, with the sources
|
||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||
* crosses from a tab to the Yaak server, and what the server reads.
|
||||
*/
|
||||
export type HttpSendSettings = {
|
||||
validateCertificates: boolean;
|
||||
followRedirects: boolean;
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number;
|
||||
sendCookies: boolean;
|
||||
storeCookies: boolean;
|
||||
httpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
@@ -315,8 +335,41 @@ export type HttpUrlParameter = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type ImportSource = {
|
||||
model: "import_source";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
importer: string;
|
||||
origin: string;
|
||||
originLabel: string;
|
||||
lastImportedAt: string;
|
||||
};
|
||||
|
||||
export type ImportSourceResource = {
|
||||
model: "import_source_resource";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
importSourceId: string;
|
||||
sourceKey: string;
|
||||
modelType: string;
|
||||
/**
|
||||
* `None` once the user has decided not to import this key
|
||||
*/
|
||||
modelId?: string;
|
||||
/**
|
||||
* Hash of the resource as last applied or decided from the source, if one was recorded
|
||||
*/
|
||||
contentHash?: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
|
||||
export type KeyValue = {
|
||||
@@ -459,7 +512,14 @@ export type WebsocketEvent = {
|
||||
};
|
||||
|
||||
export type WebsocketEventType =
|
||||
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
||||
| "binary"
|
||||
| "close"
|
||||
| "error"
|
||||
| "frame"
|
||||
| "open"
|
||||
| "ping"
|
||||
| "pong"
|
||||
| "text";
|
||||
|
||||
export type WebsocketMessageType = "text" | "binary";
|
||||
|
||||
@@ -506,6 +566,7 @@ export type Workspace = {
|
||||
settingDnsOverrides: Array<DnsOverride>;
|
||||
settingSendCookies: boolean;
|
||||
settingStoreCookies: boolean;
|
||||
settingHttpVersion: HttpVersion;
|
||||
};
|
||||
|
||||
export type WorkspaceMeta = {
|
||||
|
||||
Generated
+100
-2
@@ -1,4 +1,102 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
||||
import type {
|
||||
Environment,
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "./gen_models";
|
||||
|
||||
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
export type BatchUpsertResult = {
|
||||
workspaces: Array<Workspace>;
|
||||
environments: Array<Environment>;
|
||||
folders: Array<Folder>;
|
||||
httpRequests: Array<HttpRequest>;
|
||||
grpcRequests: Array<GrpcRequest>;
|
||||
websocketRequests: Array<WebsocketRequest>;
|
||||
};
|
||||
|
||||
export type ImportConflictResolution = "keep_mine" | "take_source";
|
||||
|
||||
/**
|
||||
* Where a staged import will be committed.
|
||||
*
|
||||
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
|
||||
* the exact destination that confirmation will use.
|
||||
*/
|
||||
export type ImportDestination =
|
||||
| { type: "new_workspace" }
|
||||
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
|
||||
|
||||
/**
|
||||
* Where an import's contents came from, used to link the committed workspace back to it.
|
||||
*/
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ImportPlan = {
|
||||
importer: string;
|
||||
destination: ImportDestination;
|
||||
resources: BatchUpsertResult;
|
||||
warnings: Array<ImportPlanWarning>;
|
||||
/**
|
||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string };
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>;
|
||||
origin?: ImportOrigin;
|
||||
};
|
||||
|
||||
export type ImportPlanAction =
|
||||
| "create"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "unchanged"
|
||||
| "keep_local"
|
||||
| "conflict"
|
||||
| "not_imported";
|
||||
|
||||
export type ImportPlanItem = {
|
||||
action: ImportPlanAction;
|
||||
model: ImportResourceType;
|
||||
modelId: string;
|
||||
name: string;
|
||||
/**
|
||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
||||
*/
|
||||
parentId?: string;
|
||||
selected: boolean;
|
||||
resolution?: ImportConflictResolution;
|
||||
reason?: ImportPlanReason;
|
||||
/**
|
||||
* Fields where the source and the local copy disagree, so the preview can say why
|
||||
*/
|
||||
changedFields: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra context for an action that would otherwise be indistinguishable from its plain form.
|
||||
*/
|
||||
export type ImportPlanReason = "moved_into_not_imported_folder";
|
||||
|
||||
export type ImportPlanWarning = { title: string; detail: string };
|
||||
|
||||
/**
|
||||
* The model types an import plan can contain.
|
||||
*/
|
||||
export type ImportResourceType =
|
||||
| "environment"
|
||||
| "folder"
|
||||
| "grpc_request"
|
||||
| "http_request"
|
||||
| "websocket_request"
|
||||
| "workspace";
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createStore } from "jotai";
|
||||
import { expect, test } from "vitest";
|
||||
import type { HttpResponseEvent } from "../bindings/gen_models";
|
||||
import { httpResponseEventsAtom, modelStoreDataAtom } from "./atoms";
|
||||
import { newStoreData } from "./util";
|
||||
|
||||
// The five setting events that every send writes, all within the same millisecond
|
||||
const SETTING_NAMES = [
|
||||
"validate_certificates",
|
||||
"redirects",
|
||||
"timeout",
|
||||
"send_cookies",
|
||||
"store_cookies",
|
||||
];
|
||||
|
||||
function settingEvent(id: string, name: string, createdAt: string): HttpResponseEvent {
|
||||
return {
|
||||
model: "http_response_event",
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
workspaceId: "wk_1",
|
||||
responseId: "rs_1",
|
||||
event: { type: "setting", name, value: "true" },
|
||||
};
|
||||
}
|
||||
|
||||
test("events with equal createdAt keep store (DB) insertion order", () => {
|
||||
const store = createStore();
|
||||
const data = newStoreData();
|
||||
SETTING_NAMES.forEach((name, i) => {
|
||||
data.http_response_event[`hre_${i}`] = settingEvent(
|
||||
`hre_${i}`,
|
||||
name,
|
||||
"2026-08-17T00:00:00.123",
|
||||
);
|
||||
});
|
||||
store.set(modelStoreDataAtom, data);
|
||||
|
||||
const names = store.get(httpResponseEventsAtom).map((e) => {
|
||||
return e.event.type === "setting" ? e.event.name : e.event.type;
|
||||
});
|
||||
expect(names).toEqual(SETTING_NAMES);
|
||||
});
|
||||
|
||||
test("events with distinct createdAt sort ascending", () => {
|
||||
const store = createStore();
|
||||
const data = newStoreData();
|
||||
for (const [id, createdAt] of [
|
||||
["hre_b", "2026-08-17T00:00:00.456"],
|
||||
["hre_a", "2026-08-17T00:00:00.123"],
|
||||
["hre_c", "2026-08-17T00:00:00.789"],
|
||||
]) {
|
||||
data.http_response_event[id!] = settingEvent(id!, "timeout", createdAt!);
|
||||
}
|
||||
store.set(modelStoreDataAtom, data);
|
||||
|
||||
expect(store.get(httpResponseEventsAtom).map((e) => e.id)).toEqual(["hre_a", "hre_b", "hre_c"]);
|
||||
});
|
||||
@@ -61,7 +61,9 @@ export function createOrderedModelAtom<M extends AnyModel["model"]>(
|
||||
const modelData = data[modelType] ?? {};
|
||||
return Object.values(modelData).sort(
|
||||
(a: ExtractModel<AnyModel, M>, b: ExtractModel<AnyModel, M>) => {
|
||||
const n = a[field] > b[field] ? 1 : -1;
|
||||
// NOTE: ties must return 0, or the comparator is inconsistent and V8 reorders
|
||||
// equal-keyed rows. Sort is stable, so 0 preserves store (DB) insertion order.
|
||||
const n = a[field] === b[field] ? 0 : a[field] > b[field] ? 1 : -1;
|
||||
return order === "desc" ? n * -1 : n;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ export function newStoreData(): ModelStoreData {
|
||||
http_request: {},
|
||||
http_response: {},
|
||||
http_response_event: {},
|
||||
import_source: {},
|
||||
key_value: {},
|
||||
plugin: {},
|
||||
settings: {},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE workspaces ADD COLUMN setting_http_version TEXT DEFAULT 'auto' NOT NULL;
|
||||
|
||||
ALTER TABLE folders ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL;
|
||||
|
||||
ALTER TABLE http_requests ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE import_sources
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
model TEXT DEFAULT 'import_source' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
workspace_id TEXT NOT NULL,
|
||||
importer TEXT NOT NULL,
|
||||
origin TEXT NOT NULL,
|
||||
origin_label TEXT NOT NULL,
|
||||
last_imported_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE import_source_resources
|
||||
(
|
||||
model TEXT DEFAULT 'import_source_resource' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
import_source_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
model_type TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
snapshot TEXT NOT NULL,
|
||||
PRIMARY KEY (import_source_id, source_key)
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user