Compare commits

...
Author SHA1 Message Date
Gregory SchierandClaude Opus 5 64b9479deb fix(import): treat a pair's row ID as identity, not content
Headers, URL parameters, and environment variables each carry an `id`
that identifies the row to the pair editor. Importers leave it empty and
the editor stamps one in the first time it touches a resource, so merely
opening an imported request made the next import report it as locally
edited. `comparable` now drops `id` at every level rather than only the
top, since a row ID says no more about content than the model's own ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:31:12 -07:00
Gregory SchierandClaude Opus 5 6796569466 feat(import): say which fields differ, and stop offering to apply nothing
A row marked "edited" or "conflict" gave no way to tell what actually
differs, so a resource that drifted from the file — however it drifted —
reads as an unexplained accusation. The plan now carries the field names
the source and the local copy disagree on, and the row's tooltip lists
them.

The preview's primary button also read "Apply" when the current selection
would change nothing. It now reads "Done", since committing an empty
selection only records the decisions made in the preview.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:24:25 -07:00
Gregory SchierandClaude Opus 5 72d3bda769 fix(import): shrink the conflict control and fold its help inside it
The conflict row's segmented control ran at the default size with the help
icon floating outside the group. Drop it to the smallest button size and
hand the help to the control, which shows it in its last option whenever
the label is hidden — the only case where the label has nowhere to put it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:13:19 -07:00
Gregory SchierandClaude Opus 5 bd932ce85f feat(import): remember the user's import selection
Yaak now remembers, per item of a linked import source, whether the user
wants it. A mapping row that still points at a model means wanted, so
re-imports merge into it; a row with no model behind it means not wanted,
so re-imports leave it alone. Wanted-ness is structural rather than a flag:
deleting the model locally, or turning the item down in the preview, is
what makes it not wanted.

- import_source_resources drops `snapshot` for a nullable `content_hash`
  and lets `model_id` be NULL. The migration keeps every beta row's
  key to model mapping and starts hashes empty; until a hash is recorded,
  a difference can't be attributed to either side, so the item is offered
  once as a conflict that keeps local changes.
- comparable() ignores sortPriority. Importers number it from source
  order, so inserting one operation used to plan a fake update for
  everything after it. The trade is that pure reorders don't propagate.
- Keys the user turned down come back as unchecked "not imported" rows
  instead of being re-offered as new resources or resurrected. Checking
  one imports it under the same source key and pulls in the folders it
  needs.
- An imported resource the source moves into a folder that isn't imported
  is offered as a deletion, with a tooltip pointing at the folder.
- Plan-time source resolution binds by source-key overlap instead of by
  path, so a renamed or moved file merges into what it created and heals
  the stored origin. Several sources sharing keys never guess-merge: the
  plan says so and imports everything as new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 10:56:34 -07:00
Gregory Schier d2d4b80a09 feat(import): merge re-imports from a linked source instead of duplicating (#618) 2026-09-01 11:12:07 -07:00
Gregory Schier d461c982ec Don't block window creation on plugin runtime boot (#616) 2026-09-01 09:14:50 -07:00
Gregory SchierandClaude Fable 5 8eebee460e fix(pair-editor): remount the editor when its stateKey changes (#617)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 08:58:55 -07:00
Gregory SchierandClaude Opus 5 81a2a5d955 feat(import): add stable per-resource source keys to the importer contract (#614)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:11:54 -07:00
Gregory SchierandClaude Opus 5 e63a87718c fix(tooling): keep the formatter off generated files in every entry point (#615)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 07:40:42 -07:00
Gregory Schier 222ea9cb83 fix(editor): opt editors out of macOS Writing Tools suggestions
A "Write with Siri" pill appears over empty editors on the macOS 27 beta,
because Codemirror's contentDOM is a contenteditable like any other. The
`writingsuggestions` attribute opts an editable element out, and setting
it here covers every editor at once.

If the pill turns out to be separate from writing suggestions, the
remaining lever is WKWebView's writingToolsBehavior, which would disable
Writing Tools for the whole window.
2026-08-31 10:55:05 -07:00
Gregory Schier 360c098b09 fix(grpc): remove the dead Refresh item from the method picker
It has never had an onSelect. Introduced inert in #193 and carried
through the codebase split, so clicking it only ever closed the dropdown.
Reload Schema in the message editor's schema dropdown does the job it
looked like it was for.
2026-08-31 10:54:40 -07:00
Gregory Schier fce1039bac feat(grpc): generate an example message from the method schema (#613) 2026-08-31 10:44:31 -07:00
Gregory Schier f18f93d613 Stage imports before committing (#571) 2026-08-31 10:20:44 -07:00
Gregory SchierandClaude Opus 5 661a384bed Faster codemirror search match counting (#612)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 10:09:50 -07:00
Gregory SchierandClaude Fable 5 50cccf1d25 fix(auth-oauth1): sign with the token secret when no access token is set (#611)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:27:40 -07:00
Gregory SchierandClaude Fable 5 0aae516f3a feat(importer-openapi): fill the OAuth redirect URI from an environment variable (#610)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:27:36 -07:00
Gregory SchierandClaude Fable 5 b7b8ae5f94 feat(settings): add HTTP version as an inherited request setting (#609)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:01:02 -07:00
Gregory SchierandClaude Fable 5 6777003b70 fix(updater): check for updates on Linux deb/rpm installs and prompt manual download (#604)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:43:54 -07:00
Gregory SchierandClaude Fable 5 426c6d5eb6 fix(plugins): render template function config values before plugins see them (#608)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:19:15 -07:00
Ngo Quoc Viet 068afe325e fix(importer-curl): keep an = inside --url-query and form values (#606) 2026-08-24 21:14:37 -07:00
Ngo Quoc Viet cef1129d4b fix(template-function-json): escape control characters in json.escape (#607) 2026-08-24 21:14:14 -07:00
Ngo Quoc Viet 9a7bcf73bb fix(auth-oauth1): use the signing key as the PLAINTEXT signature (#605) 2026-08-24 21:13:57 -07:00
Gregory SchierandClaude Opus 5 aa76d501f0 fix(appearance): detect the macOS system appearance via NSApp.effectiveAppearance (#603)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:04:26 -07:00
Gregory Schier ce8cb5e7bc fix(environments): avoid opening dropdown with editor 2026-08-20 11:14:36 -07:00
Gregory Schier 66c13d7e13 Port OpenAPI improvements from the integration branch (#602) 2026-08-20 08:42:42 -07:00
Ngo Quoc Viet 388dd8815f fix(copy-as): emit shell-safe arguments in the curl and gRPCurl exporters (#593) 2026-08-20 08:41:47 -07:00
Ngo Quoc Viet cb295b7102 fix(importer-curl): keep --data-urlencode whole and survive a stray percent (#597) 2026-08-20 08:41:03 -07:00
Gregory Schier 4e835cf7e8 Add OpenAPI import round-trip harness (#600) 2026-08-20 07:44:15 -07:00
Gregory Schier 2350689d7a Import OpenAPI security the way Yaak inherits it (#601) 2026-08-20 07:39:18 -07:00
Gregory Schier 4eebd606ef Fix OpenAPI importer issues found by spec review (#599) 2026-08-20 07:16:44 -07:00
Gregory Schier c4f96f3f11 Create environments for OpenAPI server URLs (#590) 2026-08-19 12:45:32 -07:00
Gregory Schier 71c217d3f0 Populate OAuth credential variables during OpenAPI import (#587) 2026-08-19 08:43:17 -07:00
Gregory Schier d89831a84d Preserve OpenAPI security requirement semantics (#586) 2026-08-19 08:35:12 -07:00
Gregory Schier 3332ae263f Align OpenAPI 3.2 URL expectations with base URL variables (#594) 2026-08-19 07:42:54 -07:00
Gregory Schier df1fd864b2 Support OpenAPI 3.2 QUERY and additional operations (#591) 2026-08-19 07:10:32 -07:00
Gregory Schier a2d54ca774 Preserve base URL variable when OpenAPI servers are omitted (#585) 2026-08-19 07:07:18 -07:00
Gregory Schier 36fec8b005 Render all columns in irregular CSV responses (#584) 2026-08-18 20:24:57 -07:00
Gregory Schier 115615d994 Let the web image workflow publish a version by hand
`latest` could only ever come from a release tag, and tagging also fires the
app and CLI releases. A dispatch that names a version now publishes it and
latest, which is how the first image gets out before a release exists.
2026-08-18 11:48:59 -07:00
Gregory Schier 538f782068 Serve the web client from yaak-web (#582) 2026-08-18 10:39:17 -07:00
Gregory Schier 3f202ff664 Fix macOS relaunch activation (#583) 2026-08-18 09:56:40 -07:00
Ngo Quoc Viet 131b7e5ab1 fix(importer-curl): read combined short flags as separate options (#577) 2026-08-18 09:09:04 -07:00
Gregory Schier 69083918f9 Add the browser send proxy and web sender (#572) 2026-08-18 08:28:01 -07:00
Gregory Schier 4b2dcf9a1a Open Settings in a dialog on the web build (#581) 2026-08-17 19:07:25 -07:00
Gregory Schier 569f552d79 Stop sending scope on the authorization code token request (#579) 2026-08-17 12:23:08 -07:00
Gregory Schier 33f32cccf6 Leave browser zoom to the browser (#578)
The web build bound Cmd/Ctrl +, - and 0 and then did nothing with them, so zoom appeared broken. Those keys are the browser's own page zoom, which scales the whole page and remembers it per site.

Adds an interfaceZoom capability: true on desktop, where the host zooms the webview, false in a browser. When false the app binds nothing and the hotkeys screen drops the three rows it can't configure.
2026-08-17 11:29:20 -07:00
Gregory Schier 7ca772347f Sweep orphaned response bodies at browser startup (#576) 2026-08-17 10:38:52 -07:00
Gregory Schier b89c448345 Fix cookie defaults and Timeline ordering, and give models a real Default (#573) 2026-08-17 09:14:17 -07:00
Gregory Schier 2021df112a Implement response deletes in the browser host instead of declining them (#574) 2026-08-17 09:02:18 -07:00
Gregory Schier 2d2a390bfd Use clang-18 from apt.llvm.org for the wasm build on 22.04 runners
clang-15 got past the C23 [[noreturn]] error but still fails compiling
sqlite-wasm-rs for wasm32: its stdint.h falls through to host glibc headers
(bits/libc-header-start.h not found). clang-18 handles wasm32 as freestanding
and compiles it (it is what ubuntu-24.04 uses). 22.04's repos stop at clang-15,
so install 18 from apt.llvm.org. Runners stay on 22.04 to keep the glibc floor.
2026-08-16 22:54:34 -07:00
Gregory Schier 5cce23566a Allow manual worktree setup 2026-08-16 22:32:33 -07:00
Gregory Schier e99f6d2bc7 Gate the titlebar inset on a windowChrome capability instead of osType (#570) 2026-08-16 22:21:38 -07:00
Gregory Schier bea58b16b4 Force text presentation for the Enter hotkey symbol (#569) 2026-08-16 22:21:04 -07:00
Gregory Schier 93fba4d9b4 Restore ubuntu-22.04 release runners; use clang-15 for the wasm build (#568) 2026-08-16 21:52:56 -07:00
Gregory Schier b9071eafe0 Revert "Guard app releases against missing artifacts"
This reverts commit 778c74c635.
2026-08-16 18:19:47 -07:00
Gregory Schier 778c74c635 Guard app releases against missing artifacts 2026-08-16 17:30:10 -07:00
Gregory Schier 0f434361a7 Fix Linux WASM release builds 2026-08-16 15:41:00 -07:00
Gregory Schier d27d11af7c Move plugin actions and authentication onto PluginHost (#563) 2026-08-16 11:41:03 -07:00
Gregory Schier 1a19a06a23 Add native OpenAPI importer (#486) 2026-08-16 11:40:34 -07:00
Gregory Schier 07a9a6c6c0 Update NTLM auth tests for the new send() response shape (#567) 2026-08-16 11:35:17 -07:00
Gregory Schier e54240d579 Let plugins declare assets to place beside the bundle (#565) 2026-08-16 11:22:43 -07:00
Gregory Schier 8bca013ab4 Fix plugin runtime build and JSON linter crash (#566) 2026-08-16 11:22:02 -07:00
Gregory Schier 10e962a0e6 Add a plugin API for reading HTTP response bodies (#560) 2026-08-16 11:10:14 -07:00
Gregory Schier 78954e10c8 Fix 23 Dependabot alerts (#562) 2026-08-16 10:27:28 -07:00
Gregory Schier 9eb7a001da Move template rendering and themes onto PluginHost (#559) 2026-08-16 09:22:46 -07:00
Gregory Schier 6a02cbe525 Add a Host trait and move DB/model commands off Tauri (#558) 2026-08-16 08:41:35 -07:00
Gregory Schier 6f91f76064 Run the desktop's model layer in the browser (#557) 2026-08-16 07:34:48 -07:00
Gregory Schier 32e92d484b Let yaak-models compile for wasm32-unknown-unknown (#556) 2026-08-15 14:55:54 -07:00
263 changed files with 31688 additions and 6725 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
**/node_modules
dist
**/dist
target
**/target
.claude
vendored
**/vendored
*.log
.git
+4
View File
@@ -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
+12
View File
@@ -103,6 +103,18 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
# crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
# through to host glibc headers ("bits/libc-header-start.h" not found).
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
# this compiler, so the shipped binary keeps 22.04's glibc floor.
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
chmod +x /tmp/llvm.sh
sudo /tmp/llvm.sh 18
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
+123
View File
@@ -0,0 +1,123 @@
name: Release web image
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
# One image per architecture on its own native runner (emulating a Rust release build is hours),
# joined into one multi-arch tag at the end.
on:
push:
tags: [v*]
workflow_dispatch:
inputs:
version:
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
required: false
type: string
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/mountain-loop/yaak-web
jobs:
build:
if: github.repository == 'mountain-loop/yaak'
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-22.04
arch: amd64
- platform: linux/arm64
runner: ubuntu-22.04-arm
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.web
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
publish:
name: Publish manifest
needs: build
runs-on: ubuntu-latest
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# `latest` follows a release tag, and a manual run that names a version — the way to
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
- name: Tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
flavor: latest=false
tags: |
type=semver,pattern={{version}}
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
type=ref,event=branch
type=sha,format=short
- name: Create and push the manifest
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
-3
View File
@@ -1,3 +0,0 @@
**/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/**
Generated
+430 -78
View File
@@ -2,6 +2,18 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "accessory"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc86a613208ca7d144ca24f6ec49c5cdfa5c05a46f2f7ab5880ff99371f165de"
dependencies = [
"macroific 3.0.1",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "adler2"
version = "2.0.0"
@@ -40,18 +52,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "0.6.10"
@@ -249,7 +249,7 @@ dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.8.5",
"rand 0.8.7",
"serde",
"serde_repr",
"url",
@@ -265,7 +265,7 @@ dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.1",
"rand 0.9.5",
"raw-window-handle",
"serde",
"serde_repr",
@@ -585,9 +585,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "aws-lc-rs"
version = "1.16.1"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -595,14 +595,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.38.0"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -618,6 +619,8 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
@@ -626,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]]
@@ -650,6 +658,7 @@ dependencies = [
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -1066,10 +1075,11 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.26"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
@@ -1386,6 +1396,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "const-random"
version = "0.1.18"
@@ -1870,6 +1890,20 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
[[package]]
name = "delegate-display"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54e3a499943fd5180aeffcb708164ffb22125ceeccb05491b5297981d37d6249"
dependencies = [
"impartial-ord",
"itoa",
"macroific 3.0.1",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "deranged"
version = "0.5.5"
@@ -2361,6 +2395,18 @@ dependencies = [
"regex-syntax 0.8.5",
]
[[package]]
name = "fancy_constructor"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a8211ab12c36b63c269e17873331dc1da196a3edb7a5c79015677ef972da34a"
dependencies = [
"macroific 3.0.1",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "fast-glob"
version = "1.0.0"
@@ -2417,6 +2463,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "fixedbitset"
version = "0.4.2"
@@ -2849,8 +2901,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -3091,7 +3145,7 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
"ahash",
]
[[package]]
@@ -3099,9 +3153,6 @@ name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
]
[[package]]
name = "hashbrown"
@@ -3131,11 +3182,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
dependencies = [
"hashbrown 0.14.5",
"hashbrown 0.16.1",
]
[[package]]
@@ -3233,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"
@@ -3497,6 +3554,17 @@ dependencies = [
"tiff",
]
[[package]]
name = "impartial-ord"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc680e03d1a08bdc1c01b60e0c39723430ced63d8cabd93cee0449b6ba916a25"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "include_dir"
version = "0.7.4"
@@ -3516,6 +3584,40 @@ dependencies = [
"quote",
]
[[package]]
name = "indexed_db_futures"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69ff41758cbd104e91033bb53bc449bec7eea65652960c81eddf3fc146ecea19"
dependencies = [
"accessory",
"cfg-if",
"delegate-display",
"derive_more 2.1.1",
"fancy_constructor",
"indexed_db_futures_macros_internal",
"js-sys",
"sealed 0.6.0",
"smallvec",
"thiserror 2.0.17",
"tokio",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "indexed_db_futures_macros_internal"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caeba94923b68f254abef921cea7e7698bf4675fdd89d7c58bf1ed885b49a27d"
dependencies = [
"macroific 2.0.0",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -3561,17 +3663,6 @@ dependencies = [
"cfb",
]
[[package]]
name = "inherent"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c38228f24186d9cc68c729accb4d413be9eaed6ad07ff79e0270d9e56f3de13"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "inotify"
version = "0.11.0"
@@ -4007,9 +4098,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
dependencies = [
"cc",
"pkg-config",
@@ -4109,6 +4200,102 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "macroific"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285"
dependencies = [
"macroific_attr_parse 2.0.0",
"macroific_core 2.0.0",
"macroific_macro 2.0.0",
]
[[package]]
name = "macroific"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c437abb3bf40939b00d1dff858ed2050d948bc230dee39546ed16795fcf10ec2"
dependencies = [
"macroific_attr_parse 3.0.1",
"macroific_core 3.0.1",
"macroific_macro 3.0.1",
]
[[package]]
name = "macroific_attr_parse"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9"
dependencies = [
"proc-macro2",
"quote",
"sealed 0.6.0",
"syn 2.0.101",
]
[[package]]
name = "macroific_attr_parse"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeb7a82ec1aa16094da719be18615d79cb2574b037358e4e6c6d96eedcff3a93"
dependencies = [
"proc-macro2",
"quote",
"sealed 0.7.0",
"syn 3.0.3",
]
[[package]]
name = "macroific_core"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3"
dependencies = [
"proc-macro2",
"quote",
"sealed 0.6.0",
"syn 2.0.101",
]
[[package]]
name = "macroific_core"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26c8e789484d2fa216eee9d5e0565ba80ccda389bcf0e111143a7b8f4792336e"
dependencies = [
"proc-macro2",
"quote",
"sealed 0.7.0",
"syn 3.0.3",
]
[[package]]
name = "macroific_macro"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b"
dependencies = [
"macroific_attr_parse 2.0.0",
"macroific_core 2.0.0",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "macroific_macro"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e30d63cb102648965753f0d47a6ab171b1cbb1fb5c363f4784ea986782fc7b77"
dependencies = [
"macroific_attr_parse 3.0.1",
"macroific_core 3.0.1",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -4291,7 +4478,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
dependencies = [
"rand 0.8.5",
"rand 0.8.7",
]
[[package]]
@@ -4851,15 +5038,14 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.73"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
@@ -4883,18 +5069,18 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-src"
version = "300.5.0+3.5.0"
version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.109"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
@@ -5708,7 +5894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
dependencies = [
"phf_shared 0.10.0",
"rand 0.8.5",
"rand 0.8.7",
]
[[package]]
@@ -5718,7 +5904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.5",
"rand 0.8.7",
]
[[package]]
@@ -6242,9 +6428,9 @@ dependencies = [
[[package]]
name = "r2d2_sqlite"
version = "0.25.0"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb14dba8247a6a15b7fdbc7d389e2e6f03ee9f184f87117706d509c092dfe846"
checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6"
dependencies = [
"r2d2",
"rusqlite",
@@ -6283,9 +6469,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.8.5"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -6294,9 +6480,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.9.1"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
@@ -7130,10 +7316,20 @@ dependencies = [
]
[[package]]
name = "rusqlite"
version = "0.32.1"
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.17",
]
[[package]]
name = "rusqlite"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
dependencies = [
"bitflags 2.11.0",
"chrono",
@@ -7142,6 +7338,7 @@ dependencies = [
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
@@ -7165,7 +7362,7 @@ dependencies = [
"borsh",
"bytes",
"num-traits",
"rand 0.8.5",
"rand 0.8.7",
"rkyv",
"serde",
"serde_json",
@@ -7423,20 +7620,20 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sea-query"
version = "0.32.6"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64c91783d1514b99754fc6a4079081dcc2c587dadbff65c48c7f62297443536a"
checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31"
dependencies = [
"chrono",
"inherent",
"itoa",
"sea-query-derive",
]
[[package]]
name = "sea-query-derive"
version = "0.4.3"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537"
dependencies = [
"darling 0.20.11",
"heck 0.4.1",
@@ -7448,9 +7645,9 @@ dependencies = [
[[package]]
name = "sea-query-rusqlite"
version = "0.7.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3743bbdfb24b1a84cc1a6fbf4b1188e6851f6e00ea20944b44c56bf03a585bb4"
checksum = "1ec6038023c8517c623e5bf9606b3c54d40bc8296bb6b2986040428dd84deddd"
dependencies = [
"rusqlite",
"sea-query",
@@ -7462,6 +7659,28 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "sealed"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "sealed"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -7861,9 +8080,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.3.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
@@ -8031,6 +8250,34 @@ dependencies = [
"system-deps",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "sqlite-wasm-vfs"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7a5c9ac229421d577bb5a9bb59048838509958b218dd4e0b3c1214a87c361e"
dependencies = [
"indexed_db_futures",
"js-sys",
"rsqlite-vfs",
"thiserror 2.0.17",
"tokio",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -8165,6 +8412,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -9265,7 +9523,7 @@ dependencies = [
"indexmap 1.9.3",
"pin-project",
"pin-project-lite",
"rand 0.8.5",
"rand 0.8.7",
"slab",
"tokio",
"tokio-util",
@@ -9287,6 +9545,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -9295,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",
@@ -9325,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",
@@ -9482,7 +9752,7 @@ dependencies = [
"http",
"httparse",
"log 0.4.29",
"rand 0.9.1",
"rand 0.9.5",
"rustls",
"rustls-pki-types",
"sha1",
@@ -9752,7 +10022,7 @@ checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
dependencies = [
"getrandom 0.3.3",
"js-sys",
"rand 0.9.1",
"rand 0.9.5",
"serde",
"wasm-bindgen",
]
@@ -10952,9 +11222,13 @@ name = "yaak"
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",
@@ -10996,7 +11270,7 @@ dependencies = [
"pretty_graphql",
"r2d2",
"r2d2_sqlite",
"rand 0.9.1",
"rand 0.9.5",
"reqwest 0.12.20",
"rlimit",
"serde",
@@ -11023,6 +11297,7 @@ dependencies = [
"uuid",
"yaak",
"yaak-api",
"yaak-commands",
"yaak-common",
"yaak-core",
"yaak-crypto",
@@ -11031,6 +11306,7 @@ dependencies = [
"yaak-grpc",
"yaak-http",
"yaak-license",
"yaak-lifecycle",
"yaak-mac-window",
"yaak-models",
"yaak-plugins",
@@ -11080,7 +11356,7 @@ dependencies = [
"log 0.4.29",
"oxc_resolver",
"predicates",
"rand 0.8.5",
"rand 0.8.7",
"reqwest 0.12.20",
"rolldown",
"schemars 0.8.22",
@@ -11096,12 +11372,30 @@ dependencies = [
"yaak-core",
"yaak-crypto",
"yaak-http",
"yaak-lifecycle",
"yaak-models",
"yaak-plugins",
"yaak-templates",
"zip",
]
[[package]]
name = "yaak-commands"
version = "0.0.0"
dependencies = [
"serde_json",
"tempfile",
"thiserror 2.0.17",
"tokio",
"yaak",
"yaak-core",
"yaak-crypto",
"yaak-models",
"yaak-plugins",
"yaak-rpc-schema",
"yaak-templates",
]
[[package]]
name = "yaak-common"
version = "0.1.0"
@@ -11136,6 +11430,7 @@ name = "yaak-database"
version = "0.1.0"
dependencies = [
"chrono",
"getrandom 0.2.16",
"include_dir",
"log 0.4.29",
"nanoid",
@@ -11148,6 +11443,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.17",
"ts-rs",
"uuid",
]
[[package]]
@@ -11225,7 +11521,6 @@ dependencies = [
"log 0.4.29",
"mime_guess",
"native-tls",
"regex 1.11.1",
"reqwest 0.12.20",
"serde",
"serde_json",
@@ -11261,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"
@@ -11269,7 +11572,7 @@ dependencies = [
"csscolorparser",
"log 0.4.29",
"objc",
"rand 0.9.1",
"rand 0.9.5",
"tauri",
"tauri-plugin",
]
@@ -11294,8 +11597,10 @@ dependencies = [
"sha2",
"thiserror 2.0.17",
"ts-rs",
"urlencoding",
"yaak-core",
"yaak-database",
"yaak-templates",
]
[[package]]
@@ -11311,7 +11616,7 @@ dependencies = [
"log 0.4.29",
"md5 0.7.0",
"path-slash",
"rand 0.9.1",
"rand 0.9.5",
"reqwest 0.12.20",
"serde",
"serde_json",
@@ -11421,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",
]
@@ -11431,6 +11739,7 @@ version = "0.1.0"
dependencies = [
"regex 1.11.1",
"tauri",
"yaak-core",
]
[[package]]
@@ -11465,13 +11774,56 @@ dependencies = [
"yasna",
]
[[package]]
name = "yaak-wasm"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"js-sys",
"log 0.4.29",
"serde",
"serde-wasm-bindgen",
"serde_json",
"sqlite-wasm-rs",
"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",
]
[[package]]
name = "yaak-window"
version = "0.1.0"
dependencies = [
"log 0.4.29",
"md5 0.8.0",
"rand 0.9.1",
"rand 0.9.5",
"serde",
"serde_json",
"tauri",
+7
View File
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"crates/yaak",
"crates/yaak-commands",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
@@ -13,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",
@@ -20,10 +22,13 @@ members = [
"crates/yaak-templates",
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-wasm",
"crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# Server crates (the browser tier's hosted send executor)
"crates-server/yaak-web",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
@@ -69,11 +74,13 @@ yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
# Internal crates - shared
yaak-core = { path = "crates/yaak-core" }
yaak = { path = "crates/yaak" }
yaak-commands = { path = "crates/yaak-commands" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }
yaak-git = { path = "crates/yaak-git" }
yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" }
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-sse = { path = "crates/yaak-sse" }
+19
View File
@@ -44,6 +44,25 @@ After bootstrapping, start the app in development mode:
npm start
```
## Run the App in a Browser
The client can also run as a plain web page, with no Tauri and no local process
behind it. Set `YAAK_TARGET=web` and start the frontend on its own:
```shell
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
```
That flag picks the browser host in `packages/platform/src/web/`, which answers
commands from an IndexedDB database the page owns instead of from the Rust
engine. Data persists across reloads and is shared between tabs on the same
origin. Sending HTTP is not available yet — the Send button reports that and
everything else about the request is still saved. `packages/platform/src/web/README.md`
lists which commands the browser host implements and which it declines.
Desktop builds are unaffected: without the flag the platform package installs
the Tauri host exactly as before.
## SQLite Migrations
New migrations can be created from the `src-tauri/` directory:
+46
View File
@@ -0,0 +1,46 @@
# syntax=docker/dockerfile:1
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
# image serving both from one origin.
#
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
#
# See crates-server/yaak-web/README.md for the knobs.
FROM node:22-slim AS web
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
COPY . .
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
RUN git init -q && git add -A \
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
# only to build a bundle for a deployment whose server lives somewhere else.
ARG VITE_YAAK_WEB_URL=""
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
ENV YAAK_TARGET=web
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
# backend, which this image has no reason to carry.
ENV SKIP_WASM_BUILD=1
RUN npm ci
RUN node_modules/.bin/vp -C apps/yaak-client build
FROM rust:1-bookworm AS server
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
COPY . .
RUN cargo build --release -p yaak-web
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
COPY --from=web /app/dist/apps/yaak-client /srv
ENV YAAK_WEB_BIND=0.0.0.0:8080
EXPOSE 8080
USER nobody
# Overriding the command (dropping --serve) leaves the stateless send executor:
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
CMD ["yaak-web", "--serve", "/srv"]
+23 -5
View File
@@ -1,19 +1,37 @@
import type { SettingsTab } from "../components/Settings/Settings";
import { platform } from "@yaakapp-internal/platform";
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { router } from "../lib/router";
import { rpc } from "../lib/rpc";
// Allow tab with optional subtab (e.g., "plugins:installed")
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
mutationKey: ["open_settings"],
mutationFn: async (tab) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
// Settings is its own window where the host has windows to give. Where it
// doesn't — a browser tab — it's a dialog like any other, so opening it
// doesn't take you away from the request you were working on.
if (!platform.capabilities.multiWindow) {
// Imported here so Settings stays out of the startup bundle, the way the
// route that renders it on desktop already keeps it
const { default: Settings } = await import("../components/Settings/Settings");
showDialog({
id: "settings",
size: "md",
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
noPadding: true,
noScroll: true,
// Keyed so opening a specific tab while the dialog is already up moves to it
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
});
return;
}
const location = router.buildLocation({
to: "/workspaces/$workspaceId/settings",
params: { workspaceId },
@@ -1,3 +1,4 @@
import { platform } from "@yaakapp-internal/platform";
import { createFastMutation } from "../hooks/useFastMutation";
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
@@ -24,7 +25,9 @@ export const switchWorkspace = createFastMutation<
request_id: requestId,
};
if (inNewWindow) {
// A host without windows opens the workspace here instead. Refusing would
// strand the user on the workspace they were trying to leave.
if (inNewWindow && platform.capabilities.multiWindow) {
const location = router.buildLocation({
to: "/workspaces/$workspaceId",
params: { workspaceId },
@@ -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} />
+180 -63
View File
@@ -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"
+626 -25
View File
@@ -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));
}
+7 -5
View File
@@ -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: () => {
@@ -1,9 +1,29 @@
import type { Diagnostic } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { parse as jsonLintParse } from "@prantlf/jsonlint";
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
// jsonc-parser reports error codes, so these are the words the editor shows for them
const MESSAGES: Record<string, string> = {
InvalidSymbol: "Invalid symbol",
InvalidNumberFormat: "Invalid number format",
PropertyNameExpected: "Property name expected",
ValueExpected: "Value expected",
ColonExpected: "Colon expected",
CommaExpected: "Comma expected",
CloseBraceExpected: "Closing brace expected",
CloseBracketExpected: "Closing bracket expected",
EndOfFileExpected: "End of file expected",
InvalidCommentToken: "Comments are not allowed",
UnexpectedEndOfComment: "Unexpected end of comment",
UnexpectedEndOfString: "Unexpected end of string",
UnexpectedEndOfNumber: "Unexpected end of number",
InvalidUnicode: "Invalid unicode sequence",
InvalidEscapeCharacter: "Invalid escape character",
InvalidCharacter: "Invalid character",
};
interface JsonLintOptions {
allowComments?: boolean;
allowTrailingCommas?: boolean;
@@ -11,34 +31,28 @@ interface JsonLintOptions {
export function jsonParseLinter(options?: JsonLintOptions) {
return (view: EditorView): Diagnostic[] => {
try {
const doc = view.state.doc.toString();
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
jsonLintParse(escapedDoc, {
mode: (options?.allowComments ?? true) ? "cjson" : "json",
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
});
// oxlint-disable-next-line no-explicit-any
} catch (err: any) {
if (!("location" in err)) {
return [];
}
const doc = view.state.doc.toString();
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
// const line = location?.start?.line;
// const column = location?.start?.column;
if (err.location.start.offset) {
return [
{
from: err.location.start.offset,
to: err.location.start.offset,
severity: "error",
message: err.message,
},
];
}
}
return [];
const errors: ParseError[] = [];
parse(escapedDoc, errors, {
allowTrailingComma: options?.allowTrailingCommas ?? false,
disallowComments: !(options?.allowComments ?? true),
});
// Later errors are mostly consequences of the first one, so only that one is shown
const error = errors[0];
if (error == null) return [];
return [
{
from: error.offset,
to: error.offset + error.length,
severity: "error",
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
},
];
};
}
@@ -0,0 +1,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",
"…",
" ",
"İ",
"Σ",
"ς",
"日",
"🎉",
"Ⅻ",
"",
"①",
"́",
];
/** 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)}
/>
);
+6 -2
View File
@@ -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>
);
}
+1 -3
View File
@@ -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>
))}
+26 -12
View File
@@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => {
// Merge default hotkeys with custom hotkeys from settings
// Custom hotkeys override defaults for the same action
// An empty array means the hotkey is intentionally disabled
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
const merged: Partial<Record<HotkeyAction, string[]>> = {};
for (const action of hotkeyActions) {
merged[action] = defaultHotkeys[action];
}
for (const [action, keys] of Object.entries(customHotkeys)) {
if (action in defaultHotkeys && Array.isArray(keys)) {
if (action in merged && Array.isArray(keys)) {
merged[action as HotkeyAction] = keys;
}
}
@@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => {
});
/** Helper function to get current hotkeys from the store */
function getHotkeys(): Record<HotkeyAction, string[]> {
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
return jotaiStore.get(hotkeysAtom);
}
@@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [
"Space",
];
/** Zoom is the browser's own on these keys, so the app has no such action there. */
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
/**
* The actions this host actually has. An action left out of here has no keys in
* `hotkeysAtom`, so it never matches and never claims the keystroke.
*/
export const hotkeyActions: HotkeyAction[] = (
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
).sort((a, b) => {
const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
)
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
.sort((a, b) => {
const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
export type HotKeyOptions = {
enable?: boolean | (() => boolean);
@@ -333,7 +345,9 @@ export function formatHotkeyString(trigger: string): string[] {
} else if (p === "Alt") {
labelParts.push("⌥");
} else if (p === "Enter") {
labelParts.push("↩");
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
labelParts.push("↩︎");
} else if (p === "Tab") {
labelParts.push("⇥");
} else if (p === "Backspace") {
+1 -2
View File
@@ -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 }),
});
}
+1 -2
View File
@@ -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 }),
});
});
+49 -17
View File
@@ -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}
/>
);
},
+60 -14
View File
@@ -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&apos;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&apos;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;
}
+121
View File
@@ -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;
}
+1 -6
View File
@@ -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: () => {
+14 -16
View File
@@ -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",
+3 -5
View File
@@ -23,7 +23,6 @@
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@mjackson/multipart-parser": "^0.10.1",
"@prantlf/jsonlint": "^16.0.0",
"@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2",
@@ -54,6 +53,7 @@
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"js-md5": "^0.8.3",
"jsonc-parser": "^3.3.1",
"lucide-react": "^0.525.0",
"mime": "^4.0.4",
"motion": "^12.4.7",
@@ -93,14 +93,12 @@
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"babel-plugin-react-compiler": "^1.0.0",
"decompress": "^4.2.1",
"internal-ip": "^8.0.0",
"rollup": "^4.60.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
"vite-plugin-static-copy": "^3.3.0",
"vite-plugin-svgr": "^4.5.0",
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.2.1"
"vite-plus": "^0.2.9"
}
}
+6 -6
View File
@@ -46,9 +46,9 @@ const WorkspacesWorkspaceIdRequestsRequestIdRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/workspaces': typeof WorkspacesIndexRoute
'/workspaces/': typeof WorkspacesIndexRoute
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId/': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
}
export interface FileRoutesByTo {
@@ -70,9 +70,9 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/workspaces'
| '/workspaces/'
| '/workspaces/$workspaceId/settings'
| '/workspaces/$workspaceId'
| '/workspaces/$workspaceId/'
| '/workspaces/$workspaceId/requests/$requestId'
fileRoutesByTo: FileRoutesByTo
to:
@@ -110,14 +110,14 @@ declare module '@tanstack/react-router' {
'/workspaces/': {
id: '/workspaces/'
path: '/workspaces'
fullPath: '/workspaces'
fullPath: '/workspaces/'
preLoaderRoute: typeof WorkspacesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/workspaces/$workspaceId/': {
id: '/workspaces/$workspaceId/'
path: '/workspaces/$workspaceId'
fullPath: '/workspaces/$workspaceId'
fullPath: '/workspaces/$workspaceId/'
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
@@ -14,5 +14,6 @@ export const Route = createFileRoute("/workspaces/$workspaceId/settings")({
});
function RouteComponent() {
return <Settings />;
const { tab } = Route.useSearch();
return <Settings tab={tab} />;
}
+29 -2
View File
@@ -6,7 +6,6 @@ import path from "node:path";
import { defineConfig, normalizePath } from "vite-plus";
import { viteStaticCopy } from "vite-plugin-static-copy";
import svgr from "vite-plugin-svgr";
import topLevelAwait from "vite-plugin-top-level-await";
import wasm from "vite-plugin-wasm";
const require = createRequire(import.meta.url);
@@ -17,9 +16,38 @@ const standardFontsDir = normalizePath(
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"),
);
/**
* Which host the platform package installs. `web` builds Yaak to run in a plain
* browser tab, with its own IndexedDB store instead of the Rust engine; anything
* else builds the desktop app exactly as before.
*/
const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop";
// https://vitejs.dev/config/
export default defineConfig(async () => {
return {
resolve: {
alias:
yaakTarget === "web"
? {
// Resolve the platform package to its browser entry, so a web
// build never pulls `@tauri-apps/*` into the graph at all. A
// build-time branch inside the package would not manage that:
// the dead branch folds away, but the imports it guarded stay.
"@yaakapp-internal/platform": path.resolve(
import.meta.dirname,
"../../packages/platform/src/index.web.ts",
),
}
: {},
},
// The browser host runs the model layer in a worker; that bundle needs the
// same wasm handling as the main one. Top-level await needs no transform
// because the build targets esnext.
worker: {
format: "es" as const,
plugins: () => [wasm()],
},
plugins: [
wasm(),
tanstackRouter({
@@ -30,7 +58,6 @@ export default defineConfig(async () => {
}),
svgr(),
react(),
topLevelAwait(),
viteStaticCopy({
targets: [
{ src: cMapsDir, dest: "" },
+2 -2
View File
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.2.1"
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
"vite-plus": "^0.2.9"
}
}
+1
View File
@@ -45,6 +45,7 @@ yaak-api = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-http = { workspace = true }
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-templates = { workspace = true }
@@ -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> {
+161 -2
View File
@@ -181,7 +181,11 @@ async fn dev(args: PluginPathArg) -> CommandResult {
ui::info(&format!("Rebuilding plugin {display_path}"));
}
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
match generate_plugin_metadata(&watch_root) {
// Assets are staged on every rebuild, so a changed asset or
// declaration is picked up without restarting.
let result = copy_build_assets(&watch_root)
.and_then(|()| generate_plugin_metadata(&watch_root));
match result {
Ok(()) => ui::success(&format!(
"Generated plugin metadata at {}",
watch_root.join("build/metadata.json").display()
@@ -408,6 +412,7 @@ struct PublishResponse {
async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
prepare_build_output_dir(plugin_dir)?;
copy_build_assets(plugin_dir)?;
let mut bundler = Bundler::new(bundler_options(plugin_dir, false))
.map_err(|err| format!("Failed to initialize Rolldown: {err}"))?;
let output = bundler.write().await.map_err(|err| format!("Plugin build failed:\n{err}"))?;
@@ -498,6 +503,63 @@ fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
.map_err(|e| format!("Failed to create build directory {}: {e}", build_dir.display()))
}
#[derive(Deserialize, Default)]
struct PluginManifest {
#[serde(default)]
yaak: PluginManifestConfig,
}
#[derive(Deserialize, Default)]
struct PluginManifestConfig {
/// Files to place beside the bundle, as paths relative to the plugin
/// directory. Publishing ships everything in `build/`, so these travel with
/// the plugin.
#[serde(default, rename = "buildAssets")]
build_assets: Vec<String>,
}
/// Copy the plugin's declared assets into `build/`.
///
/// This runs after the directory is cleared and before the bundle is written,
/// because a bundle may read an asset from its own directory at import time and
/// metadata generation imports the bundle.
fn copy_build_assets(plugin_dir: &Path) -> CommandResult {
let manifest_path = plugin_dir.join("package.json");
let manifest: PluginManifest = serde_json::from_str(
&fs::read_to_string(&manifest_path)
.map_err(|e| format!("Failed to read {}: {e}", manifest_path.display()))?,
)
.map_err(|e| format!("Failed to parse {}: {e}", manifest_path.display()))?;
let build_dir = plugin_dir.join("build");
let mut names = HashSet::new();
for asset in manifest.yaak.build_assets {
let src = plugin_dir.join(&asset);
let name = src
.file_name()
.ok_or_else(|| format!("yaak.buildAssets entry is not a file path: {asset}"))?;
// A copy that later gets overwritten would pass the build and fail on
// load, so anything the build itself writes, or a second asset with
// the same name, is rejected up front. Names are compared without
// case, because a plugin is installed on case-insensitive filesystems
// wherever it was built.
let key = name.to_string_lossy().to_lowercase();
if key == "index.js" || key == "metadata.json" {
return Err(format!("Build asset {asset} would be overwritten by the build output"));
}
if !names.insert(key) {
return Err(format!("Two build assets share the name {}", name.display()));
}
if !src.is_file() {
return Err(format!("Build asset does not exist: {}", src.display()));
}
fs::copy(&src, build_dir.join(name))
.map_err(|e| format!("Failed to copy build asset {}: {e}", src.display()))?;
}
Ok(())
}
fn bundler_options(plugin_dir: &Path, watch: bool) -> BundlerOptions {
BundlerOptions {
input: Some(vec![InputItem { import: "./src/index.ts".to_string(), ..Default::default() }]),
@@ -750,7 +812,10 @@ describe("Example Plugin", () => {
#[cfg(test)]
mod tests {
use super::{create_publish_archive, generate_plugin_metadata};
use super::{
copy_build_assets, create_publish_archive, generate_plugin_metadata,
prepare_build_output_dir,
};
use serde_json::Value;
use std::collections::HashSet;
use std::fs;
@@ -795,6 +860,100 @@ mod tests {
assert!(!names.contains("ignored/secret.txt"));
}
#[test]
fn prepare_build_output_dir_clears_stale_output() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
let build = root.join("build");
fs::create_dir_all(&build).expect("create build");
fs::write(build.join("index.js"), "stale").expect("write index.js");
fs::write(build.join("left-behind.js"), "stale").expect("write extra");
prepare_build_output_dir(root).expect("prepare build dir");
// Publishing ships everything under build/, so nothing may survive.
assert!(build.is_dir());
assert_eq!(fs::read_dir(&build).expect("read build").count(), 0);
}
#[test]
fn copy_build_assets_places_declared_files_beside_the_bundle() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::create_dir_all(root.join("vendor")).expect("create vendor");
fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset");
fs::write(
root.join("package.json"),
r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#,
)
.expect("write package.json");
copy_build_assets(root).expect("copy assets");
assert_eq!(
fs::read_to_string(root.join("build/core_bg.wasm")).expect("read copied asset"),
"asset"
);
}
#[test]
fn copy_build_assets_is_a_noop_without_declarations() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::write(root.join("package.json"), r#"{"name":"demo"}"#).expect("write package.json");
copy_build_assets(root).expect("copy assets");
assert_eq!(fs::read_dir(root.join("build")).expect("read build").count(), 0);
}
#[test]
fn copy_build_assets_rejects_names_the_build_writes() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::write(root.join("index.js"), "asset").expect("write asset");
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["index.js"]}}"#)
.expect("write package.json");
let err = copy_build_assets(root).expect_err("reserved name should fail");
assert!(err.contains("overwritten by the build output"), "unexpected error: {err}");
}
#[test]
fn copy_build_assets_rejects_duplicate_names() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::create_dir_all(root.join("a")).expect("create a");
fs::create_dir_all(root.join("b")).expect("create b");
// Differ only by case: one file on macOS and Windows.
fs::write(root.join("a/core.wasm"), "one").expect("write a");
fs::write(root.join("b/Core.wasm"), "two").expect("write b");
fs::write(
root.join("package.json"),
r#"{"yaak":{"buildAssets":["a/core.wasm","b/Core.wasm"]}}"#,
)
.expect("write package.json");
let err = copy_build_assets(root).expect_err("duplicate name should fail");
assert!(err.contains("share the name"), "unexpected error: {err}");
}
#[test]
fn copy_build_assets_fails_on_a_missing_asset() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["nope.wasm"]}}"#)
.expect("write package.json");
let err = copy_build_assets(root).expect_err("missing asset should fail");
assert!(err.contains("Build asset does not exist"), "unexpected error: {err}");
}
#[test]
fn generate_plugin_metadata_detects_api_types() {
let dir = TempDir::new().expect("temp dir");
+6 -9
View File
@@ -435,15 +435,12 @@ fn create(
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
let name = name.unwrap_or_default();
let url = url.unwrap_or_default();
let method = method.unwrap_or_else(|| "GET".to_string());
let request = HttpRequest {
workspace_id,
name,
method: method.to_uppercase(),
url,
..Default::default()
};
let mut request = HttpRequest { workspace_id, name, url, ..Default::default() };
// Only override the method when one was given; `HttpRequest::default()` is the
// single place the fallback ("GET") is defined.
if let Some(method) = method {
request.method = method.to_uppercase();
}
let created = ctx
.db()
+8
View File
@@ -49,6 +49,14 @@ impl CliContext {
std::process::exit(1);
}
};
// Guest: the desktop may have this DB open, so only what's safe beside a live session
let _ = yaak_lifecycle::on_launch(
&yaak_lifecycle::Host::guest(),
&query_manager.connect(),
&blob_manager,
);
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
Self {
+14 -2
View File
@@ -1,5 +1,7 @@
use crate::context::CliExecutionContext;
use arboard::Clipboard;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use console::Term;
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
use serde_json::Value;
@@ -11,7 +13,8 @@ use tokio::task::JoinHandle;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak::render::{render_grpc_request, render_http_request};
use yaak_models::render::{render_grpc_request, render_http_request};
use yaak::response_body::FileResponseBodyStore;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::get_cookie_value_from_jar;
@@ -131,6 +134,7 @@ async fn build_plugin_reply(
match handle_shared_plugin_event(
&host_context.query_manager,
&FileResponseBodyStore::new(&host_context.query_manager),
&event.payload,
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
) {
@@ -223,7 +227,15 @@ async fn build_plugin_reply(
.await
{
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
SendHttpRequestResponse { http_response: result.response },
SendHttpRequestResponse {
http_response: result.response,
// Nothing saved this body, so the reply is the only
// place the plugin can get it.
body: result
.response_body
.returned_bytes()
.map(|b| BASE64_STANDARD.encode(b)),
},
)),
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to send HTTP request in CLI: {err}"),
@@ -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");
}
+3 -3
View File
@@ -10,9 +10,9 @@ chrono = { workspace = true, features = ["serde"] }
log = { workspace = true }
include_dir = "0.7"
r2d2 = "0.8.10"
r2d2_sqlite = "0.25.0"
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
r2d2_sqlite = "0.32"
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl"] }
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "yaak-web"
version = "0.1.0"
edition = "2024"
publish = false
description = "The server behind Yaak in the browser: executes sends, and can serve the app"
# The send engine (yaak-http) and the model types it speaks (yaak-models, for
# HttpRequest / Cookie / HttpResponseEventData). Deliberately NOT yaak (the
# render + storage orchestration), yaak-plugins, or the RPC router: this binary
# opens no database, runs no plugins, and renders nothing. yaak-models comes
# along only because yaak-http's types are its types; nothing here calls into
# its query layer.
[[bin]]
name = "yaak-web"
path = "src/main.rs"
[dependencies]
async-trait = "0.1"
axum = "0.7"
base64 = "0.22.1"
bytes = "1.11.1"
clap = { version = "4.5", features = ["derive", "env"] }
env_logger = "0.11"
futures-util = "0.3"
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync", "io-util", "time", "net"] }
tower-http = { version = "0.6", features = ["compression-gzip", "compression-zstd", "cors", "fs"] }
ts-rs = { workspace = true }
url = "2"
uuid = { version = "1", features = ["v4"] }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
+219
View File
@@ -0,0 +1,219 @@
# yaak-web
The network half of Yaak in a browser — and, with `--serve`, the half that
hands the browser the app in the first place.
A tab can't see an HTTP response the way a desktop app can: CORS hides most
headers (2 of 8 in a typical response), redirects are followed silently, and
there is no timeline. So the tab renders the request and posts it here, and this
process puts it on the network with the desktop's own engine (`yaak-http`) and
streams back everything that happened — every header, every redirect hop, DNS
timing, the body — for the tab to store.
It is a **stateless executor**. It keeps nothing: no database, no files, no
sessions, no cookies between calls. Every byte it sees comes from the tab in the
request, and every byte it returns is stored by the tab. Restart it any time.
## Self-hosting it
One container, no configuration, nothing behind it:
```shell
docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
```
Open <http://localhost:8080>. The image carries the built web client and this
binary, which serves it — so the app and its sends are on one origin, and the
tab's send URL is a path (`/v1/http/send`) rather than an address anyone has to
configure. The image is `linux/amd64` and `linux/arm64`, built from
`Dockerfile.web` at the repo root.
Your data lives in your browser (SQLite compiled to wasm, in IndexedDB), not in
the container. The container is stateless: nothing is written to disk, so
upgrading is `docker pull` and nothing else.
Two settings are worth knowing about:
```shell
docker run -p 8080:8080 \
-e YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true \
-e YAAK_WEB_RATE_LIMIT_PER_MINUTE=0 \
ghcr.io/mountain-loop/yaak-web
```
- **`YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true`** lets sends reach loopback,
private and link-local addresses. Off by default, and it should stay off on
anything strangers can reach — see [What it refuses](#what-it-refuses-and-why).
Turn it on for an instance on your own network, where calling the API on the
next machine is the whole point. Note that "private" is relative to the
*container*: `127.0.0.1` is the container itself, and reaching the Docker
host means `host.docker.internal` (or `--network host`).
- **`YAAK_WEB_RATE_LIMIT_PER_MINUTE`** defaults to 120 sends per client IP,
which suits a public instance and not a team of your own; `0` disables it.
Behind a reverse proxy, add `YAAK_WEB_TRUST_FORWARDED_FOR=true` so the rate
limit sees real client addresses instead of its own — and only then, since
otherwise anyone can spoof the header. If the reverse proxy buffers responses,
tell it not to: sends are streamed, and the `X-Accel-Buffering: no` header this
binary sets is honoured by nginx-shaped ones.
## Running it from source
```shell
cargo run -p yaak-web -- --serve dist/apps/yaak-client
```
after a `YAAK_TARGET=web SKIP_WASM_BUILD=1 npx vp -C apps/yaak-client build`.
Without `--serve` it is the send executor alone, which is what the frontend
dev server wants:
```shell
cargo run -p yaak-web
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
```
A dev build looks for the server at `http://127.0.0.1:9227` (the Vite server is a
different origin and serves no `/v1`); a production build sends to its own
origin unless `VITE_YAAK_WEB_URL` was set when it was built.
## Configuration
Every flag has a `YAAK_WEB_*` environment variable, so a container needs no
arguments; `--help` lists them all.
| Flag | Default | What |
| --- | --- | --- |
| `--serve` | off | Also serve a built web client from this directory, on the same origin. |
| `--bind` | `127.0.0.1:9227` | Listen address. The image sets `0.0.0.0:8080`. |
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
| `--max-concurrent` | 256 | Sends in flight at once. |
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
## Serving the app
`--serve DIR` puts a file server behind the API routes: `/v1/*` is matched
first, everything else comes from `DIR`, and a path with no file behind it gets
`index.html` so the app's own routes survive a refresh. Responses are compressed
(gzip or zstd) on the fly. `/assets/*` is cached forever — Vite content-hashes
those names — and everything else is `no-cache`, so a new deploy arrives on the
next reload.
Serving files changes nothing about sending: the same rendered request, the same
destination policy, the same stateless executor. It exists so that a
self-hosted Yaak is one thing to run rather than two.
## Split deployments
The app and the sender can still be separate services — one CDN-hosted bundle and
one server elsewhere, or one server shared by several fronts. Then the bundle has
to be told where to send, at build time:
```shell
docker build -f Dockerfile.web \
--build-arg VITE_YAAK_WEB_URL=https://send.example.com .
```
and the server needs the CORS origins its callers use, since the requests are no
longer same-origin:
```shell
docker run -p 8080:8080 \
-e YAAK_WEB_ALLOWED_ORIGINS=https://yaak.example.com \
ghcr.io/mountain-loop/yaak-web \
yaak-web
```
The trailing `yaak-web` is a command override: the same image run without
`--serve`, so it executes sends and serves no app.
## What it refuses, and why
A hosted sender is, by construction, a machine that makes HTTP requests on
behalf of strangers. Left alone that is an open relay into whatever network it
sits on. So by default it refuses to connect to:
- loopback (`127/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`,
`fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
`fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges,
IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the well-known
NAT64 prefix, 6to4), and the whole NAT64 local-use range;
- anything not `http://` or `https://`.
The check runs **on the resolved addresses, after DNS**, for every hop of a
redirect chain, so a public hostname that points at an internal address is
caught, and so is a `Location:` header that points at one. It also refuses body
types that would read files on its own disk (`binary`, multipart file
fields), since no browser tab could legitimately mean those.
Refusals are logged with the reason. On a public instance (`web.yaak.app`, or
anything else strangers can reach) this must stay on: the machine's private
network is the host's, not the user's, so a `localhost` or LAN API is not the
user's to reach through it — the desktop app is what reaches those. On an
instance you run for yourself, that reasoning is inverted, and
`--allow-private-networks` inverts the policy with it. It allows every range
above, including `169.254.169.254`, so use it only where the network on the
other side is one the users are entitled to.
There is no authentication either way: an instance is anonymous, protected by
the per-client rate limit and the destination policy. Anything more (a shared
token, per-user quotas) is a later slice and would sit in front of `send_http`
in `main.rs`. Put TLS in front of a public instance.
## The wire
`POST /v1/http/send` with a JSON body:
```json
{
"request": { "url": "https://…", "method": "GET", "headers": [], "body": {}, "bodyType": null, "urlParameters": [] },
"settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true },
"cookies": [ ]
}
```
`request` is a Yaak `HttpRequest` in the desktop's own model shape with every
template already rendered by the tab; the server builds the URL, headers and
body from it exactly the way the desktop does after rendering. `cookies` is the
jar's contents (or `null` for no jar).
The reply is `application/x-ndjson`, one JSON frame per line, in the order things
happened:
| `type` | When | Carries |
| --- | --- | --- |
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
| `response` | once, when the final hop's headers arrive | status, all headers, request headers as sent, remote address, HTTP version, timing |
| `body` | as the body is read | a decompressed chunk, base64 |
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
| `error` | last, on failure | the reason, and any cookies collected before the failure |
Refusals that happen before anything is sent (a blocked destination, a bad body,
rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
`{"error": "…"}`, not streams.
Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by
construction, cancellable by closing the connection, readable with `curl`, and
needs no upgrade handling on either side. A WebSocket only earns its keep when
traffic is bidirectional, which a single send is not.
The TypeScript side of this contract is generated from `src/wire.rs` by ts-rs
into `bindings/` (run `cargo test -p yaak-web` after changing a frame)
and published to the tab as `@yaakapp-internal/web`, so a change to the
wire on one side is a type error on the other.
`GET /v1/health` reports the version and the effective limits.
## What comes later
Not built, by design, but the router is shaped for it: a WebSocket relay
(`/v1/ws/relay`) and a gRPC relay (`/v1/grpc/relay`) would be long-lived,
bidirectional endpoints on the same binary, behind the same destination policy
and limits. They differ from this endpoint in holding per-connection
in-memory state while a connection is open (never persisted), which brings
connection limits and a larger abuse surface — the reason they are separate
work.
+135
View File
@@ -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
View File
@@ -0,0 +1,64 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings } from "./gen_models";
/**
* One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
* camelCase like every model the tab stores.
*/
export type Frame = { "type": "event", event: HttpResponseEventData, } | { "type": "response", status: number, statusReason: string | null,
/**
* The URL that answered, after redirects.
*/
url: string, remoteAddr: string | null, version: string | null, headers: Array<HttpResponseHeader>,
/**
* The headers that were actually sent on the final hop, cookies and all.
*/
requestHeaders: Array<HttpResponseHeader>,
/**
* `Content-Length` as declared by the server, if it declared one.
*/
contentLength: number | null,
/**
* Milliseconds from the start of the send to the response head.
*/
elapsedHeaders: number,
/**
* Milliseconds spent in DNS on the last lookup, or zero.
*/
elapsedDns: number, } | { "type": "body", data: string, } | { "type": "done",
/**
* Milliseconds from the start of the send to the end of the body.
*/
elapsed: number,
/**
* Bytes of body relayed, after decompression.
*/
contentLength: number,
/**
* Bytes on the wire as declared by the server, or the relayed size when unknown.
*/
contentLengthCompressed: number,
/**
* The jar as the send left it, for the tab to persist. `None` when the tab sent none.
*/
cookies: Array<Cookie> | null, } | { "type": "error", message: string, cookies: Array<Cookie> | null, };
/**
* The body of `POST /v1/http/send`.
*/
export type SendRequest = {
/**
* The request to send, in the desktop's own model shape but with every template already
* rendered by the tab. The server builds the URL, headers and body from it exactly the way
* the desktop does after rendering.
*/
request: HttpRequest,
/**
* The resolved settings, values only. Where they came from is the tab's to record in
* its timeline; the server only needs to obey them.
*/
settings: HttpSendSettings,
/**
* The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
*/
cookies: Array<Cookie> | null, };
+4
View File
@@ -0,0 +1,4 @@
// The server's wire contract, generated by ts-rs from src/wire.rs
// (`cargo test -p yaak-web`). The tab imports these so a change to a
// frame on the Rust side is a type error in packages/platform/src/web.
export type { Frame, SendRequest } from "./bindings/gen_web";
+6
View File
@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/web",
"version": "1.0.0",
"private": true,
"main": "index.ts"
}
+67
View File
@@ -0,0 +1,67 @@
use clap::Parser;
use std::net::SocketAddr;
use std::path::PathBuf;
/// The server behind Yaak running in a browser.
///
/// The tab renders the request and owns the data; this binary puts the bytes on the network
/// and streams back what came back, and with `--serve` hands the browser the app as well.
/// Nothing is written to disk or a database.
#[derive(Parser, Debug, Clone)]
#[command(name = "yaak-web", version, about, long_about = None)]
pub struct Config {
/// Address to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container.
#[arg(long, env = "YAAK_WEB_BIND", default_value = "127.0.0.1:9227")]
pub bind: SocketAddr,
/// Also serve a built web client from this directory, on the same origin as the API.
/// Unknown paths fall back to `index.html` so the app's own routes work on a refresh.
/// Without this the binary is only the send executor.
#[arg(long, env = "YAAK_WEB_SERVE", value_name = "DIR")]
pub serve: Option<PathBuf>,
/// Allow sends to loopback, private and link-local addresses. Off by default, because a
/// server reachable by strangers is an open relay into the network it sits on. Turn it on
/// only for an instance whose users are meant to reach that network — a self-hosted one
/// on a LAN, where the point is to call the API on the next machine.
#[arg(long, env = "YAAK_WEB_ALLOW_PRIVATE_NETWORKS", default_value_t = false)]
pub allow_private_networks: bool,
/// Browser origins allowed to call this server (CORS), comma-separated. `*` allows any.
/// A local dev instance wants the Vite origin; a hosted instance wants its own web origin.
#[arg(
long,
env = "YAAK_WEB_ALLOWED_ORIGINS",
default_value = "*",
value_delimiter = ','
)]
pub allowed_origins: Vec<String>,
/// Largest request the server accepts from the tab (the rendered request JSON, body included).
#[arg(long, env = "YAAK_WEB_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
pub max_request_bytes: usize,
/// Largest upstream response body the server will relay before cutting the send off.
#[arg(long, env = "YAAK_WEB_MAX_RESPONSE_BYTES", default_value_t = 64 * 1024 * 1024)]
pub max_response_bytes: usize,
/// Ceiling on a send's timeout, in seconds. A request asking for longer (or for no timeout)
/// gets this instead.
#[arg(long, env = "YAAK_WEB_MAX_TIMEOUT_SECS", default_value_t = 60)]
pub max_timeout_secs: u64,
/// Sends allowed per client IP per minute. 0 disables the limit. This and the concurrency
/// cap are the whole of what protects an instance: there is no authentication.
#[arg(long, env = "YAAK_WEB_RATE_LIMIT_PER_MINUTE", default_value_t = 120)]
pub rate_limit_per_minute: u32,
/// Sends in flight at once across all clients.
#[arg(long, env = "YAAK_WEB_MAX_CONCURRENT", default_value_t = 256)]
pub max_concurrent: usize,
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only turn
/// this on behind a load balancer that sets the header; otherwise anyone can spoof their way
/// past the rate limit.
#[arg(long, env = "YAAK_WEB_TRUST_FORWARDED_FOR", default_value_t = false)]
pub trust_forwarded_for: bool,
}
+271
View File
@@ -0,0 +1,271 @@
//! Where a send may go.
//!
//! A hosted sender is, by construction, a machine that makes HTTP requests on
//! behalf of strangers. Left alone that is an open relay into whatever network
//! it sits on: cloud metadata endpoints, internal admin panels, the database
//! next door. So every destination is checked twice — once on the URL before a
//! hop is attempted (literal IPs, host allow/deny lists) and once on the
//! addresses a hostname actually resolves to, right before the connection is
//! made. The second check is the one that matters for a hostname pointing at
//! an internal address, and it runs on every redirect hop because the engine
//! resolves every hop.
use async_trait::async_trait;
use log::warn;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use tokio::sync::mpsc;
use url::Url;
use yaak_http::dns::AddressFilter;
use yaak_http::sender::{HttpResponse, HttpResponseEvent, HttpSender};
use yaak_http::types::SendableHttpRequest;
/// The destination policy, shared by every send: public addresses only, unless the operator
/// has said otherwise. A hosted server's "private network" is the cloud's, not the user's, so
/// the default is public-only; a self-hosted instance on a LAN can be told that its private
/// network *is* the user's, which is what `--allow-private-networks` means.
#[derive(Clone, Default)]
pub struct DestinationPolicy {
allow_private: bool,
}
impl DestinationPolicy {
pub fn new(allow_private: bool) -> Self {
Self { allow_private }
}
/// Check a URL before a hop is attempted: scheme and literal IPs. A hostname that passes
/// here still has its resolved addresses checked by [`Self::address_filter`].
pub fn check_url(&self, raw: &str) -> Result<(), String> {
let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?;
match url.scheme() {
"http" | "https" => {}
other => return Err(format!("Refusing to send over {other:?}; only http and https")),
}
let host = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?;
let host = host.trim_matches(|c| c == '[' || c == ']');
// A literal IP never reaches the resolver, so it is checked here. Hostnames are checked
// where their addresses become known.
if let Ok(ip) = host.parse::<IpAddr>() {
self.check_ip(ip)?;
}
Ok(())
}
/// The veto the engine's resolver applies to every address a hostname resolves to.
pub fn address_filter(&self) -> AddressFilter {
let policy = self.clone();
Arc::new(move |ip| policy.check_ip(ip))
}
pub fn check_ip(&self, ip: IpAddr) -> Result<(), String> {
if self.allow_private {
return Ok(());
}
match non_public_reason(ip) {
Some(reason) => Err(format!(
"Refusing to connect to {ip}: {reason}. This server only sends to public addresses"
)),
None => Ok(()),
}
}
}
/// Why an address is not a public internet address, or `None` if it is one.
///
/// Every range here is one a hosted relay must never be talked into reaching: the machine
/// itself, the network it sits on, and the link-local range where cloud metadata services
/// (169.254.169.254) live. IPv4 addresses carried inside fixed-layout IPv6 forms — IPv4-mapped,
/// the well-known NAT64 prefix, 6to4 — are unwrapped and judged as IPv4, since that is where
/// the packets end up; the NAT64 local-use range is refused outright. This is the stable-Rust
/// stand-in for `IpAddr::is_global`, which is still behind `#![feature(ip)]`; a network-specific
/// NAT64 prefix is not knowable here.
pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> {
match ip {
IpAddr::V4(v4) => non_public_v4(v4),
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return non_public_v4(v4);
}
if let Some(v4) = embedded_v4(&v6) {
return non_public_v4(v4);
}
if v6.is_loopback() {
Some("loopback")
} else if v6.is_unspecified() {
Some("unspecified")
} else if v6.is_unique_local() {
Some("unique local (fc00::/7)")
} else if v6.is_unicast_link_local() {
Some("link-local (fe80::/10)")
} else if v6.is_multicast() {
Some("multicast")
} else if v6.segments()[..3] == [0x64, 0xff9b, 1] {
Some("NAT64 local-use (64:ff9b:1::/48)")
} else if v6.segments()[..4] == [0x100, 0, 0, 0] {
Some("discard-only (100::/64)")
} else if (v6.segments()[0] & 0xffc0) == 0xfec0 {
Some("site-local (fec0::/10)")
} else if v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8 {
Some("documentation (2001:db8::/32)")
} else {
None
}
}
}
}
fn non_public_v4(v4: Ipv4Addr) -> Option<&'static str> {
let o = v4.octets();
if v4.is_loopback() {
Some("loopback (127.0.0.0/8)")
} else if v4.is_private() {
Some("private (10/8, 172.16/12, 192.168/16)")
} else if v4.is_link_local() {
Some("link-local (169.254.0.0/16, where cloud metadata lives)")
} else if v4.is_unspecified() || o[0] == 0 {
Some("this network (0.0.0.0/8)")
} else if o[0] == 100 && (o[1] & 0xc0) == 64 {
Some("carrier-grade NAT (100.64.0.0/10)")
} else if v4.is_broadcast() {
Some("broadcast")
} else if v4.is_multicast() {
Some("multicast (224.0.0.0/4)")
} else if o[0] >= 240 {
Some("reserved (240.0.0.0/4)")
} else if v4.is_documentation() {
Some("documentation")
} else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
Some("IETF protocol assignments (192.0.0.0/24)")
} else if o[0] == 198 && (o[1] & 0xfe) == 18 {
Some("benchmarking (198.18.0.0/15)")
} else {
None
}
}
/// The IPv4 address an IPv6 address stands for, when it is one of the fixed-layout translation
/// forms: the NAT64 well-known prefix (64:ff9b::/96) or 6to4 (2002::/16, IPv4 in the next 32
/// bits). The NAT64 local-use range (64:ff9b:1::/48) is a pool operators carve their own
/// prefix from, at a length only they know, so it is refused wholesale in [`non_public_reason`]
/// rather than decoded — the same call `std`'s (still unstable) `Ipv6Addr::is_global` makes.
fn embedded_v4(v6: &Ipv6Addr) -> Option<Ipv4Addr> {
let s = v6.segments();
let o = v6.octets();
if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|x| *x == 0) {
return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
}
if s[0] == 0x2002 {
return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5]));
}
None
}
/// An [`HttpSender`] that checks each hop's URL against the policy before delegating.
///
/// The engine's redirect loop calls the sender once per hop with the hop's URL, so wrapping
/// the sender is what makes `Location:` headers subject to the same rules as the first URL —
/// including a redirect to a literal internal IP, which the resolver would never see.
pub struct GuardedSender<S> {
inner: S,
policy: DestinationPolicy,
}
impl<S: HttpSender> GuardedSender<S> {
pub fn new(inner: S, policy: DestinationPolicy) -> Self {
Self { inner, policy }
}
}
#[async_trait]
impl<S: HttpSender> HttpSender for GuardedSender<S> {
async fn send(
&self,
request: SendableHttpRequest,
event_tx: mpsc::Sender<HttpResponseEvent>,
) -> yaak_http::error::Result<HttpResponse> {
if let Err(reason) = self.policy.check_url(&request.url) {
warn!("Refused {} {}: {reason}", request.method, request.url);
return Err(yaak_http::error::Error::RequestError(reason));
}
self.inner.send(request, event_tx).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn refuses_the_ranges_a_relay_must_never_reach() {
for addr in [
"127.0.0.1",
"127.9.9.9",
"10.0.0.1",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"169.254.169.254",
"169.254.0.1",
"0.0.0.0",
"100.64.0.1",
"255.255.255.255",
"224.0.0.1",
"240.0.0.1",
"::1",
"::",
"fc00::1",
"fd12::1",
"fe80::1",
"::ffff:127.0.0.1",
"::ffff:169.254.169.254",
"64:ff9b::7f00:1",
"ff02::1",
] {
assert!(non_public_reason(ip(addr)).is_some(), "{addr} should be refused");
}
}
#[test]
fn allows_public_addresses() {
for addr in [
"1.1.1.1",
"8.8.8.8",
"93.184.216.34",
"172.32.0.1",
"2606:4700:4700::1111",
] {
assert!(non_public_reason(ip(addr)).is_none(), "{addr} should be allowed");
}
}
#[test]
fn literal_private_addresses_in_urls_are_refused() {
let policy = DestinationPolicy::new(false);
assert!(policy.check_url("http://127.0.0.1/").is_err());
assert!(policy.check_url("http://[::1]/").is_err());
assert!(policy.check_url("http://169.254.169.254/latest/meta-data").is_err());
}
#[test]
fn allow_private_networks_opens_the_local_ranges_but_not_other_schemes() {
let policy = DestinationPolicy::new(true);
assert!(policy.check_url("http://127.0.0.1/").is_ok());
assert!(policy.check_ip(ip("10.0.0.1")).is_ok());
assert!(policy.check_ip(ip("169.254.169.254")).is_ok());
assert!(policy.check_url("file:///etc/passwd").is_err());
}
#[test]
fn only_http_schemes() {
let policy = DestinationPolicy::new(false);
assert!(policy.check_url("ftp://example.com/").is_err());
assert!(policy.check_url("file:///etc/passwd").is_err());
assert!(policy.check_url("https://example.com/").is_ok());
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Per-client rate limiting, kept deliberately small.
//!
//! One token bucket per client IP, refilled continuously, in a mutex-guarded
//! map that is swept of idle entries as it goes. Good enough to keep one
//! caller from monopolising a hosted instance; not a substitute for whatever
//! sits in front of it in production.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub struct RateLimiter {
per_minute: u32,
buckets: Mutex<HashMap<IpAddr, Bucket>>,
}
struct Bucket {
tokens: f64,
last: Instant,
}
impl RateLimiter {
/// `per_minute == 0` disables limiting.
pub fn new(per_minute: u32) -> Self {
Self { per_minute, buckets: Mutex::new(HashMap::new()) }
}
/// Take one token for `client`, or say how long until one is available.
pub fn check(&self, client: IpAddr) -> Result<(), Duration> {
if self.per_minute == 0 {
return Ok(());
}
let capacity = self.per_minute as f64;
let per_second = capacity / 60.0;
let now = Instant::now();
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
// Sweep buckets that have been idle long enough to be full again; there is nothing
// to remember about them.
if buckets.len() > 1024 {
buckets.retain(|_, b| now.duration_since(b.last).as_secs_f64() * per_second < capacity);
}
let bucket = buckets.entry(client).or_insert(Bucket { tokens: capacity, last: now });
let elapsed = now.duration_since(bucket.last).as_secs_f64();
bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
bucket.last = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
Ok(())
} else {
let wait = (1.0 - bucket.tokens) / per_second;
Err(Duration::from_secs_f64(wait.max(0.001)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_full_bucket_then_a_wait() {
let limiter = RateLimiter::new(3);
let ip: IpAddr = "203.0.113.5".parse().unwrap();
assert!(limiter.check(ip).is_ok());
assert!(limiter.check(ip).is_ok());
assert!(limiter.check(ip).is_ok());
let wait = limiter.check(ip).expect_err("fourth call in a burst should wait");
assert!(wait > Duration::ZERO && wait <= Duration::from_secs(20));
}
#[test]
fn clients_are_independent_and_zero_disables() {
let limiter = RateLimiter::new(1);
let a: IpAddr = "203.0.113.5".parse().unwrap();
let b: IpAddr = "203.0.113.6".parse().unwrap();
assert!(limiter.check(a).is_ok());
assert!(limiter.check(a).is_err());
assert!(limiter.check(b).is_ok());
let unlimited = RateLimiter::new(0);
for _ in 0..1000 {
assert!(unlimited.check(a).is_ok());
}
}
}
+251
View File
@@ -0,0 +1,251 @@
//! yaak-web: the network half of Yaak in a browser.
//!
//! A tab can't see a response the way a desktop app can — CORS hides most
//! headers, redirects are followed silently, there is no timeline. So the tab
//! renders the request and hands it here; this process puts it on the network
//! with the desktop's own engine and streams back everything that happened,
//! for the tab to store. It keeps nothing: no database, no files, no session.
//!
//! One binary, configured by flags or `YAAK_WEB_*` environment variables.
//! See README.md for running and deploying it, and `guard.rs` for what it
//! refuses to talk to.
mod config;
mod guard;
mod limits;
mod send;
mod wire;
use axum::Router;
use axum::body::Body;
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use clap::Parser;
use config::Config;
use guard::DestinationPolicy;
use limits::RateLimiter;
use log::{info, warn};
use send::{Refusal, SendLimits};
use serde_json::json;
use std::net::{IpAddr, SocketAddr};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use wire::SendRequest;
#[derive(Clone)]
struct AppState {
config: Arc<Config>,
limits: Arc<SendLimits>,
rate_limiter: Arc<RateLimiter>,
in_flight: Arc<Semaphore>,
}
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = Config::parse();
let policy = DestinationPolicy::new(config.allow_private_networks);
if config.allow_private_networks {
warn!(
"Sends to loopback, private and link-local addresses are ALLOWED. Only run this way \
on an instance strangers cannot reach"
);
}
let state = AppState {
limits: Arc::new(SendLimits {
policy,
max_response_bytes: config.max_response_bytes,
max_timeout: Duration::from_secs(config.max_timeout_secs),
}),
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
config: Arc::new(config),
};
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([header::CONTENT_TYPE])
.allow_origin(allowed_origins(&state.config.allowed_origins));
let api = Router::new()
.route("/v1/health", get(health))
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay`
// on the same router, behind the same policy, limits and auth. Not built; see README.
.route("/v1/http/send", post(send_http))
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
.layer(cors)
.with_state(state.clone());
let app = match &state.config.serve {
Some(dir) => {
info!("Serving the web client from {}", dir.display());
api.merge(web_router(dir))
}
None => api,
};
let bind = state.config.bind;
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
eprintln!("Failed to bind {bind}: {e}");
std::process::exit(1);
});
info!(
"yaak-web listening on http://{bind} (rate limit: {}/min)",
state.config.rate_limit_per_minute,
);
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
info!("Shutting down");
})
.await
.expect("server error");
}
/// The built web client, served on the same origin as the API.
///
/// This is what makes a single container zero-configuration: the tab's send URL is a path on
/// the page's own origin, so there is no CORS, no second service and no URL to bake in. It is
/// only a file server — a send behaves exactly as it does without this flag.
///
/// Merged as a fallback, so the `/v1` routes are matched first and a request that matches no
/// file at all gets `index.html` (the app routes client-side; a deep link must survive a
/// refresh).
fn web_router(dir: &Path) -> Router {
let index = ServeFile::new(dir.join("index.html"));
Router::new()
// `fallback`, not `not_found_service`: the app's own routes are real pages, so
// index.html is served with the 200 the browser expects, not a 404 carrying HTML.
.fallback_service(ServeDir::new(dir).fallback(index))
.layer(middleware::from_fn(cache_control))
.layer(CompressionLayer::new())
}
/// Vite gives everything in `/assets` a content-hashed name, so those can be cached forever.
/// Everything else — `index.html` above all, including the copy served for an unknown path —
/// must be revalidated, or a browser keeps serving the deploy before last.
async fn cache_control(req: Request, next: Next) -> Response {
let hashed_name = req.uri().path().starts_with("/assets/");
let mut res = next.run(req).await;
if !res.status().is_success() {
return res;
}
let is_html = res
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("text/html"));
let value = if hashed_name && !is_html {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
res
}
fn allowed_origins(origins: &[String]) -> AllowOrigin {
if origins.iter().any(|o| o.trim() == "*") {
return AllowOrigin::any();
}
let parsed: Vec<HeaderValue> =
origins.iter().filter_map(|o| HeaderValue::from_str(o.trim()).ok()).collect();
AllowOrigin::list(parsed)
}
async fn health(State(state): State<AppState>) -> impl IntoResponse {
Json(json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"maxResponseBytes": state.config.max_response_bytes,
"maxTimeoutSecs": state.config.max_timeout_secs,
}))
}
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
let message = message.into();
(status, Json(json!({ "error": message }))).into_response()
}
/// The client's address for rate limiting: the socket peer, or the first `X-Forwarded-For`
/// hop when the operator has said the header can be trusted.
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
if config.trust_forwarded_for
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
&& let Some(first) = forwarded.split(',').next()
&& let Ok(ip) = first.trim().parse::<IpAddr>()
{
return ip;
}
peer.ip()
}
async fn send_http(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(body): Json<SendRequest>,
) -> Response {
let ip = client_ip(&state.config, &headers, peer);
if let Err(wait) = state.rate_limiter.check(ip) {
warn!("Rate limited {ip}");
let mut res = error_response(
StatusCode::TOO_MANY_REQUESTS,
format!("Rate limit reached; try again in {}s", wait.as_secs().max(1)),
);
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(wait.as_secs().max(1)));
return res;
}
let Ok(permit) = state.in_flight.clone().try_acquire_owned() else {
warn!("At capacity; refusing {ip}");
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This server is at capacity");
};
let prepared = match send::prepare(state.limits.clone(), body).await {
Ok(p) => p,
Err(Refusal::Unsupported(m)) => return error_response(StatusCode::BAD_REQUEST, m),
Err(Refusal::Invalid(m)) => return error_response(StatusCode::BAD_REQUEST, m),
Err(Refusal::Destination(m)) => {
warn!("Refused send from {ip}: {m}");
return error_response(StatusCode::FORBIDDEN, m);
}
};
let description = prepared.describe();
info!("{ip} -> {description}");
let started = Instant::now();
let (tx, rx) = tokio::sync::mpsc::channel(send::FRAME_CHANNEL_CAPACITY);
tokio::spawn(async move {
prepared.run(tx).await;
send::log_outcome(&description, started, "finished");
drop(permit);
});
let stream = tokio_stream_from(rx);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/x-ndjson")
.header(header::CACHE_CONTROL, "no-store")
// Some reverse proxies buffer streamed responses unless told not to
.header("x-accel-buffering", "no")
.body(Body::from_stream(stream))
.expect("valid response")
}
fn tokio_stream_from<T: Send + 'static>(
mut rx: tokio::sync::mpsc::Receiver<T>,
) -> impl futures_util::Stream<Item = T> + Send + 'static {
futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx))
}
+358
View File
@@ -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());
}
+97
View File
@@ -0,0 +1,97 @@
//! What crosses the wire between a tab and this server.
//!
//! One `POST /v1/http/send` carries a request the tab has already rendered —
//! templates resolved, inheritance applied — plus the send settings and the
//! cookies the send starts with. The reply is a stream of newline-delimited
//! JSON frames: timeline events as they happen, the response head as soon as
//! headers arrive, body chunks as they are read, and one terminal frame.
//!
//! Nothing here names a workspace, a request id, or a response id. The server
//! does not know what the tab will call this response; it only knows what came
//! back.
//!
//! The TypeScript side of this contract is generated from these types into
//! `bindings/` (`cargo test -p yaak-web`) and published to the tab as
//! `@yaakapp-internal/web`, so a change here is a type error there.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yaak_models::models::{
Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings,
};
/// The body of `POST /v1/http/send`.
#[derive(Deserialize, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_web.ts")]
pub struct SendRequest {
/// The request to send, in the desktop's own model shape but with every template already
/// rendered by the tab. The server builds the URL, headers and body from it exactly the way
/// the desktop does after rendering.
pub request: HttpRequest,
/// The resolved settings, values only. Where they came from is the tab's to record in
/// its timeline; the server only needs to obey them.
pub settings: HttpSendSettings,
/// The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
#[serde(default)]
pub cookies: Option<Vec<Cookie>>,
}
/// One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
/// camelCase like every model the tab stores.
#[derive(Serialize, Debug, TS)]
#[serde(
tag = "type",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
#[ts(export, export_to = "gen_web.ts")]
pub enum Frame {
/// A timeline event, in the same shape the desktop stores. Interleaved with everything
/// else in the order the engine produced it.
Event { event: HttpResponseEventData },
/// The response head. Sent once, as soon as the final hop's headers are in — before any of
/// the body — so the tab can show status and headers while the body streams.
Response {
status: u16,
status_reason: Option<String>,
/// The URL that answered, after redirects.
url: String,
remote_addr: Option<String>,
version: Option<String>,
headers: Vec<HttpResponseHeader>,
/// The headers that were actually sent on the final hop, cookies and all.
request_headers: Vec<HttpResponseHeader>,
/// `Content-Length` as declared by the server, if it declared one.
#[ts(type = "number | null")]
content_length: Option<u64>,
/// Milliseconds from the start of the send to the response head.
#[ts(type = "number")]
elapsed_headers: u64,
/// Milliseconds spent in DNS on the last lookup, or zero.
#[ts(type = "number")]
elapsed_dns: u64,
},
/// A piece of the response body, decompressed, base64-encoded.
Body { data: String },
/// The send finished. The last frame on a successful stream.
Done {
/// Milliseconds from the start of the send to the end of the body.
#[ts(type = "number")]
elapsed: u64,
/// Bytes of body relayed, after decompression.
#[ts(type = "number")]
content_length: u64,
/// Bytes on the wire as declared by the server, or the relayed size when unknown.
#[ts(type = "number")]
content_length_compressed: u64,
/// The jar as the send left it, for the tab to persist. `None` when the tab sent none.
cookies: Option<Vec<Cookie>>,
},
/// The send failed. The last frame on a failed stream. Cookies collected before the failure
/// still come back — the transaction may have set some before the hop that failed.
Error {
message: String,
cookies: Option<Vec<Cookie>>,
},
}
+3 -1
View File
@@ -39,7 +39,7 @@ md5 = "0.8.0"
notify = "8.0.0"
pretty_graphql = "0.2"
r2d2 = "0.8.10"
r2d2_sqlite = "0.25.0"
r2d2_sqlite = "0.32"
mime_guess = "2.0.5"
rand = "0.9.0"
reqwest = { workspace = true, features = [
@@ -80,6 +80,7 @@ yaak-common = { workspace = true }
yaak-tauri-utils = { workspace = true }
yaak-core = { workspace = true }
yaak = { workspace = true }
yaak-commands = { workspace = true }
yaak-crypto = { workspace = true }
yaak-fonts = { workspace = true }
yaak-git = { workspace = true }
@@ -87,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
View File
@@ -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 };
@@ -1,92 +0,0 @@
use crate::PluginContextExt;
use crate::error::Result;
use std::sync::Arc;
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
use yaak_crypto::manager::EncryptionManager;
use yaak_models::models::HttpRequestHeader;
use yaak_models::queries::workspaces::default_headers;
use yaak_plugins::events::GetThemesResponse;
use yaak_plugins::manager::PluginManager;
use yaak_plugins::native_template_functions::{
decrypt_secure_template_function, encrypt_secure_template_function,
};
/// Extension trait for accessing the EncryptionManager from Tauri Manager types.
pub trait EncryptionManagerExt<'a, R> {
fn crypto(&'a self) -> State<'a, EncryptionManager>;
}
impl<'a, R: Runtime, M: Manager<R>> EncryptionManagerExt<'a, R> for M {
fn crypto(&'a self) -> State<'a, EncryptionManager> {
self.state::<EncryptionManager>()
}
}
pub(crate) async fn cmd_decrypt_template<R: Runtime>(
window: WebviewWindow<R>,
template: &str,
) -> Result<String> {
let encryption_manager = window.app_handle().state::<EncryptionManager>();
let plugin_context = window.plugin_context();
Ok(decrypt_secure_template_function(&encryption_manager, &plugin_context, template)?)
}
pub(crate) async fn cmd_secure_template<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
template: &str,
) -> Result<String> {
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let plugin_context = window.plugin_context();
Ok(encrypt_secure_template_function(
plugin_manager,
encryption_manager,
&plugin_context,
template,
)?)
}
pub(crate) async fn cmd_get_themes<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> Result<Vec<GetThemesResponse>> {
Ok(plugin_manager.get_themes(&window.plugin_context()).await?)
}
pub(crate) async fn cmd_enable_encryption<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
) -> Result<()> {
window.crypto().ensure_workspace_key(workspace_id)?;
window.crypto().reveal_workspace_key(workspace_id)?;
Ok(())
}
pub(crate) async fn cmd_reveal_workspace_key<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
) -> Result<String> {
Ok(window.crypto().reveal_workspace_key(workspace_id)?)
}
pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
key: &str,
) -> Result<()> {
window.crypto().set_human_key(workspace_id, key)?;
Ok(())
}
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: &str,
) -> Result<()> {
window.crypto().disable_encryption(workspace_id)?;
Ok(())
}
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
default_headers()
}
@@ -41,6 +41,9 @@ pub enum Error {
#[error(transparent)]
YaakError(#[from] yaak::Error),
#[error(transparent)]
CommandError(#[from] yaak_commands::Error),
#[error(transparent)]
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
+2 -20
View File
@@ -2,13 +2,11 @@ use std::collections::BTreeMap;
use crate::PluginContextExt;
use crate::error::Result;
use crate::models_ext::QueryManagerExt;
use KeyAndValueRef::{Ascii, Binary};
use tauri::{Manager, Runtime, WebviewWindow};
use 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();
@@ -21,29 +19,13 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String>
entries
}
pub(crate) fn resolve_grpc_request<R: Runtime>(
window: &WebviewWindow<R>,
request: &GrpcRequest,
) -> Result<(GrpcRequest, String)> {
let mut new_request = request.clone();
let (authentication_type, authentication, authentication_context_id) =
window.db().resolve_auth_for_grpc_request(request)?;
new_request.authentication_type = authentication_type;
new_request.authentication = authentication;
let metadata = window.db().resolve_metadata_for_grpc_request(request)?;
new_request.metadata = metadata;
Ok((new_request, authentication_context_id))
}
pub(crate) async fn build_metadata<R: Runtime>(
window: &WebviewWindow<R>,
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);
@@ -179,19 +178,3 @@ async fn send_http_request_inner<R: Runtime>(
Ok(SentHttpRequest { response: result.response, body: result.response_body })
}
pub fn resolve_http_request<R: Runtime>(
window: &WebviewWindow<R>,
request: &HttpRequest,
) -> Result<(HttpRequest, String)> {
let mut new_request = request.clone();
let (authentication_type, authentication, authentication_context_id) =
window.db().resolve_auth_for_http_request(request)?;
new_request.authentication_type = authentication_type;
new_request.authentication = authentication;
let headers = window.db().resolve_headers_for_http_request(request)?;
new_request.headers = headers;
Ok((new_request, authentication_context_id))
}
+53 -22
View File
@@ -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()));
+72 -563
View File
@@ -2,19 +2,18 @@ extern crate core;
use crate::encoding::read_response_body;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};
use crate::http_request::{resolve_http_request, send_http_request};
use crate::import::{import_data, import_url};
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
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_json_value, render_template};
use crate::render::{render_grpc_request, render_template};
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use crate::uri_scheme::handle_deep_link;
use error::Result as YaakResult;
use eventsource_client::{EventParser, SSE};
use log::{debug, error, info, warn};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
@@ -29,42 +28,31 @@ use tauri_plugin_log::{Builder, Target, TargetKind, log};
use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak::export::{self, ExportDataParams};
use yaak::send::ResponseBody;
use yaak_commands::responses::locate_response_body;
use yaak_commands::resolve::resolve_grpc_request;
use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
use yaak_grpc::{Code, ServiceDefinition};
use yaak_mac_window::AppHandleMacWindowExt;
use yaak_models::models::{
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
WorkspaceMeta,
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
use yaak_plugins::events::{
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
CallWorkspaceActionRequest, Color, FilterResponse, GetFolderActionsResponse,
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse,
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, InternalEvent,
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
RenderPurpose, ShowToastRequest,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
use yaak_sse::sse::ServerSentEvent;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_templates::format_json::format_json;
use yaak_templates::strip_json_comments::strip_json_comments;
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
mod commands;
mod encoding;
mod error;
mod feedback;
@@ -79,6 +67,7 @@ mod notifications;
mod plugin_events;
mod plugins_ext;
mod render;
mod restart;
mod rpc_ext;
mod sync_ext;
mod updates;
@@ -223,56 +212,6 @@ async fn detect_cli_version_for_binary(program: &str) -> Option<String> {
Some(parts.next().unwrap_or(line).to_string())
}
async fn cmd_template_tokens_to_string<R: Runtime>(
window: WebviewWindow<R>,
app_handle: AppHandle<R>,
tokens: Tokens,
) -> YaakResult<String> {
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new(
plugin_manager,
encryption_manager,
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
RenderPurpose::Preview,
);
let new_tokens = transform_args(tokens, &cb)?;
Ok(new_tokens.to_string())
}
async fn cmd_render_template<R: Runtime>(
window: WebviewWindow<R>,
app_handle: AppHandle<R>,
template: &str,
workspace_id: &str,
environment_id: Option<&str>,
purpose: Option<RenderPurpose>,
ignore_error: Option<bool>,
) -> YaakResult<String> {
let environment_chain =
app_handle.db().resolve_environments(workspace_id, None, environment_id)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let result = render_template(
template,
environment_chain,
&PluginTemplateCallback::new(
plugin_manager,
encryption_manager,
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
purpose.unwrap_or(RenderPurpose::Preview),
),
&RenderOptions {
error_behavior: match ignore_error {
Some(true) => RenderErrorBehavior::ReturnEmpty,
_ => RenderErrorBehavior::Throw,
},
},
)
.await?;
Ok(result)
}
async fn cmd_send_feedback<R: Runtime>(
app_handle: AppHandle<R>,
feature: String,
@@ -299,7 +238,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> YaakResult<Vec<ServiceDefinition>> {
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
let (resolved_request, auth_context_id) =
resolve_grpc_request(&window.db(), &unrendered_request)?;
let environment_chain = app_handle.db().resolve_environments(
&unrendered_request.workspace_id,
@@ -309,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,
@@ -359,7 +299,8 @@ async fn cmd_grpc_go<R: Runtime>(
grpc_handle: State<'_, Mutex<GrpcHandle>>,
) -> YaakResult<String> {
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
let (resolved_request, auth_context_id) =
resolve_grpc_request(&window.db(), &unrendered_request)?;
let environment_chain = app_handle.db().resolve_environments(
&unrendered_request.workspace_id,
unrendered_request.folder_id.as_deref(),
@@ -368,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,
@@ -963,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(())
}
@@ -1011,10 +952,6 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
Ok(EphemeralHttpResponse { response: sent.response, body })
}
async fn cmd_format_json(text: &str) -> YaakResult<String> {
Ok(format_json(text, " "))
}
async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
match pretty_graphql::format_text(text, &Default::default()) {
Ok(formatted) => Ok(formatted),
@@ -1022,44 +959,12 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
}
}
/// Where a response's body is, and what it is meant to be read as.
struct ResponseBodyLocation {
/// None when the response has no stored body.
path: Option<PathBuf>,
/// The response's declared `Content-Type`, empty when it has none.
content_type: String,
}
/// Find a response's body from its id alone.
///
/// The frontend hands back an id and never a path, so the only bodies reachable
/// here are ones the engine wrote and the database still knows about. A
/// response that was never saved has no entry, and its body came back from the
/// send that made it.
fn locate_response_body<R: Runtime>(
app_handle: &AppHandle<R>,
response_id: &str,
) -> YaakResult<ResponseBodyLocation> {
let response = app_handle.db().get_http_response(response_id)?;
Ok(ResponseBodyLocation {
path: response.body_path.map(PathBuf::from),
content_type: response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone())
.unwrap_or_default(),
})
}
async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
response_id: &str,
filter: Option<&str>,
) -> YaakResult<FilterResponse> {
let location = locate_response_body(window.app_handle(), response_id)?;
let location = locate_response_body(&window.db(), response_id)?;
let Some(body_path) = location.path else {
return Ok(FilterResponse { content: String::new(), error: None });
};
@@ -1070,48 +975,19 @@ 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 }),
}
}
/// The body's path on this machine, for the desktop host to read or hand to the
/// webview's asset protocol.
///
/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
/// the path, and only because it is about to open the file itself. Hosts
/// without a filesystem serve the same bytes over HTTP instead.
async fn cmd_http_response_body_path<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Option<String>> {
let location = locate_response_body(&app_handle, response_id)?;
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
}
async fn cmd_http_request_body<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Option<Vec<u8>>> {
let body_id = format!("{}.request", response_id);
let chunks = app_handle.blobs().get_chunks(&body_id)?;
if chunks.is_empty() {
return Ok(None);
}
// Concatenate all chunks
let body: Vec<u8> = chunks.into_iter().flat_map(|c| c.data).collect();
Ok(Some(body))
}
async fn cmd_get_sse_events<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Vec<ServerSentEvent>> {
let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else {
let Some(body_path) = locate_response_body(&app_handle.db(), response_id)?.path else {
return Ok(Vec::new());
};
@@ -1134,321 +1010,42 @@ async fn cmd_get_sse_events<R: Runtime>(
Ok(events)
}
async fn cmd_get_http_response_events<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Vec<HttpResponseEvent>> {
let events: Vec<HttpResponseEvent> = app_handle.db().list_http_response_events(response_id)?;
Ok(events)
}
async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>,
file_path: &str,
) -> 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)
}
async fn cmd_http_request_actions<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<Vec<GetHttpRequestActionsResponse>> {
Ok(plugin_manager.get_http_request_actions(&window.plugin_context()).await?)
}
async fn cmd_websocket_request_actions<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<Vec<GetWebsocketRequestActionsResponse>> {
Ok(plugin_manager.get_websocket_request_actions(&window.plugin_context()).await?)
}
async fn cmd_call_websocket_request_action<R: Runtime>(
window: WebviewWindow<R>,
req: CallWebsocketRequestActionRequest,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<()> {
let websocket_request = window.db().get_websocket_request(&req.args.websocket_request.id)?;
Ok(plugin_manager
.call_websocket_request_action(
&window.plugin_context(),
CallWebsocketRequestActionRequest {
args: CallWebsocketRequestActionArgs { websocket_request },
..req
},
)
.await?)
}
async fn cmd_workspace_actions<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<Vec<GetWorkspaceActionsResponse>> {
Ok(plugin_manager.get_workspace_actions(&window.plugin_context()).await?)
}
async fn cmd_call_workspace_action<R: Runtime>(
window: WebviewWindow<R>,
req: CallWorkspaceActionRequest,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<()> {
let workspace = window.db().get_workspace(&req.args.workspace.id)?;
Ok(plugin_manager
.call_workspace_action(
&window.plugin_context(),
CallWorkspaceActionRequest { args: CallWorkspaceActionArgs { workspace }, ..req },
)
.await?)
}
async fn cmd_folder_actions<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<Vec<GetFolderActionsResponse>> {
Ok(plugin_manager.get_folder_actions(&window.plugin_context()).await?)
}
async fn cmd_call_folder_action<R: Runtime>(
window: WebviewWindow<R>,
req: CallFolderActionRequest,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<()> {
let folder = window.db().get_folder(&req.args.folder.id)?;
Ok(plugin_manager
.call_folder_action(
&window.plugin_context(),
CallFolderActionRequest { args: CallFolderActionArgs { folder }, ..req },
)
.await?)
}
async fn cmd_grpc_request_actions<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<Vec<GetGrpcRequestActionsResponse>> {
Ok(plugin_manager.get_grpc_request_actions(&window.plugin_context()).await?)
}
async fn cmd_template_function_summaries<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<Vec<GetTemplateFunctionSummaryResponse>> {
let results = plugin_manager.get_template_function_summaries(&window.plugin_context()).await?;
Ok(results)
}
async fn cmd_template_function_config<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
function_name: &str,
values: HashMap<String, JsonPrimitive>,
model: AnyModel,
_environment_id: Option<&str>,
) -> YaakResult<GetTemplateFunctionConfigResponse> {
Ok(plugin_manager
.get_template_function_config(&window.plugin_context(), function_name, values, model.id())
.await?)
}
async fn cmd_get_http_authentication_summaries<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<Vec<GetHttpAuthenticationSummaryResponse>> {
let results =
plugin_manager.get_http_authentication_summaries(&window.plugin_context()).await?;
Ok(results.into_iter().map(|(_, a)| a).collect())
}
async fn cmd_get_http_authentication_config<R: Runtime>(
window: WebviewWindow<R>,
app_handle: AppHandle<R>,
plugin_manager: State<'_, PluginManager>,
encryption_manager: State<'_, EncryptionManager>,
auth_name: &str,
values: HashMap<String, JsonPrimitive>,
model: AnyModel,
environment_id: Option<&str>,
) -> YaakResult<GetHttpAuthenticationConfigResponse> {
// Extract workspace_id and folder_id from the model to resolve the environment chain
let (workspace_id, folder_id) = match &model {
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
AnyModel::Workspace(w) => (w.id.clone(), None),
_ => return Err(GenericError("Unsupported model type for authentication config".into())),
};
// Resolve environment chain and render the values for token lookup
let environment_chain = app_handle.db().resolve_environments(
&workspace_id,
folder_id.as_deref(),
environment_id,
)?;
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
let cb = PluginTemplateCallback::new(
plugin_manager_arc,
encryption_manager_arc,
&window.plugin_context(),
RenderPurpose::Preview,
);
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
let values_json: serde_json::Value = serde_json::to_value(&values)?;
let rendered_json =
render_json_value(values_json, environment_chain, &cb, &RenderOptions::return_empty())
.await?;
// Convert back to HashMap<String, JsonPrimitive>
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
Ok(plugin_manager
.get_http_authentication_config(
&window.plugin_context(),
auth_name,
rendered_values,
model.id(),
)
.await?)
}
async fn cmd_call_http_request_action<R: Runtime>(
window: WebviewWindow<R>,
req: CallHttpRequestActionRequest,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<()> {
Ok(plugin_manager
.call_http_request_action(
&window.plugin_context(),
CallHttpRequestActionRequest {
args: CallHttpRequestActionArgs {
http_request: resolve_http_request(&window, &req.args.http_request)?.0,
..req.args
},
..req
},
)
.await?)
}
async fn cmd_call_grpc_request_action<R: Runtime>(
window: WebviewWindow<R>,
req: CallGrpcRequestActionRequest,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<()> {
Ok(plugin_manager
.call_grpc_request_action(
&window.plugin_context(),
CallGrpcRequestActionRequest {
args: CallGrpcRequestActionArgs {
grpc_request: resolve_grpc_request(&window, &req.args.grpc_request)?.0,
..req.args
},
..req
},
)
.await?)
}
async fn cmd_call_http_authentication_action<R: Runtime>(
window: WebviewWindow<R>,
app_handle: AppHandle<R>,
plugin_manager: State<'_, PluginManager>,
encryption_manager: State<'_, EncryptionManager>,
auth_name: &str,
action_index: i32,
values: HashMap<String, JsonPrimitive>,
model: AnyModel,
environment_id: Option<&str>,
) -> YaakResult<()> {
// Extract workspace_id and folder_id from the model to resolve the environment chain
let (workspace_id, folder_id) = match &model {
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
AnyModel::Workspace(w) => (w.id.clone(), None),
_ => return Err(GenericError("Unsupported model type for authentication action".into())),
};
// Resolve environment chain and render the values
let environment_chain = app_handle.db().resolve_environments(
&workspace_id,
folder_id.as_deref(),
environment_id,
)?;
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
let cb = PluginTemplateCallback::new(
plugin_manager_arc,
encryption_manager_arc,
&window.plugin_context(),
RenderPurpose::Send,
);
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
let values_json: serde_json::Value = serde_json::to_value(&values)?;
let rendered_json =
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
// Convert back to HashMap<String, JsonPrimitive>
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
Ok(plugin_manager
.call_http_authentication_action(
&window.plugin_context(),
auth_name,
action_index,
rendered_values,
&model.id(),
)
.await?)
}
async fn cmd_curl_to_request<R: Runtime>(
window: WebviewWindow<R>,
command: &str,
plugin_manager: State<'_, PluginManager>,
workspace_id: &str,
) -> YaakResult<HttpRequest> {
let import_result = plugin_manager.import_data(&window.plugin_context(), command).await?;
Ok(import_result
.resources
.http_requests
.get(0)
.ok_or(GenericError("No curl command found".to_string()))
.map(|r| {
let mut request = r.clone();
request.workspace_id = workspace_id.into();
request.id = "".to_string();
request
})?)
}
async fn cmd_export_data<R: Runtime>(
app_handle: AppHandle<R>,
export_path: &str,
workspace_ids: Vec<&str>,
include_private_environments: bool,
) -> YaakResult<()> {
let version = app_handle.package_info().version.to_string();
Ok(export::export_data(ExportDataParams {
query_manager: &app_handle.db_manager(),
yaak_version: &version,
export_path: Path::new(export_path),
workspace_ids,
include_private_environments,
})?)
}
/// Decodes base64 and writes the bytes to a file the user picked.
///
@@ -1473,20 +1070,6 @@ async fn cmd_save_base64_to_binary<R: Runtime>(
Ok(())
}
async fn cmd_save_response<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
filepath: &str,
) -> YaakResult<()> {
let response = app_handle.db().get_http_response(response_id)?;
let body_path =
response.body_path.ok_or(GenericError("Response does not have a body".to_string()))?;
fs::copy(body_path, filepath).map_err(|e| GenericError(e.to_string()))?;
Ok(())
}
async fn cmd_send_http_request<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
@@ -1558,101 +1141,6 @@ async fn cmd_send_http_request<R: Runtime>(
Ok(r)
}
async fn cmd_reload_plugins<R: Runtime>(
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<Vec<(String, String)>> {
let plugins = app_handle.db().list_plugins()?;
let plugin_context =
PluginContext::new(Some(window.label().to_string()), window.workspace_id());
let errors = plugin_manager.initialize_all_plugins(plugins, &plugin_context).await;
Ok(errors)
}
async fn cmd_plugin_info<R: Runtime>(
id: &str,
app_handle: AppHandle<R>,
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<PluginMetadata> {
let plugin = app_handle.db().get_plugin(id)?;
if let Some(plugin_handle) = plugin_manager
.get_plugin_by_dir(plugin.directory.as_str())
.await
{
return Ok(plugin_handle.info());
}
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
return Ok(metadata);
}
Ok(fallback_plugin_metadata(&plugin.directory))
}
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
let display_name = PathBuf::from(directory)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(directory)
.to_string();
PluginMetadata {
version: "Unavailable".to_string(),
name: directory.to_string(),
display_name,
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
homepage_url: None,
repository_url: None,
}
}
async fn cmd_delete_all_grpc_connections<R: Runtime>(
request_id: &str,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<()> {
Ok(app_handle.db().delete_all_grpc_connections_for_request(
request_id,
&UpdateSource::from_window_label(window.label()),
)?)
}
async fn cmd_delete_send_history<R: Runtime>(
workspace_id: &str,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<()> {
Ok(app_handle.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
tx.delete_all_http_responses_for_workspace(workspace_id, source)?;
tx.delete_all_grpc_connections_for_workspace(workspace_id, source)?;
tx.delete_all_websocket_connections_for_workspace(workspace_id, source)?;
Ok(())
})?)
}
async fn cmd_delete_all_http_responses<R: Runtime>(
request_id: &str,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<()> {
app_handle.db().delete_all_http_responses_for_request(
request_id,
&UpdateSource::from_window_label(window.label()),
)?;
Ok(())
}
async fn cmd_get_workspace_meta<R: Runtime>(
app_handle: AppHandle<R>,
workspace_id: &str,
) -> YaakResult<WorkspaceMeta> {
let db = app_handle.db();
let workspace = db.get_workspace(workspace_id)?;
Ok(db.get_or_create_workspace_meta(&workspace.id)?)
}
async fn cmd_new_child_window<R: Runtime>(
parent_window: WebviewWindow<R>,
@@ -1779,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>());
@@ -1878,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"))]
@@ -1930,6 +1427,7 @@ pub fn run() {
}
});
}
RunEvent::Exit => restart::relaunch_if_requested(),
_ => {}
};
});
@@ -1951,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 {
@@ -1972,6 +1473,7 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
let ev = match ev {
Ok(Some(ev)) => ev,
// Nothing to say, or the reply comes later from somewhere else.
Ok(None) => return,
Err(e) => {
warn!("Failed to handle plugin event: {e:?}");
@@ -1984,13 +1486,20 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
timeout: Some(30000),
}),
);
return;
// Tell the plugin as well as the user. It is awaiting a
// reply, and a toast it cannot see would leave it
// waiting for one that never comes.
InternalEventPayload::ErrorResponse(ErrorResponse { error: e.to_string() })
}
};
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:?}"),
}
});
}
@@ -12,12 +12,9 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::error::Result;
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{ModelPayload, UpdateSource};
use yaak_plugins::manager::PluginManager;
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
@@ -123,163 +120,12 @@ impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
/// Extension trait for accessing the BlobManager from Tauri Manager types.
pub trait BlobManagerExt<'a, R> {
fn blob_manager(&'a self) -> State<'a, BlobManager>;
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext;
}
impl<'a, R: Runtime, M: Manager<R>> BlobManagerExt<'a, R> for M {
fn blob_manager(&'a self) -> State<'a, BlobManager> {
self.state::<BlobManager>()
}
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext {
let manager = self.state::<BlobManager>();
manager.inner().connect()
}
}
// Commands for yaak-models
use tauri::WebviewWindow;
pub(crate) fn models_upsert<R: Runtime>(
window: WebviewWindow<R>,
model: AnyModel,
) -> Result<String> {
let db = window.db();
let blobs = window.blob_manager();
let source = &UpdateSource::from_window_label(window.label());
yaak::models_ops::upsert_model(&db, &blobs, model, source)
}
// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a
// blocking thread instead of stalling the main thread and all other IPC.
pub(crate) async fn models_delete<R: Runtime>(
window: WebviewWindow<R>,
model: AnyModel,
) -> Result<String> {
use yaak_models::error::Error::GenericError;
tauri::async_runtime::spawn_blocking(move || {
let blobs = window.blob_manager();
// Use transaction for deletions because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
yaak::models_ops::delete_model(tx, &blobs, model, source)
})
})
.await
.map_err(|e| GenericError(format!("Delete task failed: {e}")))?
}
pub(crate) fn models_duplicate<R: Runtime>(
window: WebviewWindow<R>,
model_type: String,
model_id: String,
) -> Result<String> {
// Use transaction for duplications because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
yaak::models_ops::duplicate_model(tx, &model_type, &model_id, source)
})
}
pub(crate) fn models_websocket_events<R: Runtime>(
app_handle: tauri::AppHandle<R>,
connection_id: &str,
) -> Result<Vec<WebsocketEvent>> {
Ok(app_handle.db().list_websocket_events(connection_id)?)
}
pub(crate) fn models_grpc_events<R: Runtime>(
app_handle: tauri::AppHandle<R>,
connection_id: &str,
) -> Result<Vec<GrpcEvent>> {
Ok(app_handle.db().list_grpc_events(connection_id)?)
}
pub(crate) fn models_get_settings<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Result<Settings> {
Ok(app_handle.db().get_settings())
}
pub(crate) fn models_get_graphql_introspection<R: Runtime>(
app_handle: tauri::AppHandle<R>,
request_id: &str,
) -> Result<Option<GraphQlIntrospection>> {
Ok(app_handle.db().get_graphql_introspection(request_id))
}
pub(crate) fn models_upsert_graphql_introspection<R: Runtime>(
app_handle: tauri::AppHandle<R>,
request_id: &str,
workspace_id: &str,
content: Option<String>,
window: WebviewWindow<R>,
) -> Result<GraphQlIntrospection> {
let source = UpdateSource::from_window_label(window.label());
Ok(app_handle.db().upsert_graphql_introspection(workspace_id, request_id, content, &source)?)
}
pub(crate) async fn models_workspace_models<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: Option<&str>,
plugin_manager: State<'_, PluginManager>,
) -> Result<String> {
let mut l: Vec<AnyModel> = Vec::new();
// Add the global models
{
let db = window.db();
l.push(db.get_settings().into());
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
}
let plugins = {
let db = window.db();
db.list_plugins()?
};
let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;
l.append(&mut plugins.into_iter().map(Into::into).collect());
// Add the workspace children
if let Some(wid) = workspace_id {
let db = window.db();
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
}
let j = serde_json::to_string(&l)?;
Ok(escape_str_for_webview(&j))
}
fn escape_str_for_webview(input: &str) -> String {
input
.chars()
.map(|c| {
let code = c as u32;
// ASCII
if code <= 0x7F {
c.to_string()
// BMP characters encoded normally
} else if code < 0xFFFF {
format!("\\u{:04X}", code)
// Beyond BMP encoded a surrogate pairs
} else {
let high = ((code - 0x10000) >> 10) + 0xD800;
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
format!("\\u{:04X}\\u{:04X}", high, low)
}
})
.collect()
}
/// Initialize database managers as a plugin (for initialization order).
@@ -305,30 +151,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
}
};
let db = query_manager.connect();
if let Err(err) = db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
{
error!("Failed to prune model_changes rows on startup: {err:?}");
}
// Only stream writes that happen after this app launch.
let cursor = ModelChangeCursor::from_launch_time();
let poll_query_manager = query_manager.clone();
// GC response bodies orphaned by cascade deletes, which historically
// didn't clean the blob DB or responses directory
let gc_query_manager = query_manager.clone();
let gc_blob_manager = blob_manager.clone();
let gc_responses_dir = app_path.join("responses");
tauri::async_runtime::spawn_blocking(move || {
let db = gc_query_manager.connect();
match db.delete_orphaned_response_bodies(&gc_blob_manager, &gc_responses_dir) {
Ok(0) => {}
Ok(n) => log::info!("Deleted {n} orphaned response bodies"),
Err(e) => error!("Failed to delete orphaned response bodies: {e:?}"),
}
});
app_handle.manage(query_manager);
app_handle.manage(blob_manager);
@@ -7,6 +7,8 @@ use crate::{
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
workspace_from_window,
};
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use chrono::Utc;
use log::error;
use std::sync::Arc;
@@ -16,6 +18,7 @@ use tauri_plugin_opener::OpenerExt;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak::response_body::FileResponseBodyStore;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::get_cookie_value_from_jar;
use yaak_models::models::{HttpResponse, Plugin};
@@ -29,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;
@@ -54,6 +56,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
match handle_shared_plugin_event(
app_handle.db_manager().inner(),
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
&event.payload,
SharedPluginEventContext {
plugin_name: &plugin_name,
@@ -201,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,
@@ -227,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,
@@ -263,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,
@@ -313,8 +316,13 @@ async fn handle_host_plugin_request<R: Runtime>(
)
.await?;
// An ad-hoc request saves nothing, so the engine hands the body
// back and this reply is the only place the plugin can get it.
let body = http_response.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b));
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
http_response: http_response.response,
body,
})))
}
HostRequest::OpenWindow(req) => {
+77 -24
View File
@@ -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,18 +223,12 @@ 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?)
}
pub async fn cmd_plugin_init_errors(
plugin_manager: State<'_, PluginManager>,
) -> Result<Vec<(String, String)>> {
Ok(plugin_manager.take_init_errors().await)
}
pub async fn cmd_plugins_updates<R: Runtime>(
app_handle: AppHandle<R>,
) -> Result<PluginUpdatesResponse> {
@@ -223,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();
@@ -306,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();
@@ -345,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();
@@ -361,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);
});
}
+7 -24
View File
@@ -1,25 +1,8 @@
use serde_json::Value;
pub use yaak::render::{render_grpc_request, render_http_request};
use yaak_models::models::Environment;
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
//! One import path for rendering, wherever the pieces actually live.
//!
//! The request renderers are engine code; the template renderers moved to
//! `yaak-commands` when the template commands did. Callers in this crate do not
//! need to track which is which.
pub async fn render_template<T: TemplateCallback>(
template: &str,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<String> {
let vars = &make_vars_hashmap(environment_chain);
parse_and_render(template, vars, cb, &opt).await
}
pub async fn render_json_value<T: TemplateCallback>(
value: Value,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<Value> {
let vars = &make_vars_hashmap(environment_chain);
render_json_value_raw(value, vars, cb, opt).await
}
pub use yaak_models::render::{render_grpc_request, render_http_request};
pub use yaak_commands::render::{render_json_value, render_template};
+107
View File
@@ -0,0 +1,107 @@
#[cfg(target_os = "macos")]
use log::{error, info};
#[cfg(any(target_os = "macos", test))]
use std::path::{Path, PathBuf};
#[cfg(target_os = "macos")]
use std::process::{Command, Stdio};
#[cfg(target_os = "macos")]
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{AppHandle, Runtime};
#[cfg(target_os = "macos")]
static RELAUNCH_WITH_LAUNCH_SERVICES: AtomicBool = AtomicBool::new(false);
/// Restart the app without directly spawning the executable on macOS.
///
/// Tauri's current macOS restart path starts the executable from the dying
/// process. Besides inheriting stale process state, that bypasses
/// LaunchServices and can leave the replacement app running without an active
/// window. Defer the relaunch until `RunEvent::Exit`, when the event loop is
/// already shutting down, and hand it to LaunchServices instead.
pub fn request_restart<R: Runtime>(app_handle: &AppHandle<R>) {
#[cfg(target_os = "macos")]
if current_app_bundle().is_some() {
info!("Requesting restart through macOS LaunchServices");
RELAUNCH_WITH_LAUNCH_SERVICES.store(true, Ordering::SeqCst);
app_handle.exit(0);
return;
}
app_handle.request_restart();
}
/// Complete a pending macOS restart after Tauri has emitted its exit events.
pub fn relaunch_if_requested() {
#[cfg(target_os = "macos")]
{
if !RELAUNCH_WITH_LAUNCH_SERVICES.swap(false, Ordering::SeqCst) {
return;
}
let Some(bundle) = current_app_bundle() else {
error!("Failed to resolve the app bundle for restart");
return;
};
match Command::new("/usr/bin/open")
.arg("-n")
.arg(&bundle)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(_) => info!("Relaunching {} through LaunchServices", bundle.display()),
Err(error) => error!("Failed to relaunch through LaunchServices: {error}"),
}
}
}
#[cfg(target_os = "macos")]
fn current_app_bundle() -> Option<PathBuf> {
app_bundle_from_executable(&std::env::current_exe().ok()?)
}
#[cfg(any(target_os = "macos", test))]
fn app_bundle_from_executable(executable: &Path) -> Option<PathBuf> {
let macos_dir = executable.parent()?;
if macos_dir.file_name()? != "MacOS" {
return None;
}
let contents_dir = macos_dir.parent()?;
if contents_dir.file_name()? != "Contents" {
return None;
}
let bundle = contents_dir.parent()?;
if bundle.extension()? != "app" {
return None;
}
Some(bundle.to_owned())
}
#[cfg(test)]
mod tests {
use super::app_bundle_from_executable;
use std::path::{Path, PathBuf};
#[test]
fn resolves_macos_app_bundle() {
assert_eq!(
app_bundle_from_executable(Path::new(
"/Applications/Yaak.app/Contents/MacOS/yaak-app-client"
)),
Some(PathBuf::from("/Applications/Yaak.app"))
);
}
#[test]
fn ignores_unbundled_executable() {
assert_eq!(
app_bundle_from_executable(Path::new("/workspace/target/debug/yaak-app-client")),
None
);
}
}
+372 -76
View File
@@ -8,10 +8,13 @@
//! is the only way the frontend reaches any of it — one envelope,
//! `{ cmd, payload }`, exactly like the proxy app.
//!
//! Adapters exist so command implementations keep their natural Tauri
//! signatures (window, app handle, managed state) while the wire format stays
//! transport-agnostic: another host builds its router from the same schema with
//! a different context type and its own adapters, and the frontend cannot tell.
//! Command bodies live in one of two places. Host-independent ones are in
//! `yaak_commands`, written against its `Host` trait, which `ClientCtx`
//! implements below; their adapters are one line. The rest still have their
//! natural Tauri signatures (window, app handle, managed state) and their
//! adapters unpack the request for them. Either way the wire format stays
//! transport-agnostic: another host builds its router from the same schema
//! with its own `Host`, and the frontend cannot tell.
use crate::error::Result;
use crate::notifications::YaakNotifier;
@@ -19,7 +22,11 @@ use crate::updates::YaakUpdater;
use log::warn;
use serde::Serialize;
use tauri::{Manager, Runtime, State, WebviewWindow};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use yaak_commands::{Host, PluginHost};
use yaak_core::WorkspaceContext;
use yaak_crypto::manager::EncryptionManager;
use yaak_git::{
BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
@@ -27,13 +34,18 @@ use yaak_git::{
};
use yaak_grpc::manager::GrpcHandle;
use yaak_grpc::ServiceDefinition;
use yaak_models::blob_manager::BlobManager;
use yaak_models::models::{
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
WorkspaceMeta,
};
use yaak_models::util::BatchUpsertResult;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, ImportPlan};
use yaak_plugins::events::{
FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
@@ -41,11 +53,15 @@ use yaak_plugins::events::{
};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::native_template_functions::encrypt_secure_template_function;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_rpc::RpcRouter;
use yaak_rpc_schema::*;
use yaak_sse::sse::ServerSentEvent;
use yaak_sync::sync::SyncOp;
use yaak_templates::TemplateCallback;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_ws::WebsocketManager;
/// Per-call context: the window a command was invoked from.
@@ -65,6 +81,226 @@ impl<R: Runtime> Clone for ClientCtx<R> {
}
}
/// The desktop is a host: the client is the window, the session is the
/// window's URL, and the shared managers are Tauri managed state.
impl<R: Runtime> Host for ClientCtx<R> {
fn client_id(&self) -> &str {
self.window.label()
}
fn session(&self) -> WorkspaceContext {
self.window.workspace_context()
}
fn app_version(&self) -> String {
self.window.package_info().version.to_string()
}
fn query_manager(&self) -> &QueryManager {
self.window.state::<QueryManager>().inner()
}
fn blob_manager(&self) -> &BlobManager {
self.window.state::<BlobManager>().inner()
}
fn encryption_manager(&self) -> &EncryptionManager {
self.window.state::<EncryptionManager>().inner()
}
}
impl<R: Runtime> ClientCtx<R> {
/// The plugin runtime this window talks to, 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
}
}
/// The desktop answers all of these out of the `PluginManager` it already
/// runs — the Node sidecar. Each is a delegation, which is the point: the
/// operations are what the handlers need, and this is one host's way of
/// providing them.
impl<R: Runtime> PluginHost for ClientCtx<R> {
async fn loaded_plugin_metadata(&self, directory: &str) -> Option<PluginMetadata> {
let handle = self.pm().await.ok()?.get_plugin_by_dir(directory).await?;
Some(handle.info())
}
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
match self.pm().await {
Ok(pm) => pm.take_init_errors().await,
Err(_) => Vec::new(),
}
}
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
match self.pm().await {
Ok(pm) => pm.resolve_plugins_for_runtime_from_db(plugins).await,
Err(_) => plugins,
}
}
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.pm().await?.get_template_function_summaries(&self.plugin_context()).await?)
}
async fn template_function_config(
&self,
function_name: &str,
values: HashMap<String, JsonPrimitive>,
model_id: &str,
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
Ok(self
.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().await?.get_themes(&self.plugin_context()).await?)
}
async fn http_request_actions(
&self,
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
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().await?.get_websocket_request_actions(&self.plugin_context()).await?)
}
async fn grpc_request_actions(
&self,
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
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().await?.get_workspace_actions(&self.plugin_context()).await?)
}
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
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().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().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().await?.call_websocket_request_action(&self.plugin_context(), req).await?)
}
async fn call_workspace_action(
&self,
req: CallWorkspaceActionRequest,
) -> yaak_commands::Result<()> {
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().await?.call_folder_action(&self.plugin_context(), req).await?)
}
async fn http_authentication_summaries(
&self,
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
let results = self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
Ok(results.into_iter().map(|(_, a)| a).collect())
}
async fn http_authentication_config(
&self,
auth_name: &str,
values: HashMap<String, JsonPrimitive>,
model_id: &str,
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
Ok(self
.pm()
.await?
.get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id)
.await?)
}
async fn call_http_authentication_action(
&self,
auth_name: &str,
action_index: i32,
values: HashMap<String, JsonPrimitive>,
model_id: &str,
) -> yaak_commands::Result<()> {
Ok(self
.pm()
.await?
.call_http_authentication_action(
&self.plugin_context(),
auth_name,
action_index,
values,
model_id,
)
.await?)
}
async fn import_data(&self, content: &str) -> yaak_commands::Result<ImportResponse> {
Ok(self.pm().await?.import_data(&self.plugin_context(), content).await?)
}
async fn reload_plugins(&self, plugins: Vec<Plugin>) -> Vec<(String, String)> {
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().await?);
let encryption_manager = Arc::new(self.encryption_manager().clone());
Ok(encrypt_secure_template_function(
plugin_manager,
encryption_manager,
&self.plugin_context(),
template,
)?)
}
}
/// The one Tauri command. The payload is the yaak-rpc envelope's payload;
/// a missing payload means an empty one.
#[tauri::command]
@@ -158,11 +394,11 @@ async fn cmd_metadata<R: Runtime>(ctx: ClientCtx<R>, _req: CmdMetadataReq) -> Re
}
async fn cmd_template_tokens_to_string<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateTokensToStringReq) -> Result<String> {
Ok(crate::cmd_template_tokens_to_string(ctx.window.clone(), ctx.window.app_handle().clone(), req.tokens).await?)
Ok(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?)
}
async fn cmd_render_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdRenderTemplateReq) -> Result<String> {
Ok(crate::cmd_render_template(ctx.window.clone(), ctx.window.app_handle().clone(), &req.template, &req.workspace_id, req.environment_id.as_deref(), req.purpose, req.ignore_error).await?)
Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?)
}
async fn cmd_send_feedback<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendFeedbackReq) -> Result<()> {
@@ -189,8 +425,8 @@ async fn cmd_send_ephemeral_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendE
Ok(crate::cmd_send_ephemeral_request(req.request, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.clone(), ctx.window.app_handle().clone()).await?)
}
async fn cmd_format_json<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatJsonReq) -> Result<String> {
Ok(crate::cmd_format_json(&req.text).await?)
async fn cmd_format_json<R: Runtime>(ctx: ClientCtx<R>, req: CmdFormatJsonReq) -> Result<String> {
Ok(yaak_commands::data::cmd_format_json(ctx, req).await?)
}
async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphqlReq) -> Result<String> {
@@ -198,15 +434,15 @@ 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>> {
Ok(crate::cmd_http_response_body_path(ctx.window.app_handle().clone(), &req.response_id).await?)
Ok(yaak_commands::responses::cmd_http_response_body_path(ctx, req).await?)
}
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?)
Ok(yaak_commands::responses::cmd_http_request_body(ctx, req).await?)
}
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
@@ -214,83 +450,105 @@ async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsR
}
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
Ok(crate::cmd_get_http_response_events(ctx.window.app_handle().clone(), &req.response_id).await?)
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_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
Ok(crate::cmd_http_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_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_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
Ok(crate::cmd_websocket_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_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>> {
Ok(yaak_commands::actions::cmd_http_request_actions(ctx, req).await?)
}
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
Ok(yaak_commands::actions::cmd_websocket_request_actions(ctx, req).await?)
}
async fn cmd_call_websocket_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWebsocketRequestActionReq) -> Result<()> {
Ok(crate::cmd_call_websocket_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::actions::cmd_call_websocket_request_action(ctx, req).await?)
}
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
Ok(crate::cmd_workspace_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
Ok(yaak_commands::actions::cmd_workspace_actions(ctx, req).await?)
}
async fn cmd_call_workspace_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWorkspaceActionReq) -> Result<()> {
Ok(crate::cmd_call_workspace_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::actions::cmd_call_workspace_action(ctx, req).await?)
}
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
Ok(crate::cmd_folder_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
Ok(yaak_commands::actions::cmd_folder_actions(ctx, req).await?)
}
async fn cmd_call_folder_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallFolderActionReq) -> Result<()> {
Ok(crate::cmd_call_folder_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::actions::cmd_call_folder_action(ctx, req).await?)
}
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
Ok(crate::cmd_grpc_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
Ok(yaak_commands::actions::cmd_grpc_request_actions(ctx, req).await?)
}
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, _req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
Ok(crate::cmd_template_function_summaries(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
Ok(yaak_commands::templates::cmd_template_function_summaries(ctx, req).await?)
}
async fn cmd_template_function_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionConfigReq) -> Result<GetTemplateFunctionConfigResponse> {
Ok(crate::cmd_template_function_config(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.function_name, req.values, req.model, req.environment_id.as_deref()).await?)
Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?)
}
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
Ok(crate::cmd_get_http_authentication_summaries(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
Ok(yaak_commands::auth::cmd_get_http_authentication_summaries(ctx, req).await?)
}
async fn cmd_get_http_authentication_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationConfigReq) -> Result<GetHttpAuthenticationConfigResponse> {
Ok(crate::cmd_get_http_authentication_config(ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<EncryptionManager>(), &req.auth_name, req.values, req.model, req.environment_id.as_deref()).await?)
Ok(yaak_commands::auth::cmd_get_http_authentication_config(ctx, req).await?)
}
async fn cmd_call_http_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpRequestActionReq) -> Result<()> {
Ok(crate::cmd_call_http_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::actions::cmd_call_http_request_action(ctx, req).await?)
}
async fn cmd_call_grpc_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallGrpcRequestActionReq) -> Result<()> {
Ok(crate::cmd_call_grpc_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::actions::cmd_call_grpc_request_action(ctx, req).await?)
}
async fn cmd_call_http_authentication_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpAuthenticationActionReq) -> Result<()> {
Ok(crate::cmd_call_http_authentication_action(ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<EncryptionManager>(), &req.auth_name, req.action_index, req.values, req.model, req.environment_id.as_deref()).await?)
Ok(yaak_commands::auth::cmd_call_http_authentication_action(ctx, req).await?)
}
async fn cmd_curl_to_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
Ok(crate::cmd_curl_to_request(ctx.window.clone(), &req.command, ctx.window.app_handle().state::<PluginManager>(), &req.workspace_id).await?)
Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?)
}
async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -> Result<()> {
Ok(crate::cmd_export_data(ctx.window.app_handle().clone(), &req.export_path, req.workspace_ids.iter().map(|s| s.as_str()).collect(), req.include_private_environments).await?)
Ok(yaak_commands::data::cmd_export_data(ctx, req).await?)
}
async fn cmd_save_base64_to_binary<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveBase64ToBinaryReq) -> Result<()> {
@@ -298,35 +556,35 @@ async fn cmd_save_base64_to_binary<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveBa
}
async fn cmd_save_response<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveResponseReq) -> Result<()> {
Ok(crate::cmd_save_response(ctx.window.app_handle().clone(), &req.response_id, &req.filepath).await?)
Ok(yaak_commands::responses::cmd_save_response(ctx, req).await?)
}
async fn cmd_send_http_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendHttpRequestReq) -> Result<HttpResponse> {
Ok(crate::cmd_send_http_request(ctx.window.app_handle().clone(), ctx.window.clone(), req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), req.request_id).await?)
}
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, _req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
Ok(crate::cmd_reload_plugins(ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
Ok(yaak_commands::actions::cmd_reload_plugins(ctx, req).await?)
}
async fn cmd_plugin_info<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
Ok(crate::cmd_plugin_info(&req.id, ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
Ok(yaak_commands::plugins::cmd_plugin_info(ctx, req).await?)
}
async fn cmd_delete_all_grpc_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllGrpcConnectionsReq) -> Result<()> {
Ok(crate::cmd_delete_all_grpc_connections(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
Ok(yaak_commands::models::cmd_delete_all_grpc_connections(ctx, req).await?)
}
async fn cmd_delete_send_history<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteSendHistoryReq) -> Result<()> {
Ok(crate::cmd_delete_send_history(&req.workspace_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
Ok(yaak_commands::models::cmd_delete_send_history(ctx, req).await?)
}
async fn cmd_delete_all_http_responses<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllHttpResponsesReq) -> Result<()> {
Ok(crate::cmd_delete_all_http_responses(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
Ok(yaak_commands::models::cmd_delete_all_http_responses(ctx, req).await?)
}
async fn cmd_get_workspace_meta<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetWorkspaceMetaReq) -> Result<WorkspaceMeta> {
Ok(crate::cmd_get_workspace_meta(ctx.window.app_handle().clone(), &req.workspace_id).await?)
Ok(yaak_commands::models::cmd_get_workspace_meta(ctx, req).await?)
}
async fn cmd_new_child_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewChildWindowReq) -> Result<()> {
@@ -342,71 +600,110 @@ async fn cmd_check_for_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdCheckForU
}
async fn cmd_decrypt_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdDecryptTemplateReq) -> Result<String> {
Ok(crate::commands::cmd_decrypt_template(ctx.window.clone(), &req.template).await?)
Ok(yaak_commands::encryption::cmd_decrypt_template(ctx, req).await?)
}
async fn cmd_secure_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdSecureTemplateReq) -> Result<String> {
Ok(crate::commands::cmd_secure_template(ctx.window.app_handle().clone(), ctx.window.clone(), &req.template).await?)
Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?)
}
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
Ok(crate::commands::cmd_get_themes(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
Ok(yaak_commands::templates::cmd_get_themes(ctx, req).await?)
}
async fn cmd_enable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdEnableEncryptionReq) -> Result<()> {
Ok(crate::commands::cmd_enable_encryption(ctx.window.clone(), &req.workspace_id).await?)
Ok(yaak_commands::encryption::cmd_enable_encryption(ctx, req).await?)
}
async fn cmd_reveal_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdRevealWorkspaceKeyReq) -> Result<String> {
Ok(crate::commands::cmd_reveal_workspace_key(ctx.window.clone(), &req.workspace_id).await?)
Ok(yaak_commands::encryption::cmd_reveal_workspace_key(ctx, req).await?)
}
async fn cmd_set_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdSetWorkspaceKeyReq) -> Result<()> {
Ok(crate::commands::cmd_set_workspace_key(ctx.window.clone(), &req.workspace_id, &req.key).await?)
Ok(yaak_commands::encryption::cmd_set_workspace_key(ctx, req).await?)
}
async fn cmd_disable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdDisableEncryptionReq) -> Result<()> {
Ok(crate::commands::cmd_disable_encryption(ctx.window.clone(), &req.workspace_id).await?)
Ok(yaak_commands::encryption::cmd_disable_encryption(ctx, req).await?)
}
async fn cmd_default_headers<R: Runtime>(_ctx: ClientCtx<R>, _req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
Ok(crate::commands::cmd_default_headers())
async fn cmd_default_headers<R: Runtime>(ctx: ClientCtx<R>, req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?)
}
async fn models_upsert<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertReq) -> Result<String> {
Ok(crate::models_ext::models_upsert(ctx.window.clone(), req.model)?)
Ok(yaak_commands::models::models_upsert(ctx, req).await?)
}
/// Runs on a blocking thread rather than the async runtime: a cascading delete
/// (a workspace with thousands of requests) holds a transaction for its whole
/// duration, and stalling the runtime stalls every other IPC call behind it.
/// That is this host's concern, so the shared handler stays plain and the
/// relocation happens here.
async fn models_delete<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDeleteReq) -> Result<String> {
Ok(crate::models_ext::models_delete(ctx.window.clone(), req.model).await?)
let deleted = tauri::async_runtime::spawn_blocking(move || {
yaak_commands::models::models_delete_blocking(&ctx, req)
})
.await
.map_err(|e| crate::error::Error::GenericError(format!("Delete task failed: {e}")))?;
Ok(deleted?)
}
async fn models_duplicate<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq) -> Result<String> {
Ok(crate::models_ext::models_duplicate(ctx.window.clone(), req.model_type, req.model_id)?)
Ok(yaak_commands::models::models_duplicate(ctx, req).await?)
}
async fn models_websocket_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWebsocketEventsReq) -> Result<Vec<WebsocketEvent>> {
Ok(crate::models_ext::models_websocket_events(ctx.window.app_handle().clone(), &req.connection_id)?)
Ok(yaak_commands::models::models_websocket_events(ctx, req).await?)
}
async fn models_grpc_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGrpcEventsReq) -> Result<Vec<GrpcEvent>> {
Ok(crate::models_ext::models_grpc_events(ctx.window.app_handle().clone(), &req.connection_id)?)
Ok(yaak_commands::models::models_grpc_events(ctx, req).await?)
}
async fn models_get_settings<R: Runtime>(ctx: ClientCtx<R>, _req: ModelsGetSettingsReq) -> Result<Settings> {
Ok(crate::models_ext::models_get_settings(ctx.window.app_handle().clone())?)
async fn models_get_settings<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetSettingsReq) -> Result<Settings> {
Ok(yaak_commands::models::models_get_settings(ctx, req).await?)
}
async fn models_get_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetGraphqlIntrospectionReq) -> Result<Option<GraphQlIntrospection>> {
Ok(crate::models_ext::models_get_graphql_introspection(ctx.window.app_handle().clone(), &req.request_id)?)
Ok(yaak_commands::models::models_get_graphql_introspection(ctx, req).await?)
}
async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertGraphqlIntrospectionReq) -> Result<GraphQlIntrospection> {
Ok(crate::models_ext::models_upsert_graphql_introspection(ctx.window.app_handle().clone(), &req.request_id, &req.workspace_id, req.content, ctx.window.clone())?)
Ok(yaak_commands::models::models_upsert_graphql_introspection(ctx, req).await?)
}
/// Non-ASCII is escaped to `\uXXXX` before the JSON crosses into the webview:
/// on Linux, sending Cyrillic (and possibly other) characters through this
/// payload leaves every string in the parsed models subtly mis-encoded and
/// CodeMirror unable to place the cursor (feedback: "editing the URL sometimes
/// freezes the app"). Escape sequences sidestep it. This is a quirk of the
/// webview transport, not of the data, so it lives in the adapter rather than
/// the shared handler.
async fn models_workspace_models<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWorkspaceModelsReq) -> Result<String> {
Ok(crate::models_ext::models_workspace_models(ctx.window.clone(), req.workspace_id.as_deref(), ctx.window.app_handle().state::<PluginManager>()).await?)
let json = yaak_commands::models::models_workspace_models(ctx, req).await?;
Ok(escape_str_for_webview(&json))
}
fn escape_str_for_webview(input: &str) -> String {
input
.chars()
.map(|c| {
let code = c as u32;
// ASCII
if code <= 0x7F {
c.to_string()
// BMP characters encoded normally
} else if code < 0xFFFF {
format!("\\u{:04X}", code)
// Beyond BMP encoded a surrogate pairs
} else {
let high = ((code - 0x10000) >> 10) + 0xD800;
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
format!("\\u{:04X}\\u{:04X}", high, low)
}
})
.collect()
}
async fn cmd_git_checkout<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitCheckoutReq) -> Result<String> {
@@ -538,7 +835,7 @@ async fn cmd_sync_apply<R: Runtime>(ctx: ClientCtx<R>, req: CmdSyncApplyReq) ->
}
async fn cmd_ws_delete_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsDeleteConnectionsReq) -> Result<()> {
Ok(crate::ws_ext::cmd_ws_delete_connections(&req.request_id, ctx.window.app_handle().clone(), ctx.window.clone()).await?)
Ok(yaak_commands::models::cmd_ws_delete_connections(ctx, req).await?)
}
async fn cmd_ws_send<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsSendReq) -> Result<WebsocketConnection> {
@@ -550,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> {
@@ -569,8 +866,8 @@ async fn cmd_plugins_uninstall<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsUni
Ok(crate::plugins_ext::cmd_plugins_uninstall(&req.plugin_id, ctx.window.clone()).await?)
}
async fn cmd_plugin_init_errors<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
Ok(crate::plugins_ext::cmd_plugin_init_errors(ctx.window.app_handle().state::<PluginManager>()).await?)
async fn cmd_plugin_init_errors<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
Ok(yaak_commands::plugins::cmd_plugin_init_errors(ctx, req).await?)
}
async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdatesReq) -> Result<PluginUpdatesResponse> {
@@ -580,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?)
}
+189 -15
View File
@@ -4,6 +4,7 @@ use std::time::{Duration, Instant};
use crate::error::Result;
use crate::models_ext::QueryManagerExt;
use crate::restart;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
@@ -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 {
+6 -35
View File
@@ -18,28 +18,17 @@ use yaak_http::cookies::CookieStore;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_models::models::{
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
WebsocketEventType, WebsocketRequest,
WebsocketEventType,
};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
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};
use yaak_tls::find_client_certificate;
use yaak_commands::resolve::resolve_websocket_request;
use yaak_ws::{WebsocketManager, render_websocket_request};
pub async fn cmd_ws_delete_connections<R: Runtime>(
request_id: &str,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> Result<()> {
Ok(app_handle.db().delete_all_websocket_connections_for_request(
request_id,
&UpdateSource::from_window_label(window.label()),
)?)
}
pub async fn cmd_ws_send<R: Runtime>(
connection_id: &str,
environment_id: Option<&str>,
@@ -86,8 +75,8 @@ async fn send_websocket_message<R: Runtime>(
environment_id,
)?;
let (resolved_request, _auth_context_id) =
resolve_websocket_request(&window, &unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
resolve_websocket_request(&window.db(), &unrendered_request)?;
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,
@@ -152,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)?;
@@ -165,8 +153,8 @@ pub async fn cmd_ws_connect<R: Runtime>(
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
let settings = app_handle.db().get_settings();
let (resolved_request, auth_context_id) =
resolve_websocket_request(&window, &unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
resolve_websocket_request(&window.db(), &unrendered_request)?;
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,
@@ -465,23 +453,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
Ok(connection)
}
/// Resolve inherited authentication and headers for a websocket request
fn resolve_websocket_request<R: Runtime>(
window: &WebviewWindow<R>,
request: &WebsocketRequest,
) -> Result<(WebsocketRequest, String)> {
let mut new_request = request.clone();
let (authentication_type, authentication, authentication_context_id) =
window.db().resolve_auth_for_websocket_request(request)?;
new_request.authentication_type = authentication_type;
new_request.authentication = authentication;
let headers = window.db().resolve_headers_for_websocket_request(request)?;
new_request.headers = headers;
Ok((new_request, authentication_context_id))
}
/// Convert WS URL to HTTP URL for cookie filtering
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
@@ -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 }
+74 -12
View File
@@ -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)
}
+1
View File
@@ -7,3 +7,4 @@ publish = false
[dependencies]
tauri = { workspace = true }
regex = "1.11.0"
yaak-core = { workspace = true }
+30 -15
View File
@@ -1,38 +1,53 @@
use regex::Regex;
use tauri::{Runtime, WebviewWindow};
use tauri::{Runtime, Url, WebviewWindow};
use yaak_core::WorkspaceContext;
pub trait WorkspaceWindowTrait {
fn workspace_id(&self) -> Option<String>;
fn cookie_jar_id(&self) -> Option<String>;
fn environment_id(&self) -> Option<String>;
fn request_id(&self) -> Option<String>;
/// All four at once, from a single read of the window URL.
fn workspace_context(&self) -> WorkspaceContext;
}
impl<R: Runtime> WorkspaceWindowTrait for WebviewWindow<R> {
fn workspace_id(&self) -> Option<String> {
let url = self.url().unwrap();
let re = Regex::new(r"/workspaces/(?<id>\w+)").unwrap();
match re.captures(url.as_str()) {
None => None,
Some(captures) => captures.name("id").map(|c| c.as_str().to_string()),
}
workspace_id_from_url(&self.url().unwrap())
}
fn cookie_jar_id(&self) -> Option<String> {
let url = self.url().unwrap();
let mut query_pairs = url.query_pairs();
query_pairs.find(|(k, _v)| k == "cookie_jar_id").map(|(_k, v)| v.to_string())
query_param(&self.url().unwrap(), "cookie_jar_id")
}
fn environment_id(&self) -> Option<String> {
let url = self.url().unwrap();
let mut query_pairs = url.query_pairs();
query_pairs.find(|(k, _v)| k == "environment_id").map(|(_k, v)| v.to_string())
query_param(&self.url().unwrap(), "environment_id")
}
fn request_id(&self) -> Option<String> {
query_param(&self.url().unwrap(), "request_id")
}
fn workspace_context(&self) -> WorkspaceContext {
let url = self.url().unwrap();
let mut query_pairs = url.query_pairs();
query_pairs.find(|(k, _v)| k == "request_id").map(|(_k, v)| v.to_string())
WorkspaceContext {
workspace_id: workspace_id_from_url(&url),
environment_id: query_param(&url, "environment_id"),
cookie_jar_id: query_param(&url, "cookie_jar_id"),
request_id: query_param(&url, "request_id"),
}
}
}
fn workspace_id_from_url(url: &Url) -> Option<String> {
let re = Regex::new(r"/workspaces/(?<id>\w+)").unwrap();
match re.captures(url.as_str()) {
None => None,
Some(captures) => captures.name("id").map(|c| c.as_str().to_string()),
}
}
fn query_param(url: &Url, key: &str) -> Option<String> {
let mut query_pairs = url.query_pairs();
query_pairs.find(|(k, _v)| k == key).map(|(_k, v)| v.to_string())
}
+13 -5
View File
@@ -9,12 +9,20 @@ chrono = { version = "0.4.38", features = ["serde"] }
include_dir = "0.7"
log = { workspace = true }
nanoid = "0.4.0"
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.25.0" }
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
sea-query-rusqlite = { version = "0.7.0", features = ["with-chrono"] }
rusqlite = { version = "0.38", features = ["bundled", "chrono"] }
sea-query-rusqlite = { version = "0.8.0", features = ["with-chrono"] }
sea-query = { version = "1.0", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
r2d2 = "0.8.10"
r2d2_sqlite = { version = "0.32" }
# nanoid pulls getrandom, which needs to be told how to reach the browser's
# CSPRNG on wasm32-unknown-unknown. Native targets are unaffected.
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
uuid = { version = "1", features = ["js"] }
@@ -1,9 +1,8 @@
use r2d2::PooledConnection;
use r2d2_sqlite::SqliteConnectionManager;
use crate::pool::SqliteConn;
use rusqlite::{Connection, Statement, ToSql, Transaction};
pub enum ConnectionOrTx<'a> {
Connection(PooledConnection<SqliteConnectionManager>),
Connection(SqliteConn),
Transaction(&'a Transaction<'a>),
}
@@ -3,6 +3,7 @@ use crate::error::Error::ModelNotFound;
use crate::error::Result;
use crate::traits::UpsertModelInfo;
use crate::update_source::UpdateSource;
use sea_query::ExprTrait;
use sea_query::{
Asterisk, Expr, Func, IntoColumnRef, IntoIden, OnConflict, Query, SimpleExpr,
SqliteQueryBuilder,
+1 -1
View File
@@ -7,7 +7,7 @@ pub enum Error {
SqlError(#[from] rusqlite::Error),
#[error("SQL Pool error: {0}")]
SqlPoolError(#[from] r2d2::Error),
SqlPoolError(#[from] crate::pool::PoolError),
#[error("Database error: {0}")]
Database(String),
+5 -2
View File
@@ -2,6 +2,7 @@ pub mod connection_or_tx;
pub mod db_context;
pub mod error;
pub mod migrate;
pub mod pool;
pub mod traits;
pub mod update_source;
pub mod util;
@@ -11,13 +12,15 @@ pub use connection_or_tx::ConnectionOrTx;
pub use db_context::DbContext;
pub use error::{Error, Result};
pub use migrate::run_migrations;
pub use pool::{PoolError, SqliteConn, SqlitePool};
pub use traits::{UpsertModelInfo, upsert_date};
pub use update_source::{ModelChangeEvent, UpdateSource};
pub use util::{generate_id, generate_id_of_length, generate_prefixed_id};
// Re-export pool types that consumers will need
// Re-export types that consumers will need
#[cfg(not(target_arch = "wasm32"))]
pub use r2d2;
#[cfg(not(target_arch = "wasm32"))]
pub use r2d2_sqlite;
pub use rusqlite;
pub use sea_query;
pub use sea_query_rusqlite;
+2 -3
View File
@@ -1,8 +1,7 @@
use crate::error::Result;
use crate::pool::SqlitePool;
use include_dir::Dir;
use log::{debug, info};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, params};
const TRACKING_TABLE: &str = "_sqlx_migrations";
@@ -11,7 +10,7 @@ const TRACKING_TABLE: &str = "_sqlx_migrations";
///
/// Migrations are sorted by filename (use timestamp prefixes like `00000001_init.sql`).
/// Applied migrations are tracked in `_sqlx_migrations`.
pub fn run_migrations(pool: &Pool<SqliteConnectionManager>, dir: &Dir<'_>) -> Result<()> {
pub fn run_migrations(pool: &SqlitePool, dir: &Dir<'_>) -> Result<()> {
info!("Running migrations");
// Create tracking table
+80
View File
@@ -0,0 +1,80 @@
//! Where connections come from.
//!
//! Every query in the model layer asks a pool for a connection, uses it, and
//! hands it back. That is the whole contract, and it is the one place the
//! desktop and the browser genuinely differ: the desktop has threads and wants
//! an r2d2 pool; a browser tab has one thread, no way to spawn another, and one
//! connection is exactly enough. Everything above this module is identical on
//! both.
//!
//! On native targets `SqlitePool` *is* `r2d2::Pool` — a type alias, so nothing
//! that already builds pools changes. On wasm it is one connection that every
//! `get()` hands out a shared handle to.
//!
//! A `SqliteConn` only ever derefs immutably. The code above this layer opens
//! transactions with [`rusqlite::Transaction::new_unchecked`], which takes
//! `&Connection`; the `&mut` that `Connection::transaction` demands is a
//! compile-time guard against nesting a transaction on one connection, and it
//! is what would have forced the wasm pool to lend its connection exclusively.
//! The model layer nests connections freely — a helper that already holds one
//! calls another that asks for its own — so an exclusive lend would panic on
//! the second ask. Sharing the handle instead makes nested *reads* work the way
//! they do on the desktop; nested *write transactions* fail on both, only
//! differently (here SQLite refuses the inner `BEGIN`; natively the inner
//! connection blocks on `busy_timeout` and then fails).
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use r2d2_sqlite::SqliteConnectionManager;
pub type SqlitePool = r2d2::Pool<SqliteConnectionManager>;
pub type SqliteConn = r2d2::PooledConnection<SqliteConnectionManager>;
pub type PoolError = r2d2::Error;
}
#[cfg(target_arch = "wasm32")]
mod imp {
use rusqlite::Connection;
use std::ops::Deref;
use std::rc::Rc;
/// One connection, shared by everyone who asks.
///
/// `Rc` rather than `Arc` because a `Connection` is `!Sync`, so wrapping
/// it in an `Arc` would buy no `Send`/`Sync` anyway — and there is one
/// thread here to be honest about.
#[derive(Clone, Debug)]
pub struct SqlitePool {
conn: Rc<Connection>,
}
impl SqlitePool {
pub fn single(conn: Connection) -> Self {
Self { conn: Rc::new(conn) }
}
/// Another handle to the connection. Cannot fail; the `Result` keeps
/// the signature identical to r2d2's so callers are written once.
pub fn get(&self) -> Result<SqliteConn, PoolError> {
Ok(SqliteConn(self.conn.clone()))
}
}
/// The error a `get()` would return if it could. It can't, so this has no
/// variants; it exists so `Error::SqlPoolError` has the same shape on both
/// targets.
#[derive(Debug, thiserror::Error)]
pub enum PoolError {}
#[derive(Debug)]
pub struct SqliteConn(Rc<Connection>);
impl Deref for SqliteConn {
type Target = Connection;
fn deref(&self) -> &Connection {
&self.0
}
}
}
pub use imp::*;
+466 -60
View File
@@ -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, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponse = {
model: "http_response";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
contentLength: number | null;
contentLengthCompressed: number | null;
elapsed: number;
elapsedHeaders: number;
elapsedDns: number;
error: string | null;
headers: Array<HttpResponseHeader>;
remoteAddr: string | null;
requestContentLength: number | null;
requestHeaders: Array<HttpResponseHeader>;
status: number;
statusReason: string | null;
state: HttpResponseState;
url: string;
version: string | null;
};
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
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;
};
File diff suppressed because one or more lines are too long
+100 -2
View File
@@ -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";
+34 -4
View File
@@ -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) -> (),
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "yaak-commands"
version = "0.0.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
serde_json = { workspace = true }
thiserror = { workspace = true }
yaak = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-rpc-schema = { workspace = true }
yaak-templates = { workspace = true }
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+157
View File
@@ -0,0 +1,157 @@
//! The actions plugins contribute to the UI, and the calls that run them.
//!
//! Listing is a plain question for the plugin runtime. Calling is not: the
//! frontend sends back the model it was showing, and a plugin must act on what
//! that model *actually is* — re-read from the database, with inheritance
//! resolved — not on a snapshot the UI has been holding. That re-reading is the
//! work these handlers do.
use crate::error::{Error, Result};
use crate::host::PluginHost;
use crate::resolve::{resolve_grpc_request, resolve_http_request};
use yaak_models::models::HttpRequest;
use yaak_plugins::events::{
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
CallWorkspaceActionRequest, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpRequestActionsResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse,
};
use yaak_rpc_schema::*;
// -- Listing --
pub async fn cmd_http_request_actions<H: PluginHost>(
host: H,
_req: CmdHttpRequestActionsReq,
) -> Result<Vec<GetHttpRequestActionsResponse>> {
host.http_request_actions().await
}
pub async fn cmd_websocket_request_actions<H: PluginHost>(
host: H,
_req: CmdWebsocketRequestActionsReq,
) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
host.websocket_request_actions().await
}
pub async fn cmd_grpc_request_actions<H: PluginHost>(
host: H,
_req: CmdGrpcRequestActionsReq,
) -> Result<Vec<GetGrpcRequestActionsResponse>> {
host.grpc_request_actions().await
}
pub async fn cmd_workspace_actions<H: PluginHost>(
host: H,
_req: CmdWorkspaceActionsReq,
) -> Result<Vec<GetWorkspaceActionsResponse>> {
host.workspace_actions().await
}
pub async fn cmd_folder_actions<H: PluginHost>(
host: H,
_req: CmdFolderActionsReq,
) -> Result<Vec<GetFolderActionsResponse>> {
host.folder_actions().await
}
// -- Calling --
pub async fn cmd_call_http_request_action<H: PluginHost>(
host: H,
req: CmdCallHttpRequestActionReq,
) -> Result<()> {
let inner = req.req;
let http_request = resolve_http_request(&host.db(), &inner.args.http_request)?.0;
host.call_http_request_action(CallHttpRequestActionRequest {
args: CallHttpRequestActionArgs { http_request },
..inner
})
.await
}
pub async fn cmd_call_grpc_request_action<H: PluginHost>(
host: H,
req: CmdCallGrpcRequestActionReq,
) -> Result<()> {
let inner = req.req;
let grpc_request = resolve_grpc_request(&host.db(), &inner.args.grpc_request)?.0;
host.call_grpc_request_action(CallGrpcRequestActionRequest {
args: CallGrpcRequestActionArgs { grpc_request, ..inner.args },
..inner
})
.await
}
pub async fn cmd_call_websocket_request_action<H: PluginHost>(
host: H,
req: CmdCallWebsocketRequestActionReq,
) -> Result<()> {
let inner = req.req;
let websocket_request = host.db().get_websocket_request(&inner.args.websocket_request.id)?;
host.call_websocket_request_action(CallWebsocketRequestActionRequest {
args: CallWebsocketRequestActionArgs { websocket_request },
..inner
})
.await
}
pub async fn cmd_call_workspace_action<H: PluginHost>(
host: H,
req: CmdCallWorkspaceActionReq,
) -> Result<()> {
let inner = req.req;
let workspace = host.db().get_workspace(&inner.args.workspace.id)?;
host.call_workspace_action(CallWorkspaceActionRequest {
args: CallWorkspaceActionArgs { workspace },
..inner
})
.await
}
pub async fn cmd_call_folder_action<H: PluginHost>(
host: H,
req: CmdCallFolderActionReq,
) -> Result<()> {
let inner = req.req;
let folder = host.db().get_folder(&inner.args.folder.id)?;
host.call_folder_action(CallFolderActionRequest {
args: CallFolderActionArgs { folder },
..inner
})
.await
}
// -- Other things the plugin runtime does --
/// Turn a `curl` command line into an unsaved request, by handing it to the
/// same importer plugins that read files.
pub async fn cmd_curl_to_request<H: PluginHost>(
host: H,
req: CmdCurlToRequestReq,
) -> Result<HttpRequest> {
let imported = host.import_data(&req.command).await?;
let request = imported
.resources
.http_requests
.first()
.ok_or_else(|| Error::Generic("No curl command found".to_string()))?;
// Belongs to the workspace the user is importing into, and is not saved
// until they say so — hence the blank id.
let mut request = request.clone();
request.workspace_id = req.workspace_id;
request.id = String::new();
Ok(request)
}
/// Restart every plugin, returning whatever failed to come back up.
pub async fn cmd_reload_plugins<H: PluginHost>(
host: H,
_req: CmdReloadPluginsReq,
) -> Result<Vec<(String, String)>> {
let plugins = host.db().list_plugins()?;
Ok(host.reload_plugins(plugins).await)
}
+62
View File
@@ -0,0 +1,62 @@
//! Authentication config forms and their actions.
//!
//! Both commands here do the same preparation: the frontend sends the model
//! whose auth is being edited plus the values currently in the form, and those
//! values may contain templates. They have to be rendered against the model's
//! own environment chain before a plugin sees them, or an auth plugin receives
//! `${[ api_key ]}` where it expected a key.
use crate::error::Result;
use crate::host::PluginHost;
use crate::render::render_form_values;
use yaak_plugins::events::{
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, RenderPurpose,
};
use yaak_rpc_schema::*;
use yaak_templates::RenderOptions;
pub async fn cmd_get_http_authentication_summaries<H: PluginHost>(
host: H,
_req: CmdGetHttpAuthenticationSummariesReq,
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
host.http_authentication_summaries().await
}
pub async fn cmd_get_http_authentication_config<H: PluginHost>(
host: H,
req: CmdGetHttpAuthenticationConfigReq,
) -> Result<GetHttpAuthenticationConfigResponse> {
// A config form is being displayed, so a template that cannot resolve
// should show as blank rather than refuse to open the form.
let values = render_form_values(
&host,
&req.model,
req.environment_id.as_deref(),
req.values,
RenderPurpose::Preview,
&RenderOptions::return_empty(),
)
.await?;
host.http_authentication_config(&req.auth_name, values, req.model.id()).await
}
pub async fn cmd_call_http_authentication_action<H: PluginHost>(
host: H,
req: CmdCallHttpAuthenticationActionReq,
) -> Result<()> {
// An action actually uses these values, so an unresolvable template is an
// error rather than an empty string that would silently authenticate wrong.
let values = render_form_values(
&host,
&req.model,
req.environment_id.as_deref(),
req.values,
RenderPurpose::Send,
&RenderOptions::throw(),
)
.await?;
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
.await
}

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